feat(drafts): DB drafts with deferred numbering — create-as-draft (no number), assign number on finalize (offers/invoices/issued-orders)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 18:04:11 +02:00
parent 28186397a0
commit 473f7c4a8c
8 changed files with 494 additions and 37 deletions

View File

@@ -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<string, string[]> = {
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<string, unknown> = { 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) {

View File

@@ -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) {

View File

@@ -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<string> {
const run = async (client: TxClient): Promise<string> => {
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<string> {
const run = async (client: TxClient): Promise<string> => {
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<string> {
const run = async (client: TxClient): Promise<string> => {
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,

View File

@@ -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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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) {