diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts index 9f99b59..3352ba6 100644 --- a/src/__tests__/manual-create.test.ts +++ b/src/__tests__/manual-create.test.ts @@ -13,7 +13,10 @@ afterEach(async () => { await prisma.orders.deleteMany({ where: { id } }); createdProjectIds.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 = { @@ -35,7 +38,7 @@ function failResult(res: unknown): never { } 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({ ...baseOrder, scope_title: "Ruční zakázka", @@ -48,7 +51,11 @@ describe("createOrder (manual, optional linked project)", () => { where: { id: res.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); }); @@ -60,8 +67,8 @@ describe("createOrder (manual, optional linked project)", () => { }); }); -describe("shared pool coherence (tracking)", () => { - it("order → standalone project → order produce three distinct shared numbers", async () => { +describe("separate order vs project sequences (coherence)", () => { + it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => { const o1 = await createOrder({ ...baseOrder, create_project: true }); if (!("id" in o1)) failResult(o1); createdOrderIds.push(o1.id!); @@ -75,7 +82,9 @@ describe("shared pool coherence (tracking)", () => { if (!("id" in o2)) failResult(o2); 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(p.project_number).toBeTruthy(); expect(o2.order_number).toBeTruthy(); @@ -86,7 +95,7 @@ describe("shared pool coherence (tracking)", () => { }); 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({ name: "Test projekt", status: "aktivni", diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index 08a72f5..b41d087 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -6,6 +6,9 @@ import { isSharedNumberTaken, previewOfferNumber, isOfferNumberTaken, + generateProjectNumber, + previewProjectNumber, + isProjectNumberTaken, } from "../services/numbering.service"; 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", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index efb2059..231d86e 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -13,6 +13,7 @@ const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}"; const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}"; const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}"; const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}"; +const DEFAULT_PROJECT_PATTERN = "{YY}{CODE}{NNNN}"; /** * Apply a numbering pattern template. @@ -46,6 +47,8 @@ async function getSettings() { invoice_number_pattern: true, issued_order_number_pattern: true, issued_order_type_code: true, + project_number_pattern: true, + project_type_code: true, }, }); } @@ -156,7 +159,9 @@ async function releaseSequence( : type === "issued_order" ? settings?.issued_order_number_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 = type === "shared" ? settings?.order_type_code || "71" @@ -164,7 +169,9 @@ async function releaseSequence( ? settings?.invoice_type_code || "81" : type === "issued_order" ? settings?.issued_order_type_code || "72" - : ""; + : type === "project" + ? settings?.project_type_code || "73" + : ""; const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : ""; // Lock the sequence row for the duration of the decrement so a concurrent @@ -237,6 +244,14 @@ export async function isOrderNumberTaken(number: string): Promise { return !!existing; } +/** Verify a project number is not already used. */ +export async function isProjectNumberTaken(number: string): Promise { + const existing = await prisma.projects.findFirst({ + where: { project_number: number }, + }); + return !!existing; +} + /** * Next offer/quotation number (consumes sequence). * Verifies uniqueness against the quotations table; retries if taken. @@ -330,6 +345,54 @@ export async function previewSharedNumber(): Promise { 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 { + 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 { + 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). * 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). */ export async function releaseInvoiceNumber( year?: number, diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index c79cb21..d6c3dee 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -5,6 +5,7 @@ import { previewSharedNumber, releaseSharedNumber, isOrderNumberTaken, + generateProjectNumber, } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; @@ -241,7 +242,10 @@ export async function createOrderFromQuotation( const result = await prisma.$transaction(async (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({ data: { @@ -299,7 +303,7 @@ export async function createOrderFromQuotation( data: { project_number: projectNumber, name: - quotation.project_code || quotation.quotation_number || orderNumber, + quotation.project_code || quotation.quotation_number || projectNumber, customer_id: quotation.customer_id, quotation_id: quotationId, order_id: order.id, @@ -429,13 +433,16 @@ export async function createOrder( let projectId: number | undefined; let createdProject: { project_number: string; name: string } | null = null; - // Only auto-create a project for genuine offer-less orders; share the - // order's number (same shared pool) exactly like the from-quotation flow. + // Only auto-create a project for genuine offer-less orders. The project + // 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) { + const projectNumber = await generateProjectNumber(tx); const project = await tx.projects.create({ data: { - project_number: orderNumber, - name: body.scope_title || body.customer_order_number || orderNumber, + project_number: projectNumber, + name: + body.scope_title || body.customer_order_number || projectNumber, customer_id: order.customer_id, order_id: order.id, status: "aktivni", diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 204ad2b..9783970 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -1,8 +1,8 @@ import prisma from "../config/database"; import { - generateSharedNumber, - previewSharedNumber, - releaseSharedNumber, + generateProjectNumber, + previewProjectNumber, + releaseProjectNumber, } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; 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_number = await generateSharedNumber(tx); + const project_number = await generateProjectNumber(tx); return tx.projects.create({ data: { project_number, @@ -210,9 +210,9 @@ export async function createProject(data: CreateProjectInput) { 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 { - return previewSharedNumber(); + return previewProjectNumber(); } 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 ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); - await releaseSharedNumber(year, existing.project_number ?? undefined); + await releaseProjectNumber(year, existing.project_number ?? undefined); return existing; }