diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts index 3352ba6..a71bb17 100644 --- a/src/__tests__/manual-create.test.ts +++ b/src/__tests__/manual-create.test.ts @@ -1,24 +1,48 @@ import { describe, it, expect, afterEach } from "vitest"; import prisma from "../config/database"; import { createProject } from "../services/projects.service"; -import { createOrder } from "../services/orders.service"; +import { + createOrder, + createOrderFromQuotation, + deleteOrder, +} from "../services/orders.service"; +import { previewProjectNumber } from "../services/numbering.service"; + +const YEAR = new Date().getFullYear(); const createdProjectIds: number[] = []; const createdOrderIds: number[] = []; +const createdQuotationIds: number[] = []; afterEach(async () => { for (const id of createdProjectIds) await prisma.projects.deleteMany({ where: { id } }); for (const id of createdOrderIds) await prisma.orders.deleteMany({ where: { id } }); + for (const id of createdQuotationIds) + await prisma.quotations.deleteMany({ where: { id } }); createdProjectIds.length = 0; createdOrderIds.length = 0; + createdQuotationIds.length = 0; // Orders and projects now draw from SEPARATE sequences — reset both. await prisma.number_sequences.deleteMany({ where: { type: { in: ["shared", "project"] } }, }); }); +/** + * Read the current `last_number` of a (type, current-year) sequence row, or 0 + * when the row doesn't exist yet (the first allocation creates it at 1). Used to + * prove each generator advances its OWN row by exactly 1. + */ +async function seqLastNumber(type: "shared" | "project"): Promise { + const row = await prisma.number_sequences.findFirst({ + where: { type, year: YEAR }, + select: { last_number: true }, + }); + return row?.last_number ?? 0; +} + const baseOrder = { status: "prijata" as const, currency: "CZK", @@ -39,6 +63,13 @@ function failResult(res: unknown): never { describe("createOrder (manual, optional linked project)", () => { it("creates an order AND a project, each with its OWN number, linked by FK", async () => { + // Capture BOTH sequence counters before the create so we can prove each + // generator advanced its OWN row (not that the project consumed the shared + // counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe` + // pass even if the project drew from `shared` — the deltas catch that. + const sharedBefore = await seqLastNumber("shared"); + const projectBefore = await seqLastNumber("project"); + const res = await createOrder({ ...baseOrder, scope_title: "Ruční zakázka", @@ -57,6 +88,15 @@ describe("createOrder (manual, optional linked project)", () => { expect(project?.project_number).toBeTruthy(); expect(project?.project_number).not.toBe(res.order_number); expect(project?.order_id).toBe(res.id); + + // Sequence isolation: the order consumed exactly one `shared` number and + // the project consumed exactly one `project` number — each generator + // advanced its OWN row by 1. A regression where the project draws from the + // `shared` counter would bump `shared` by 2 and leave `project` at 0. + const sharedAfter = await seqLastNumber("shared"); + const projectAfter = await seqLastNumber("project"); + expect(sharedAfter - sharedBefore).toBe(1); + expect(projectAfter - projectBefore).toBe(1); }); it("creates only the order when create_project=false", async () => { @@ -67,6 +107,82 @@ describe("createOrder (manual, optional linked project)", () => { }); }); +describe("createOrderFromQuotation (auto-project gets its OWN number)", () => { + it("links a project whose number is from the project sequence, distinct from the order number", async () => { + // Seed a minimal quotation (no items/sections needed — createOrderFrom + // Quotation copies whatever is present and both arrays may be empty). + const quotation = await prisma.quotations.create({ + data: { + quotation_number: `Q-FROMQ-${Date.now()}`, + status: "active", + currency: "CZK", + language: "cs", + vat_rate: 21, + apply_vat: true, + }, + }); + createdQuotationIds.push(quotation.id); + + const res = await createOrderFromQuotation({ + quotationId: quotation.id, + customerOrderNumber: "PO-FROMQ", + }); + if (!("data" in res) || !res.data) failResult(res); + const orderId = res.data.order_id; + createdOrderIds.push(orderId); + + const order = await prisma.orders.findUnique({ where: { id: orderId } }); + const project = await prisma.projects.findFirst({ + where: { order_id: orderId }, + }); + if (project) createdProjectIds.push(project.id); + + // Primary flow: the auto-created project must get a non-empty number from + // the dedicated `project` sequence (code 73), DISTINCT from the order's + // shared number (code 71). Pre-split they shared one number. + expect(project).toBeTruthy(); + expect(project?.project_number).toBeTruthy(); + expect(order?.order_number).toBeTruthy(); + expect(project?.project_number).not.toBe(order?.order_number); + expect(project?.order_id).toBe(orderId); + }); +}); + +describe("deleteOrder releases the project number (release regression)", () => { + it("frees the auto-created project's number back to the project sequence", async () => { + // The project sequence is reset in afterEach, so the preview before the + // create is the number createOrder will consume for the project. + const previewBefore = await previewProjectNumber(); + + const res = await createOrder({ + ...baseOrder, + scope_title: "Smazatelná zakázka", + create_project: true, + }); + if (!("id" in res)) failResult(res); + const orderId = res.id!; + expect(res.project_id).toBeTruthy(); + const project = await prisma.projects.findUnique({ + where: { id: res.project_id! }, + }); + expect(project?.project_number).toBe(previewBefore); + + // After the order delete, the project row is gone AND its number must have + // been released (decremented) so the preview returns the freed number — not + // a permanent gap. This is the fix #1 regression guard. + const del = await deleteOrder(orderId); + if ("error" in del) failResult(del); + + const gone = await prisma.projects.findUnique({ + where: { id: res.project_id! }, + }); + expect(gone).toBeNull(); + + const previewAfter = await previewProjectNumber(); + expect(previewAfter).toBe(previewBefore); + }); +}); + 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 }); diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index b41d087..b74953e 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -112,6 +112,55 @@ describe("generateProjectNumber", () => { }); }); +describe("cross-sequence isolation (shared vs project)", () => { + beforeEach(async () => { + await prisma.number_sequences.deleteMany({ + where: { type: { in: ["shared", "project"] } }, + }); + }); + + afterEach(async () => { + await prisma.number_sequences.deleteMany({ + where: { type: { in: ["shared", "project"] } }, + }); + }); + + it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => { + // Seed the two sequences to DISTINCT known highs so neither generated + // number collides with a real row (skip-taken retry never fires) and a + // cross-contamination bug is unambiguous. + await seedSequence("shared", 900000); + await seedSequence("project", 500000); + + await generateSharedNumber(); + // After a shared allocation only the shared row may have moved. + let shared = await prisma.number_sequences.findFirst({ + where: { type: "shared", year: YEAR }, + select: { last_number: true }, + }); + let project = await prisma.number_sequences.findFirst({ + where: { type: "project", year: YEAR }, + select: { last_number: true }, + }); + expect(shared?.last_number).toBe(900001); + expect(project?.last_number).toBe(500000); + + await generateProjectNumber(); + // After a project allocation only the project row may have moved; the + // shared row must still sit at 900001. + shared = await prisma.number_sequences.findFirst({ + where: { type: "shared", year: YEAR }, + select: { last_number: true }, + }); + project = await prisma.number_sequences.findFirst({ + where: { type: "project", year: YEAR }, + select: { last_number: true }, + }); + expect(shared?.last_number).toBe(900001); + expect(project?.last_number).toBe(500001); + }); +}); + describe("generateOfferNumber", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); diff --git a/src/admin/pages/CompanySettings.tsx b/src/admin/pages/CompanySettings.tsx index 2757abb..c6b5890 100644 --- a/src/admin/pages/CompanySettings.tsx +++ b/src/admin/pages/CompanySettings.tsx @@ -1166,6 +1166,9 @@ export default function CompanySettings({ prefixKey: null, codeKey: "order_type_code" as const, codeLabel: "Typový kód", + // Mirror the server generator's code fallback so the preview + // matches the real generated number when the field is empty. + defaultCode: "71", }, { label: "Objednávky vydané", @@ -1174,6 +1177,7 @@ export default function CompanySettings({ prefixKey: null, codeKey: "issued_order_type_code" as const, codeLabel: "Typový kód", + defaultCode: "72", }, { label: "Projekty", @@ -1182,6 +1186,7 @@ export default function CompanySettings({ prefixKey: null, codeKey: "project_type_code" as const, codeLabel: "Typový kód", + defaultCode: "73", }, { label: "Faktury", @@ -1190,6 +1195,7 @@ export default function CompanySettings({ prefixKey: null, codeKey: "invoice_type_code" as const, codeLabel: "Typový kód", + defaultCode: "81", }, { label: "Sklad — Příjmy", @@ -1220,7 +1226,11 @@ export default function CompanySettings({ const yyyy = String(new Date().getFullYear()); const yy = yyyy.slice(-2); const prefix = cfg.prefixKey ? form[cfg.prefixKey] || "NA" : ""; - const code = cfg.codeKey ? form[cfg.codeKey] || "" : ""; + const code = cfg.codeKey + ? form[cfg.codeKey] || + (cfg as { defaultCode?: string }).defaultCode || + "" + : ""; const preview = pattern.replace( /\{(\w+)\}/g, (m: string, k: string) => { diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index d6c3dee..1664dc6 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -6,6 +6,7 @@ import { releaseSharedNumber, isOrderNumberTaken, generateProjectNumber, + releaseProjectNumber, } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; @@ -731,6 +732,21 @@ export async function deleteOrder(id: number, deleteFiles = false) { : new Date().getFullYear(); await releaseSharedNumber(year, existing.order_number ?? undefined); + // Release each deleted project's OWN number from the dedicated `project` + // sequence. Auto-created projects now draw a distinct number from the + // `project` pool (not the order's `shared` number), so deleting the order + // must release the project number too — otherwise the highest project number + // leaves a permanent gap. Use each project's OWN created_at year: project + // numbers embed {YY} and the sequence is (type, year)-keyed, so the order's + // year could decrement the wrong row. Best-effort, post-commit, mirroring the + // shared release above — releaseSequence row-locks and only decrements when + // the deleted number is the current highest, so this is safe/idempotent. + for (const p of linkedProjects) { + if (!p.project_number) continue; + const projYear = p.created_at ? new Date(p.created_at).getFullYear() : year; + await releaseProjectNumber(projYear, p.project_number); + } + return { data: { id, order_number: existing.order_number } }; }