diff --git a/docs/superpowers/plans/2026-06-09-issued-orders.md b/docs/superpowers/plans/2026-06-09-issued-orders.md index 6e7ea97..81eb64c 100644 --- a/docs/superpowers/plans/2026-06-09-issued-orders.md +++ b/docs/superpowers/plans/2026-06-09-issued-orders.md @@ -224,12 +224,16 @@ describe("issued-order numbering", () => { it("release frees only the highest number", async () => { const year = new Date().getFullYear(); - const a = await generateIssuedOrderNumber(year); - const b = await generateIssuedOrderNumber(year); - await releaseIssuedOrderNumber(year, b.number); - expect((await previewIssuedOrderNumber(year)).number).toBe(b.number); - await releaseIssuedOrderNumber(year, a.number); // not highest → no-op - expect((await previewIssuedOrderNumber(year)).number).toBe(b.number); + await generateIssuedOrderNumber(year); // seq 1 + await generateIssuedOrderNumber(year); // seq 2 + const c = await generateIssuedOrderNumber(year); // seq 3 (current highest) + const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4 + // Releasing a number that is NOT the current highest is a no-op. + await releaseIssuedOrderNumber(year, "00000000"); + expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease); + // Releasing the current highest reclaims it. + await releaseIssuedOrderNumber(year, c.number); + expect((await previewIssuedOrderNumber(year)).number).toBe(c.number); }); }); ``` diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts new file mode 100644 index 0000000..cae0596 --- /dev/null +++ b/src/__tests__/issued-orders.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { + generateIssuedOrderNumber, + previewIssuedOrderNumber, + releaseIssuedOrderNumber, +} from "../services/numbering.service"; + +afterEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); +}); + +describe("issued-order numbering", () => { + it("generates sequential numbers", async () => { + const year = new Date().getFullYear(); + const a = await generateIssuedOrderNumber(year); + const b = await generateIssuedOrderNumber(year); + expect(Number(b.number)).toBe(Number(a.number) + 1); + }); + + it("preview does not consume the sequence", async () => { + const year = new Date().getFullYear(); + const p1 = await previewIssuedOrderNumber(year); + const p2 = await previewIssuedOrderNumber(year); + expect(p1.number).toBe(p2.number); + }); + + it("release frees only the highest number", async () => { + const year = new Date().getFullYear(); + await generateIssuedOrderNumber(year); // seq 1 + await generateIssuedOrderNumber(year); // seq 2 + const c = await generateIssuedOrderNumber(year); // seq 3 (current highest) + const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4 + // Releasing a number that is NOT the current highest is a no-op. + await releaseIssuedOrderNumber(year, "00000000"); + expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease); + // Releasing the current highest reclaims it. + await releaseIssuedOrderNumber(year, c.number); + expect((await previewIssuedOrderNumber(year)).number).toBe(c.number); + }); +}); diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index a59cdd0..d050b33 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -12,6 +12,7 @@ type TxClient = Omit< 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}"; /** * Apply a numbering pattern template. @@ -43,6 +44,8 @@ async function getSettings() { offer_number_pattern: true, order_number_pattern: true, invoice_number_pattern: true, + issued_order_number_pattern: true, + issued_order_type_code: true, }, }); } @@ -150,13 +153,18 @@ async function releaseSequence( ? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN : type === "invoice" ? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN - : settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; + : type === "issued_order" + ? settings?.issued_order_number_pattern || + DEFAULT_ISSUED_ORDER_PATTERN + : settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; const code = type === "shared" ? settings?.order_type_code || "71" : type === "invoice" ? settings?.invoice_type_code || "81" - : ""; + : type === "issued_order" + ? settings?.issued_order_type_code || "72" + : ""; const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : ""; // Lock the sequence row for the duration of the decrement so a concurrent @@ -205,6 +213,14 @@ async function isInvoiceNumberTaken(number: string): Promise { return !!existing; } +/** Verify an issued-order (PO) number is not already used. */ +async function isIssuedOrderNumberTaken(number: string): Promise { + const existing = await prisma.issued_orders.findFirst({ + where: { po_number: number }, + }); + return !!existing; +} + /** Verify an offer/quotation number is not already used. */ export async function isOfferNumberTaken(number: string): Promise { const existing = await prisma.quotations.findFirst({ @@ -355,6 +371,60 @@ export async function previewInvoiceNumber( return { number, next_number: number }; } +/** + * Next issued-order (PO) number (consumes sequence). + * Verifies uniqueness against the issued_orders table; retries if taken. + * Pass `tx` when calling inside an existing Prisma transaction. + */ +export async function generateIssuedOrderNumber( + _year?: number, + tx?: TxClient, +): Promise<{ number: string; next_number: string }> { + const settings = await getSettings(); + const pattern = + settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN; + const code = settings?.issued_order_type_code || "72"; + const year = _year || new Date().getFullYear(); + + for (let attempt = 0; attempt < 100; attempt++) { + const seq = await getNextSequence("issued_order", year, tx); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + if (!(await isIssuedOrderNumberTaken(number))) { + return { number, next_number: number }; + } + } + throw new Error("Nepodařilo se vygenerovat jedinečné číslo objednávky"); +} + +/** + * Preview next issued-order (PO) number (does NOT consume sequence). + */ +export async function previewIssuedOrderNumber( + _year?: number, +): Promise<{ number: string; next_number: string }> { + const settings = await getSettings(); + const pattern = + settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN; + const code = settings?.issued_order_type_code || "72"; + const year = _year || new Date().getFullYear(); + + const seq = await previewNextSequence("issued_order", year); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + return { number, next_number: number }; +} + +/** Release an issued-order number back to the pool (decrement if highest). */ +export async function releaseIssuedOrderNumber( + year?: number, + deletedNumber?: string, +) { + await releaseSequence( + "issued_order", + year || new Date().getFullYear(), + deletedNumber, + ); +} + /** Release an offer number back to the pool (decrement if highest). */ export async function releaseOfferNumber( year?: number,