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:
275
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
275
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,7 +120,7 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createIssuedOrder", () => {
|
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 c = await makeCustomer();
|
||||||
const order = await createIssuedOrder({
|
const order = await createIssuedOrder({
|
||||||
customer_id: c.id,
|
customer_id: c.id,
|
||||||
@@ -129,7 +129,8 @@ describe("createIssuedOrder", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
createdIds.push(order.id);
|
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");
|
expect(order.status).toBe("draft");
|
||||||
const items = await prisma.issued_order_items.findMany({
|
const items = await prisma.issued_order_items.findMany({
|
||||||
where: { issued_order_id: order.id },
|
where: { issued_order_id: order.id },
|
||||||
@@ -137,6 +138,14 @@ describe("createIssuedOrder", () => {
|
|||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
expect(Number(items[0].unit_price)).toBe(100);
|
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", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
@@ -183,7 +192,10 @@ describe("deleteIssuedOrder", () => {
|
|||||||
const order = await createIssuedOrder({
|
const order = await createIssuedOrder({
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
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);
|
await deleteIssuedOrder(order.id);
|
||||||
expect(
|
expect(
|
||||||
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
|
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const CreateInvoiceSchema = z.object({
|
|||||||
invoice_number: z.string().max(255).nullish(),
|
invoice_number: z.string().max(255).nullish(),
|
||||||
order_id: nullableIntIdFromForm.nullish(),
|
order_id: nullableIntIdFromForm.nullish(),
|
||||||
customer_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"),
|
currency: z.string().max(20).optional().default("CZK"),
|
||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||||
apply_vat: booleanFromForm.optional().default(true),
|
apply_vat: booleanFromForm.optional().default(true),
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export const CreateQuotationSchema = z.object({
|
|||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||||
apply_vat: booleanFromForm.optional().default(true),
|
apply_vat: booleanFromForm.optional().default(true),
|
||||||
status: z
|
status: z
|
||||||
.enum(["active", "ordered", "invalidated"])
|
.enum(["draft", "active", "ordered", "invalidated"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("active"),
|
.default("draft"),
|
||||||
scope_title: z.string().max(255).nullish(),
|
scope_title: z.string().max(255).nullish(),
|
||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
@@ -54,7 +54,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
language: z.string().max(20).optional(),
|
language: z.string().max(20).optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
vat_rate: numberInRange(0, 100).optional(),
|
||||||
apply_vat: booleanFromForm.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_title: z.string().max(255).nullish(),
|
||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { toCzk } from "./exchange-rates";
|
|||||||
import {
|
import {
|
||||||
generateInvoiceNumber,
|
generateInvoiceNumber,
|
||||||
releaseInvoiceNumber,
|
releaseInvoiceNumber,
|
||||||
|
assignInvoiceNumber,
|
||||||
} from "./numbering.service";
|
} 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[]> = {
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
draft: ["issued"],
|
||||||
issued: ["paid"],
|
issued: ["paid"],
|
||||||
overdue: ["paid"],
|
overdue: ["paid"],
|
||||||
paid: [],
|
paid: [],
|
||||||
@@ -411,17 +414,26 @@ export async function getInvoice(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvoice(body: InvoiceInput) {
|
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
|
body.invoice_number !== undefined && body.invoice_number !== null
|
||||||
? String(body.invoice_number)
|
? String(body.invoice_number)
|
||||||
: (await generateInvoiceNumber()).number;
|
: null;
|
||||||
|
const invoiceNumber =
|
||||||
|
explicit ??
|
||||||
|
(status === "draft" ? null : (await generateInvoiceNumber()).number);
|
||||||
|
|
||||||
const invoice = await prisma.invoices.create({
|
const invoice = await prisma.invoices.create({
|
||||||
data: {
|
data: {
|
||||||
invoice_number: invoiceNumber,
|
invoice_number: invoiceNumber,
|
||||||
order_id: body.order_id ? Number(body.order_id) : null,
|
order_id: body.order_id ? Number(body.order_id) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_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",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
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() };
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
|
|
||||||
// Allow full editing in 'issued' and 'overdue' states
|
// Allow full editing in 'draft', 'issued' and 'overdue' states.
|
||||||
const isDraft = currentStatus === "issued" || currentStatus === "overdue";
|
const editable =
|
||||||
if (isDraft) {
|
currentStatus === "draft" ||
|
||||||
|
currentStatus === "issued" ||
|
||||||
|
currentStatus === "overdue";
|
||||||
|
if (editable) {
|
||||||
const strFields = [
|
const strFields = [
|
||||||
"currency",
|
"currency",
|
||||||
"payment_method",
|
"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;
|
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes editable in issued/overdue
|
// Notes editable in draft/issued/overdue
|
||||||
if (currentStatus === "issued" || currentStatus === "overdue") {
|
if (editable) {
|
||||||
if (body.notes !== undefined)
|
if (body.notes !== undefined)
|
||||||
data.notes = body.notes ? String(body.notes) : null;
|
data.notes = body.notes ? String(body.notes) : null;
|
||||||
if (body.internal_notes !== undefined)
|
if (body.internal_notes !== undefined)
|
||||||
@@ -537,10 +552,27 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
if (body.paid_date !== undefined && currentStatus !== "paid")
|
if (body.paid_date !== undefined && currentStatus !== "paid")
|
||||||
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
|
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
|
let assignedNumber: string | null = null;
|
||||||
if (isDraft && Array.isArray(body.items)) {
|
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 prisma.$transaction(async (tx) => {
|
||||||
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
||||||
await tx.invoice_items.createMany({
|
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) {
|
export async function deleteInvoice(id: number) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
generateIssuedOrderNumber,
|
generateIssuedOrderNumber,
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
releaseIssuedOrderNumber,
|
releaseIssuedOrderNumber,
|
||||||
|
assignIssuedOrderNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
|
|
||||||
export interface IssuedOrderItemInput {
|
export interface IssuedOrderItemInput {
|
||||||
@@ -185,20 +186,29 @@ export async function getIssuedOrder(id: number) {
|
|||||||
|
|
||||||
export async function createIssuedOrder(body: IssuedOrderInput) {
|
export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||||
return prisma.$transaction(async (tx) => {
|
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 !== undefined &&
|
||||||
body.po_number !== null &&
|
body.po_number !== null &&
|
||||||
body.po_number !== ""
|
body.po_number !== ""
|
||||||
? String(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({
|
const order = await tx.issued_orders.create({
|
||||||
data: {
|
data: {
|
||||||
po_number: poNumber,
|
po_number: poNumber,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
status: (body.status
|
status: status as $Enums.issued_orders_status,
|
||||||
? String(body.status)
|
|
||||||
: "draft") as $Enums.issued_orders_status,
|
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
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);
|
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)) {
|
if (editable && Array.isArray(body.items)) {
|
||||||
await prisma.$transaction(async (tx) => {
|
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) {
|
export async function deleteIssuedOrder(id: number) {
|
||||||
|
|||||||
@@ -413,6 +413,76 @@ export async function previewIssuedOrderNumber(
|
|||||||
return { number, next_number: number };
|
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). */
|
/** Release an issued-order number back to the pool (decrement if highest). */
|
||||||
export async function releaseIssuedOrderNumber(
|
export async function releaseIssuedOrderNumber(
|
||||||
year?: number,
|
year?: number,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
previewOfferNumber,
|
previewOfferNumber,
|
||||||
releaseOfferNumber,
|
releaseOfferNumber,
|
||||||
isOfferNumberTaken,
|
isOfferNumberTaken,
|
||||||
|
assignOfferNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { nasOffersManager } from "./nas-offers-manager";
|
import { nasOffersManager } from "./nas-offers-manager";
|
||||||
|
|
||||||
@@ -162,20 +163,26 @@ export async function getOffer(id: number) {
|
|||||||
export async function createOffer(body: Record<string, any>) {
|
export async function createOffer(body: Record<string, any>) {
|
||||||
try {
|
try {
|
||||||
return await prisma.$transaction(async (tx) => {
|
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
|
body.quotation_number !== undefined && body.quotation_number !== null
|
||||||
? String(body.quotation_number)
|
? 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
|
// Re-check uniqueness INSIDE the transaction to close the TOCTOU window
|
||||||
// between a pre-transaction check and the insert (mirrors createOrder).
|
// between a pre-transaction check and the insert (mirrors createOrder).
|
||||||
// generateOfferNumber already verifies its own generated numbers, so this
|
// generateOfferNumber already verifies its own generated numbers, so this
|
||||||
// guards the caller-supplied path.
|
// guards the caller-supplied path.
|
||||||
if (
|
if (explicitNumber !== null) {
|
||||||
body.quotation_number !== undefined &&
|
const taken = await isOfferNumberTaken(explicitNumber);
|
||||||
body.quotation_number !== null
|
|
||||||
) {
|
|
||||||
const taken = await isOfferNumberTaken(quotationNumber);
|
|
||||||
if (taken) {
|
if (taken) {
|
||||||
throw Object.assign(new Error("Číslo nabídky je již použito"), {
|
throw Object.assign(new Error("Číslo nabídky je již použito"), {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -198,7 +205,7 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
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_title: body.scope_title ? String(body.scope_title) : null,
|
||||||
scope_description: body.scope_description
|
scope_description: body.scope_description
|
||||||
? String(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")
|
if (existing.status === "invalidated")
|
||||||
return { error: "invalidated" as const };
|
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 (
|
if (
|
||||||
|
existing.quotation_number !== null &&
|
||||||
body.quotation_number !== undefined &&
|
body.quotation_number !== undefined &&
|
||||||
String(body.quotation_number) !== existing.quotation_number
|
String(body.quotation_number) !== existing.quotation_number
|
||||||
) {
|
) {
|
||||||
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
|
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 = {
|
const data = {
|
||||||
customer_id:
|
customer_id:
|
||||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
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(),
|
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 prisma.$transaction(async (tx) => {
|
||||||
await tx.quotations.update({ where: { id }, data });
|
await tx.quotations.update({ where: { id }, data });
|
||||||
|
if (finalizing) assignedNumber = await assignOfferNumber(id, tx);
|
||||||
if (Array.isArray(body.items)) {
|
if (Array.isArray(body.items)) {
|
||||||
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
|
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
|
||||||
await tx.quotation_items.createMany({
|
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 });
|
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) {
|
export async function deleteOffer(id: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user