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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user