feat(numbering): projects get their own dedicated number sequence (decoupled from orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,10 @@ afterEach(async () => {
|
|||||||
await prisma.orders.deleteMany({ where: { id } });
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
createdProjectIds.length = 0;
|
createdProjectIds.length = 0;
|
||||||
createdOrderIds.length = 0;
|
createdOrderIds.length = 0;
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
// Orders and projects now draw from SEPARATE sequences — reset both.
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["shared", "project"] } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseOrder = {
|
const baseOrder = {
|
||||||
@@ -35,7 +38,7 @@ function failResult(res: unknown): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createOrder (manual, optional linked project)", () => {
|
describe("createOrder (manual, optional linked project)", () => {
|
||||||
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
|
||||||
const res = await createOrder({
|
const res = await createOrder({
|
||||||
...baseOrder,
|
...baseOrder,
|
||||||
scope_title: "Ruční zakázka",
|
scope_title: "Ruční zakázka",
|
||||||
@@ -48,7 +51,11 @@ describe("createOrder (manual, optional linked project)", () => {
|
|||||||
where: { id: res.project_id! },
|
where: { id: res.project_id! },
|
||||||
});
|
});
|
||||||
if (project) createdProjectIds.push(project.id);
|
if (project) createdProjectIds.push(project.id);
|
||||||
expect(project?.project_number).toBe(res.order_number);
|
// The auto-created project now draws from the dedicated project sequence,
|
||||||
|
// so its number is distinct from the order's shared number. They relate
|
||||||
|
// only via the order_id FK, not by sharing a number.
|
||||||
|
expect(project?.project_number).toBeTruthy();
|
||||||
|
expect(project?.project_number).not.toBe(res.order_number);
|
||||||
expect(project?.order_id).toBe(res.id);
|
expect(project?.order_id).toBe(res.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,8 +67,8 @@ describe("createOrder (manual, optional linked project)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shared pool coherence (tracking)", () => {
|
describe("separate order vs project sequences (coherence)", () => {
|
||||||
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
||||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||||
if (!("id" in o1)) failResult(o1);
|
if (!("id" in o1)) failResult(o1);
|
||||||
createdOrderIds.push(o1.id!);
|
createdOrderIds.push(o1.id!);
|
||||||
@@ -75,7 +82,9 @@ describe("shared pool coherence (tracking)", () => {
|
|||||||
if (!("id" in o2)) failResult(o2);
|
if (!("id" in o2)) failResult(o2);
|
||||||
createdOrderIds.push(o2.id!);
|
createdOrderIds.push(o2.id!);
|
||||||
|
|
||||||
// All three numbers must be present (non-null) AND distinct.
|
// All three numbers must be present (non-null) AND distinct. Orders draw
|
||||||
|
// from the `shared` sequence (code 71); projects from the dedicated
|
||||||
|
// `project` sequence (code 73), so they never collide.
|
||||||
expect(o1.order_number).toBeTruthy();
|
expect(o1.order_number).toBeTruthy();
|
||||||
expect(p.project_number).toBeTruthy();
|
expect(p.project_number).toBeTruthy();
|
||||||
expect(o2.order_number).toBeTruthy();
|
expect(o2.order_number).toBeTruthy();
|
||||||
@@ -86,7 +95,7 @@ describe("shared pool coherence (tracking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createProject (manual, standalone)", () => {
|
describe("createProject (manual, standalone)", () => {
|
||||||
it("assigns a shared number and is not linked to an order", async () => {
|
it("assigns a project number and is not linked to an order", async () => {
|
||||||
const result = await createProject({
|
const result = await createProject({
|
||||||
name: "Test projekt",
|
name: "Test projekt",
|
||||||
status: "aktivni",
|
status: "aktivni",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
isSharedNumberTaken,
|
isSharedNumberTaken,
|
||||||
previewOfferNumber,
|
previewOfferNumber,
|
||||||
isOfferNumberTaken,
|
isOfferNumberTaken,
|
||||||
|
generateProjectNumber,
|
||||||
|
previewProjectNumber,
|
||||||
|
isProjectNumberTaken,
|
||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
|
||||||
@@ -77,6 +80,38 @@ describe("generateSharedNumber", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generateProjectNumber", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a non-empty string", async () => {
|
||||||
|
const num = await generateProjectNumber();
|
||||||
|
expect(typeof num).toBe("string");
|
||||||
|
expect(num.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||||
|
// Seed high so neither generated number collides with a real project →
|
||||||
|
// the skip-taken retry never fires and the increment is strictly +1.
|
||||||
|
await seedSequence("project", 900000);
|
||||||
|
const num1 = await generateProjectNumber();
|
||||||
|
const num2 = await generateProjectNumber();
|
||||||
|
assertStrictIncrement(num1, num2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previewProjectNumber returns a number not yet taken by a project", async () => {
|
||||||
|
const preview = await previewProjectNumber();
|
||||||
|
expect(typeof preview).toBe("string");
|
||||||
|
expect(preview.length).toBeGreaterThan(0);
|
||||||
|
expect(await isProjectNumberTaken(preview)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("generateOfferNumber", () => {
|
describe("generateOfferNumber", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
|
|||||||
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
|
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
|
const DEFAULT_PROJECT_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a numbering pattern template.
|
* Apply a numbering pattern template.
|
||||||
@@ -46,6 +47,8 @@ async function getSettings() {
|
|||||||
invoice_number_pattern: true,
|
invoice_number_pattern: true,
|
||||||
issued_order_number_pattern: true,
|
issued_order_number_pattern: true,
|
||||||
issued_order_type_code: true,
|
issued_order_type_code: true,
|
||||||
|
project_number_pattern: true,
|
||||||
|
project_type_code: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,9 @@ async function releaseSequence(
|
|||||||
: type === "issued_order"
|
: type === "issued_order"
|
||||||
? settings?.issued_order_number_pattern ||
|
? settings?.issued_order_number_pattern ||
|
||||||
DEFAULT_ISSUED_ORDER_PATTERN
|
DEFAULT_ISSUED_ORDER_PATTERN
|
||||||
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
: type === "project"
|
||||||
|
? settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN
|
||||||
|
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
||||||
const code =
|
const code =
|
||||||
type === "shared"
|
type === "shared"
|
||||||
? settings?.order_type_code || "71"
|
? settings?.order_type_code || "71"
|
||||||
@@ -164,7 +169,9 @@ async function releaseSequence(
|
|||||||
? settings?.invoice_type_code || "81"
|
? settings?.invoice_type_code || "81"
|
||||||
: type === "issued_order"
|
: type === "issued_order"
|
||||||
? settings?.issued_order_type_code || "72"
|
? settings?.issued_order_type_code || "72"
|
||||||
: "";
|
: type === "project"
|
||||||
|
? settings?.project_type_code || "73"
|
||||||
|
: "";
|
||||||
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
||||||
|
|
||||||
// Lock the sequence row for the duration of the decrement so a concurrent
|
// Lock the sequence row for the duration of the decrement so a concurrent
|
||||||
@@ -237,6 +244,14 @@ export async function isOrderNumberTaken(number: string): Promise<boolean> {
|
|||||||
return !!existing;
|
return !!existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verify a project number is not already used. */
|
||||||
|
export async function isProjectNumberTaken(number: string): Promise<boolean> {
|
||||||
|
const existing = await prisma.projects.findFirst({
|
||||||
|
where: { project_number: number },
|
||||||
|
});
|
||||||
|
return !!existing;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next offer/quotation number (consumes sequence).
|
* Next offer/quotation number (consumes sequence).
|
||||||
* Verifies uniqueness against the quotations table; retries if taken.
|
* Verifies uniqueness against the quotations table; retries if taken.
|
||||||
@@ -330,6 +345,54 @@ export async function previewSharedNumber(): Promise<string> {
|
|||||||
return applyPattern(pattern, { year, prefix: "", code, seq });
|
return applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next project number (consumes the dedicated `project` sequence).
|
||||||
|
* Decoupled from the `shared` order sequence so a project (standalone OR
|
||||||
|
* auto-created from an order) gets its own number from a separate pool.
|
||||||
|
* Verifies uniqueness against the projects table; retries if taken.
|
||||||
|
* Pass `tx` when calling inside an existing Prisma transaction.
|
||||||
|
*/
|
||||||
|
export async function generateProjectNumber(tx?: TxClient): Promise<string> {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const pattern = settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN;
|
||||||
|
const code = settings?.project_type_code || "73";
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
const seq = await getNextSequence("project", year, tx);
|
||||||
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
|
if (!(await isProjectNumberTaken(number))) {
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Nepodařilo se vygenerovat jedinečné číslo projektu");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview next project number (does NOT consume sequence).
|
||||||
|
*/
|
||||||
|
export async function previewProjectNumber(): Promise<string> {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const pattern = settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN;
|
||||||
|
const code = settings?.project_type_code || "73";
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Mirror generateProjectNumber's collision check (without consuming the
|
||||||
|
// sequence). The number_sequences counter can lag behind real data — e.g.
|
||||||
|
// when projects were imported or seeded without bumping it — so the raw next
|
||||||
|
// sequence may already be taken. Advance past any number already used by a
|
||||||
|
// project, so the preview matches what generateProjectNumber would actually
|
||||||
|
// assign instead of showing a stale, already-used number.
|
||||||
|
let seq = await previewNextSequence("project", year);
|
||||||
|
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||||
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
|
if (!(await isProjectNumberTaken(number))) return number;
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
return applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next invoice number (consumes sequence).
|
* Next invoice number (consumes sequence).
|
||||||
* Verifies uniqueness against the invoices table; retries if taken.
|
* Verifies uniqueness against the invoices table; retries if taken.
|
||||||
@@ -519,6 +582,18 @@ export async function releaseSharedNumber(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release a project number back to the pool (decrement if highest). */
|
||||||
|
export async function releaseProjectNumber(
|
||||||
|
year?: number,
|
||||||
|
deletedNumber?: string,
|
||||||
|
) {
|
||||||
|
await releaseSequence(
|
||||||
|
"project",
|
||||||
|
year || new Date().getFullYear(),
|
||||||
|
deletedNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Release an invoice number back to the pool (decrement if highest). */
|
/** Release an invoice number back to the pool (decrement if highest). */
|
||||||
export async function releaseInvoiceNumber(
|
export async function releaseInvoiceNumber(
|
||||||
year?: number,
|
year?: number,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
previewSharedNumber,
|
previewSharedNumber,
|
||||||
releaseSharedNumber,
|
releaseSharedNumber,
|
||||||
isOrderNumberTaken,
|
isOrderNumberTaken,
|
||||||
|
generateProjectNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
|
|
||||||
@@ -241,7 +242,10 @@ export async function createOrderFromQuotation(
|
|||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const orderNumber = await generateSharedNumber(tx);
|
const orderNumber = await generateSharedNumber(tx);
|
||||||
const projectNumber = orderNumber;
|
// The order keeps the shared (order) sequence; the auto-created project
|
||||||
|
// gets its OWN number from the dedicated project sequence. Both are
|
||||||
|
// assigned inside this transaction so they're allocated atomically.
|
||||||
|
const projectNumber = await generateProjectNumber(tx);
|
||||||
|
|
||||||
const order = await tx.orders.create({
|
const order = await tx.orders.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -299,7 +303,7 @@ export async function createOrderFromQuotation(
|
|||||||
data: {
|
data: {
|
||||||
project_number: projectNumber,
|
project_number: projectNumber,
|
||||||
name:
|
name:
|
||||||
quotation.project_code || quotation.quotation_number || orderNumber,
|
quotation.project_code || quotation.quotation_number || projectNumber,
|
||||||
customer_id: quotation.customer_id,
|
customer_id: quotation.customer_id,
|
||||||
quotation_id: quotationId,
|
quotation_id: quotationId,
|
||||||
order_id: order.id,
|
order_id: order.id,
|
||||||
@@ -429,13 +433,16 @@ export async function createOrder(
|
|||||||
let projectId: number | undefined;
|
let projectId: number | undefined;
|
||||||
let createdProject: { project_number: string; name: string } | null =
|
let createdProject: { project_number: string; name: string } | null =
|
||||||
null;
|
null;
|
||||||
// Only auto-create a project for genuine offer-less orders; share the
|
// Only auto-create a project for genuine offer-less orders. The project
|
||||||
// order's number (same shared pool) exactly like the from-quotation flow.
|
// gets its OWN number from the dedicated project sequence (not the
|
||||||
|
// order's shared number), exactly like the from-quotation flow.
|
||||||
if (body.create_project !== false && body.quotation_id == null) {
|
if (body.create_project !== false && body.quotation_id == null) {
|
||||||
|
const projectNumber = await generateProjectNumber(tx);
|
||||||
const project = await tx.projects.create({
|
const project = await tx.projects.create({
|
||||||
data: {
|
data: {
|
||||||
project_number: orderNumber,
|
project_number: projectNumber,
|
||||||
name: body.scope_title || body.customer_order_number || orderNumber,
|
name:
|
||||||
|
body.scope_title || body.customer_order_number || projectNumber,
|
||||||
customer_id: order.customer_id,
|
customer_id: order.customer_id,
|
||||||
order_id: order.id,
|
order_id: order.id,
|
||||||
status: "aktivni",
|
status: "aktivni",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import {
|
import {
|
||||||
generateSharedNumber,
|
generateProjectNumber,
|
||||||
previewSharedNumber,
|
previewProjectNumber,
|
||||||
releaseSharedNumber,
|
releaseProjectNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
import type { CreateProjectInput } from "../schemas/projects.schema";
|
import type { CreateProjectInput } from "../schemas/projects.schema";
|
||||||
@@ -177,7 +177,7 @@ export async function createProject(data: CreateProjectInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await prisma.$transaction(async (tx) => {
|
const project = await prisma.$transaction(async (tx) => {
|
||||||
const project_number = await generateSharedNumber(tx);
|
const project_number = await generateProjectNumber(tx);
|
||||||
return tx.projects.create({
|
return tx.projects.create({
|
||||||
data: {
|
data: {
|
||||||
project_number,
|
project_number,
|
||||||
@@ -210,9 +210,9 @@ export async function createProject(data: CreateProjectInput) {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preview the next shared number for the create form (does NOT consume it). */
|
/** Preview the next project number for the create form (does NOT consume it). */
|
||||||
export async function getNextProjectNumber(): Promise<string> {
|
export async function getNextProjectNumber(): Promise<string> {
|
||||||
return previewSharedNumber();
|
return previewProjectNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||||
@@ -241,7 +241,7 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
|||||||
const year = existing.created_at
|
const year = existing.created_at
|
||||||
? new Date(existing.created_at).getFullYear()
|
? new Date(existing.created_at).getFullYear()
|
||||||
: new Date().getFullYear();
|
: new Date().getFullYear();
|
||||||
await releaseSharedNumber(year, existing.project_number ?? undefined);
|
await releaseProjectNumber(year, existing.project_number ?? undefined);
|
||||||
|
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user