diff --git a/src/__tests__/drafts-deferred-numbering.test.ts b/src/__tests__/drafts-deferred-numbering.test.ts new file mode 100644 index 0000000..cb124e5 --- /dev/null +++ b/src/__tests__/drafts-deferred-numbering.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { + previewIssuedOrderNumber, + previewInvoiceNumber, + previewOfferNumber, +} from "../services/numbering.service"; +import { + createIssuedOrder, + updateIssuedOrder, + deleteIssuedOrder, +} from "../services/issued-orders.service"; +import { + createInvoice, + updateInvoice, + deleteInvoice, +} from "../services/invoices.service"; +import { + createOffer, + updateOffer, + deleteOffer, +} from "../services/offers.service"; + +// Track every row we create so the suite leaves the (real) app_test DB clean. +const issuedOrderIds: number[] = []; +const invoiceIds: number[] = []; +const offerIds: number[] = []; + +afterEach(async () => { + for (const id of issuedOrderIds) + await prisma.issued_orders.deleteMany({ where: { id } }); + for (const id of invoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + for (const id of offerIds) + await prisma.quotations.deleteMany({ where: { id } }); + issuedOrderIds.length = 0; + invoiceIds.length = 0; + offerIds.length = 0; + // Reset every sequence this suite may have touched so preview values are + // stable across tests and we don't leak consumed numbers into other files. + await prisma.number_sequences.deleteMany({ + where: { type: { in: ["issued_order", "invoice", "offer"] } }, + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Issued orders */ +/* -------------------------------------------------------------------------- */ + +describe("issued-order drafts — deferred numbering", () => { + it("a draft has no number and does NOT consume the sequence", async () => { + const before = (await previewIssuedOrderNumber()).number; + const draft = await createIssuedOrder({}); // defaults to draft + issuedOrderIds.push(draft.id); + expect(draft.status).toBe("draft"); + expect(draft.po_number).toBeNull(); + // Preview is unchanged → the sequence was not consumed. + const after = (await previewIssuedOrderNumber()).number; + expect(after).toBe(before); + }); + + it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => { + const expected = (await previewIssuedOrderNumber()).number; + const draft = await createIssuedOrder({}); + issuedOrderIds.push(draft.id); + + const res = await updateIssuedOrder(draft.id, { status: "sent" }); + expect("error" in res).toBe(false); + + const row = await prisma.issued_orders.findUnique({ + where: { id: draft.id }, + }); + expect(row?.status).toBe("sent"); + expect(row?.po_number).toBe(expected); + expect("po_number" in res && res.po_number).toBe(expected); + }); + + it("finalizing is idempotent — re-finalizing does not re-number", async () => { + const draft = await createIssuedOrder({}); + issuedOrderIds.push(draft.id); + + await updateIssuedOrder(draft.id, { status: "sent" }); + const first = ( + await prisma.issued_orders.findUnique({ where: { id: draft.id } }) + )?.po_number; + const seqAfterFirst = await prisma.number_sequences.findFirst({ + where: { type: "issued_order" }, + }); + + // draft -> sent is no longer valid (already sent); the number must not change. + // Drive a second finalize-style update that keeps status sent. + await updateIssuedOrder(draft.id, { status: "sent" }); + const second = ( + await prisma.issued_orders.findUnique({ where: { id: draft.id } }) + )?.po_number; + const seqAfterSecond = await prisma.number_sequences.findFirst({ + where: { type: "issued_order" }, + }); + + expect(second).toBe(first); + expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number); + }); + + it("two drafts coexist with null numbers (no unique violation)", async () => { + const a = await createIssuedOrder({}); + const b = await createIssuedOrder({}); + issuedOrderIds.push(a.id, b.id); + expect(a.po_number).toBeNull(); + expect(b.po_number).toBeNull(); + }); + + it("deleting a draft releases nothing (no number was consumed)", async () => { + const before = (await previewIssuedOrderNumber()).number; + const draft = await createIssuedOrder({}); + await deleteIssuedOrder(draft.id); + const after = (await previewIssuedOrderNumber()).number; + expect(after).toBe(before); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Invoices */ +/* -------------------------------------------------------------------------- */ + +describe("invoice drafts — deferred numbering", () => { + it("a draft has no number and does NOT consume the sequence", async () => { + const before = (await previewInvoiceNumber()).number; + const draft = await createInvoice({}); + invoiceIds.push(draft.id); + expect(draft.status).toBe("draft"); + expect(draft.invoice_number).toBeNull(); + const after = (await previewInvoiceNumber()).number; + expect(after).toBe(before); + }); + + it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => { + const expected = (await previewInvoiceNumber()).number; + const draft = await createInvoice({}); + invoiceIds.push(draft.id); + + const res = await updateInvoice(draft.id, { status: "issued" }); + expect("error" in res).toBe(false); + + const row = await prisma.invoices.findUnique({ where: { id: draft.id } }); + expect(row?.status).toBe("issued"); + expect(row?.invoice_number).toBe(expected); + expect("invoice_number" in res && res.invoice_number).toBe(expected); + }); + + it("finalizing twice does not re-number / double-consume", async () => { + const draft = await createInvoice({}); + invoiceIds.push(draft.id); + + await updateInvoice(draft.id, { status: "issued" }); + const first = ( + await prisma.invoices.findUnique({ where: { id: draft.id } }) + )?.invoice_number; + const seqAfterFirst = await prisma.number_sequences.findFirst({ + where: { type: "invoice" }, + }); + + // issued -> issued is a no-op status-wise; the number must stay put. + await updateInvoice(draft.id, { status: "issued" }); + const second = ( + await prisma.invoices.findUnique({ where: { id: draft.id } }) + )?.invoice_number; + const seqAfterSecond = await prisma.number_sequences.findFirst({ + where: { type: "invoice" }, + }); + + expect(second).toBe(first); + expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number); + }); + + it("two invoice drafts coexist with null numbers", async () => { + const a = await createInvoice({}); + const b = await createInvoice({}); + invoiceIds.push(a.id, b.id); + expect(a.invoice_number).toBeNull(); + expect(b.invoice_number).toBeNull(); + }); + + it("deleting a draft releases nothing", async () => { + const before = (await previewInvoiceNumber()).number; + const draft = await createInvoice({}); + await deleteInvoice(draft.id); + const after = (await previewInvoiceNumber()).number; + expect(after).toBe(before); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Offers */ +/* -------------------------------------------------------------------------- */ + +describe("offer drafts — deferred numbering", () => { + it("a draft has no number and does NOT consume the sequence", async () => { + const before = await previewOfferNumber(); + const draft = await createOffer({}); + if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`); + offerIds.push(draft.id); + expect(draft.status).toBe("draft"); + expect(draft.quotation_number).toBeNull(); + const after = await previewOfferNumber(); + expect(after).toBe(before); + }); + + it("finalizing a draft (draft -> active) assigns the next sequence number", async () => { + const expected = await previewOfferNumber(); + const draft = await createOffer({}); + if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`); + offerIds.push(draft.id); + + const res = await updateOffer(draft.id, { status: "active" }); + expect("error" in res).toBe(false); + + const row = await prisma.quotations.findUnique({ where: { id: draft.id } }); + expect(row?.status).toBe("active"); + expect(row?.quotation_number).toBe(expected); + expect("quotation_number" in res && res.quotation_number).toBe(expected); + }); + + it("finalizing twice does not re-number / double-consume", async () => { + const draft = await createOffer({}); + if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`); + offerIds.push(draft.id); + + await updateOffer(draft.id, { status: "active" }); + const first = ( + await prisma.quotations.findUnique({ where: { id: draft.id } }) + )?.quotation_number; + const seqAfterFirst = await prisma.number_sequences.findFirst({ + where: { type: "offer" }, + }); + + // active -> active is a no-op; the number must stay put. + await updateOffer(draft.id, { status: "active" }); + const second = ( + await prisma.quotations.findUnique({ where: { id: draft.id } }) + )?.quotation_number; + const seqAfterSecond = await prisma.number_sequences.findFirst({ + where: { type: "offer" }, + }); + + expect(second).toBe(first); + expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number); + }); + + it("two offer drafts coexist with null numbers", async () => { + const a = await createOffer({}); + const b = await createOffer({}); + if ("error" in a) throw new Error(`createOffer failed: ${a.error}`); + if ("error" in b) throw new Error(`createOffer failed: ${b.error}`); + offerIds.push(a.id, b.id); + expect(a.quotation_number).toBeNull(); + expect(b.quotation_number).toBeNull(); + }); + + it("an offer created already-finalized (status active) numbers immediately", async () => { + const expected = await previewOfferNumber(); + const offer = await createOffer({ status: "active" }); + if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`); + offerIds.push(offer.id); + expect(offer.quotation_number).toBe(expected); + }); + + it("deleting a draft releases nothing", async () => { + const before = await previewOfferNumber(); + const draft = await createOffer({}); + if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`); + await deleteOffer(draft.id); + const after = await previewOfferNumber(); + expect(after).toBe(before); + }); +}); diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index ae99e26..becab93 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -120,7 +120,7 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => { }); describe("createIssuedOrder", () => { - it("assigns a PO number, defaults to draft, stores items", async () => { + it("defaults to draft with NO PO number, stores items", async () => { const c = await makeCustomer(); const order = await createIssuedOrder({ customer_id: c.id, @@ -129,7 +129,8 @@ describe("createIssuedOrder", () => { ], }); createdIds.push(order.id); - expect(order.po_number).toBeTruthy(); + // Deferred numbering: a draft carries no number. + expect(order.po_number).toBeNull(); expect(order.status).toBe("draft"); const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, @@ -137,6 +138,14 @@ describe("createIssuedOrder", () => { expect(items.length).toBe(1); expect(Number(items[0].unit_price)).toBe(100); }); + + it("numbers immediately when created already-finalized (status sent)", async () => { + const before = (await previewIssuedOrderNumber()).number; + const order = await createIssuedOrder({ status: "sent" }); + createdIds.push(order.id); + expect(order.po_number).toBe(before); + expect(order.status).toBe("sent"); + }); }); describe("updateIssuedOrder status transitions", () => { @@ -183,7 +192,10 @@ describe("deleteIssuedOrder", () => { const order = await createIssuedOrder({ items: [{ description: "X", quantity: 1, unit_price: 1 }], }); - const before = order.po_number; + // Finalize so the order has a real (consumed) number to free on delete. + const finalized = await updateIssuedOrder(order.id, { status: "sent" }); + const before = "po_number" in finalized ? finalized.po_number : null; + expect(before).toBeTruthy(); await deleteIssuedOrder(order.id); expect( await prisma.issued_orders.findUnique({ where: { id: order.id } }), diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts index e3fd6ad..e7d7cca 100644 --- a/src/schemas/invoices.schema.ts +++ b/src/schemas/invoices.schema.ts @@ -22,7 +22,7 @@ export const CreateInvoiceSchema = z.object({ invoice_number: z.string().max(255).nullish(), order_id: nullableIntIdFromForm.nullish(), customer_id: nullableIntIdFromForm.nullish(), - status: z.string().max(20).optional().default("issued"), + status: z.string().max(20).optional().default("draft"), currency: z.string().max(20).optional().default("CZK"), vat_rate: numberInRange(0, 100).optional().default(21.0), apply_vat: booleanFromForm.optional().default(true), diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index e9d35ac..0bde5cc 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -36,9 +36,9 @@ export const CreateQuotationSchema = z.object({ vat_rate: numberInRange(0, 100).optional().default(21.0), apply_vat: booleanFromForm.optional().default(true), status: z - .enum(["active", "ordered", "invalidated"]) + .enum(["draft", "active", "ordered", "invalidated"]) .optional() - .default("active"), + .default("draft"), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), items: z.array(QuotationItemSchema).optional(), @@ -54,7 +54,7 @@ export const UpdateQuotationSchema = z.object({ language: z.string().max(20).optional(), vat_rate: numberInRange(0, 100).optional(), apply_vat: booleanFromForm.optional(), - status: z.enum(["active", "ordered", "invalidated"]).optional(), + status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), items: z.array(QuotationItemSchema).optional(), diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index 4c586cc..704dc76 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -3,10 +3,13 @@ import { toCzk } from "./exchange-rates"; import { generateInvoiceNumber, releaseInvoiceNumber, + assignInvoiceNumber, } from "./numbering.service"; -// Status transition rules matching PHP +// Status transition rules matching PHP. +// draft -> issued is the finalize step (consumes + assigns the invoice number). const VALID_TRANSITIONS: Record = { + draft: ["issued"], issued: ["paid"], overdue: ["paid"], paid: [], @@ -411,17 +414,26 @@ export async function getInvoice(id: number) { } export async function createInvoice(body: InvoiceInput) { - const invoiceNumber = + const status = body.status ? String(body.status) : "draft"; + + // Deferred numbering: a draft carries NO invoice_number (the column is + // nullable-unique so many drafts coexist). The official number is consumed + // only when the draft is finalized (assignInvoiceNumber on draft->issued). + // A caller-supplied number, or an invoice created already-finalized, numbers now. + const explicit = body.invoice_number !== undefined && body.invoice_number !== null ? String(body.invoice_number) - : (await generateInvoiceNumber()).number; + : null; + const invoiceNumber = + explicit ?? + (status === "draft" ? null : (await generateInvoiceNumber()).number); const invoice = await prisma.invoices.create({ data: { invoice_number: invoiceNumber, order_id: body.order_id ? Number(body.order_id) : null, customer_id: body.customer_id ? Number(body.customer_id) : null, - status: body.status ? String(body.status) : "issued", + status, currency: body.currency ? String(body.currency) : "CZK", vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, @@ -479,9 +491,12 @@ export async function updateInvoice(id: number, body: InvoiceInput) { const data: Record = { modified_at: new Date() }; - // Allow full editing in 'issued' and 'overdue' states - const isDraft = currentStatus === "issued" || currentStatus === "overdue"; - if (isDraft) { + // Allow full editing in 'draft', 'issued' and 'overdue' states. + const editable = + currentStatus === "draft" || + currentStatus === "issued" || + currentStatus === "overdue"; + if (editable) { const strFields = [ "currency", "payment_method", @@ -515,8 +530,8 @@ export async function updateInvoice(id: number, body: InvoiceInput) { data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null; } - // Notes editable in issued/overdue - if (currentStatus === "issued" || currentStatus === "overdue") { + // Notes editable in draft/issued/overdue + if (editable) { if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null; if (body.internal_notes !== undefined) @@ -537,10 +552,27 @@ export async function updateInvoice(id: number, body: InvoiceInput) { if (body.paid_date !== undefined && currentStatus !== "paid") data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null; - await prisma.invoices.update({ where: { id }, data }); + // Finalize transition draft -> issued: consume + write the official invoice + // number ATOMICALLY with the status change, so there is never a window where + // the invoice is "issued" but still has a null invoice_number. + // assignInvoiceNumber is idempotent (won't re-number an already-numbered row). + const finalizing = + currentStatus === "draft" && + body.status !== undefined && + String(body.status) === "issued"; - // Only allow items update in draft state - if (isDraft && Array.isArray(body.items)) { + let assignedNumber: string | null = null; + if (finalizing) { + await prisma.$transaction(async (tx) => { + await tx.invoices.update({ where: { id }, data }); + assignedNumber = await assignInvoiceNumber(id, tx); + }); + } else { + await prisma.invoices.update({ where: { id }, data }); + } + + // Allow items update while editable (draft/issued/overdue). + if (editable && Array.isArray(body.items)) { await prisma.$transaction(async (tx) => { await tx.invoice_items.deleteMany({ where: { invoice_id: id } }); await tx.invoice_items.createMany({ @@ -558,7 +590,12 @@ export async function updateInvoice(id: number, body: InvoiceInput) { }); } - return { id, invoice_number: existing.invoice_number }; + // Reflect the number the finalize transition just assigned (existing was read + // before the assign, so its invoice_number is still the pre-finalize null). + return { + id, + invoice_number: assignedNumber ?? existing.invoice_number, + }; } export async function deleteInvoice(id: number) { diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts index 040fa5b..4c3281f 100644 --- a/src/services/issued-orders.service.ts +++ b/src/services/issued-orders.service.ts @@ -4,6 +4,7 @@ import { generateIssuedOrderNumber, previewIssuedOrderNumber, releaseIssuedOrderNumber, + assignIssuedOrderNumber, } from "./numbering.service"; export interface IssuedOrderItemInput { @@ -185,20 +186,29 @@ export async function getIssuedOrder(id: number) { export async function createIssuedOrder(body: IssuedOrderInput) { return prisma.$transaction(async (tx) => { - const poNumber = + const status = body.status ? String(body.status) : "draft"; + + // Deferred numbering: a draft carries NO po_number (the column is + // nullable-unique so many drafts coexist). The official number is consumed + // only when the draft is finalized (assignIssuedOrderNumber on draft->sent). + // A caller-supplied number, or a doc created already-finalized, numbers now. + const explicit = body.po_number !== undefined && body.po_number !== null && body.po_number !== "" ? String(body.po_number) - : (await generateIssuedOrderNumber(undefined, tx)).number; + : null; + const poNumber = + explicit ?? + (status === "draft" + ? null + : (await generateIssuedOrderNumber(undefined, tx)).number); const order = await tx.issued_orders.create({ data: { po_number: poNumber, customer_id: body.customer_id ? Number(body.customer_id) : null, - status: (body.status - ? String(body.status) - : "draft") as $Enums.issued_orders_status, + status: status as $Enums.issued_orders_status, currency: body.currency ? String(body.currency) : "CZK", vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, @@ -299,7 +309,23 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { if (body.status !== undefined) data.status = String(body.status); - await prisma.issued_orders.update({ where: { id }, data }); + // Finalize transition draft -> sent: consume + write the official number + // ATOMICALLY with the status change, so there is never a window where the + // order is "sent" but still has a null po_number. assignIssuedOrderNumber is + // idempotent (won't re-number an order that already has a number). + const finalizing = + currentStatus === "draft" && + body.status !== undefined && + String(body.status) === "sent"; + + if (finalizing) { + await prisma.$transaction(async (tx) => { + await tx.issued_orders.update({ where: { id }, data }); + await assignIssuedOrderNumber(id, tx); + }); + } else { + await prisma.issued_orders.update({ where: { id }, data }); + } if (editable && Array.isArray(body.items)) { await prisma.$transaction(async (tx) => { @@ -321,7 +347,18 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { }); } - return { id, po_number: existing.po_number }; + // Reflect the number the finalize transition just assigned (existing was read + // before the assign, so its po_number is still the pre-finalize null). + const poNumber = finalizing + ? (( + await prisma.issued_orders.findUnique({ + where: { id }, + select: { po_number: true }, + }) + )?.po_number ?? existing.po_number) + : existing.po_number; + + return { id, po_number: poNumber }; } export async function deleteIssuedOrder(id: number) { diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index d050b33..efb2059 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -413,6 +413,76 @@ export async function previewIssuedOrderNumber( return { number, next_number: number }; } +/** + * Deferred-assign helpers — used when a draft (number-less) document is + * finalized. Each runs inside a transaction (the caller's `tx` if provided, + * otherwise a fresh `prisma.$transaction`), reads the row's current number, and: + * - if it is already set (non-null/non-empty), returns it unchanged + * (idempotent — finalizing twice never re-numbers / double-consumes); else + * - calls the existing per-type generator (which consumes the sequence under + * the same row lock) and writes the number onto the row. + * The sequence is consumed exactly once, atomically with the row write. + */ +export async function assignOfferNumber( + id: number, + tx?: TxClient, +): Promise { + const run = async (client: TxClient): Promise => { + const row = await client.quotations.findUnique({ + where: { id }, + select: { quotation_number: true }, + }); + if (row?.quotation_number) return row.quotation_number; + const number = await generateOfferNumber(client); + await client.quotations.update({ + where: { id }, + data: { quotation_number: number }, + }); + return number; + }; + return tx ? run(tx) : prisma.$transaction(run); +} + +export async function assignInvoiceNumber( + id: number, + tx?: TxClient, +): Promise { + const run = async (client: TxClient): Promise => { + const row = await client.invoices.findUnique({ + where: { id }, + select: { invoice_number: true }, + }); + if (row?.invoice_number) return row.invoice_number; + const { number } = await generateInvoiceNumber(undefined, client); + await client.invoices.update({ + where: { id }, + data: { invoice_number: number }, + }); + return number; + }; + return tx ? run(tx) : prisma.$transaction(run); +} + +export async function assignIssuedOrderNumber( + id: number, + tx?: TxClient, +): Promise { + const run = async (client: TxClient): Promise => { + const row = await client.issued_orders.findUnique({ + where: { id }, + select: { po_number: true }, + }); + if (row?.po_number) return row.po_number; + const { number } = await generateIssuedOrderNumber(undefined, client); + await client.issued_orders.update({ + where: { id }, + data: { po_number: number }, + }); + return number; + }; + return tx ? run(tx) : prisma.$transaction(run); +} + /** Release an issued-order number back to the pool (decrement if highest). */ export async function releaseIssuedOrderNumber( year?: number, diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index ae78512..f81fa98 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -4,6 +4,7 @@ import { previewOfferNumber, releaseOfferNumber, isOfferNumberTaken, + assignOfferNumber, } from "./numbering.service"; import { nasOffersManager } from "./nas-offers-manager"; @@ -162,20 +163,26 @@ export async function getOffer(id: number) { export async function createOffer(body: Record) { try { return await prisma.$transaction(async (tx) => { - const quotationNumber = + const status = body.status ? String(body.status) : "draft"; + const explicitNumber = body.quotation_number !== undefined && body.quotation_number !== null ? String(body.quotation_number) - : await generateOfferNumber(tx); + : null; + + // Deferred numbering: a draft carries NO quotation_number (the column is + // nullable-unique so many drafts coexist). The official number is consumed + // only when the draft is finalized (assignOfferNumber on draft->active). + // A caller-supplied number, or an offer created already-finalized, numbers now. + const quotationNumber = + explicitNumber ?? + (status === "draft" ? null : await generateOfferNumber(tx)); // Re-check uniqueness INSIDE the transaction to close the TOCTOU window // between a pre-transaction check and the insert (mirrors createOrder). // generateOfferNumber already verifies its own generated numbers, so this // guards the caller-supplied path. - if ( - body.quotation_number !== undefined && - body.quotation_number !== null - ) { - const taken = await isOfferNumberTaken(quotationNumber); + if (explicitNumber !== null) { + const taken = await isOfferNumberTaken(explicitNumber); if (taken) { throw Object.assign(new Error("Číslo nabídky je již použito"), { status: 400, @@ -198,7 +205,7 @@ export async function createOffer(body: Record) { language: body.language ? String(body.language) : "cs", vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, - status: body.status ? String(body.status) : "active", + status, scope_title: body.scope_title ? String(body.scope_title) : null, scope_description: body.scope_description ? String(body.scope_description) @@ -252,13 +259,25 @@ export async function updateOffer(id: number, body: Record) { if (existing.status === "invalidated") return { error: "invalidated" as const }; + // A finalized offer's number is immutable. A draft has no number yet + // (quotation_number === null) — it is assigned on finalize, not editable here, + // so an empty/absent submitted value on a draft is not a "change" and must not + // block the draft->active finalize. if ( + existing.quotation_number !== null && body.quotation_number !== undefined && String(body.quotation_number) !== existing.quotation_number ) { return { error: "Číslo nabídky nelze změnit", status: 400 } as const; } + // Finalize transition draft -> active: assign the official number ATOMICALLY + // with the status change. assignOfferNumber is idempotent. + const finalizing = + existing.status === "draft" && + body.status !== undefined && + String(body.status) === "active"; + const data = { customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined, @@ -305,9 +324,11 @@ export async function updateOffer(id: number, body: Record) { modified_at: new Date(), }; - if (Array.isArray(body.items) || Array.isArray(body.sections)) { + let assignedNumber: string | null = null; + if (Array.isArray(body.items) || Array.isArray(body.sections) || finalizing) { await prisma.$transaction(async (tx) => { await tx.quotations.update({ where: { id }, data }); + if (finalizing) assignedNumber = await assignOfferNumber(id, tx); if (Array.isArray(body.items)) { await tx.quotation_items.deleteMany({ where: { quotation_id: id } }); await tx.quotation_items.createMany({ @@ -340,7 +361,12 @@ export async function updateOffer(id: number, body: Record) { await prisma.quotations.update({ where: { id }, data }); } - return { id, quotation_number: existing.quotation_number }; + // Reflect the number the finalize transition just assigned (existing was read + // before the assign, so its quotation_number is still the pre-finalize null). + return { + id, + quotation_number: assignedNumber ?? existing.quotation_number, + }; } export async function deleteOffer(id: number) {