feat(issued-orders): make line items optional (issue by sections alone)
Items are no longer required on issued orders — an order can be issued purely from its rich-text sections (Obsah). - DocumentItemsEditor: opt-in allowEmpty prop lets the list go to zero rows (default false keeps the offers/invoices at-least-one-item rule). - IssuedOrderDetail: allowEmpty on the editor; a saved order with no items reopens empty (not a blank starter row); submit guard now requires at least one item OR a non-empty section, not an item. - PDF: with no items the billing heading, items table and total row are omitted entirely — a sections-only order shows only its Obsah. - Service: finalize (draft->sent) and create-as-non-draft reject a completely blank order (no items AND no sections) with a Czech 400 (empty_document); drafts may still be saved empty as WIP. - Tests: sections-only finalize + blank-order rejection + sections-only PDF render; existing finalize fixtures given minimal content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
|
||||
|
||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const order = await mkIssued({ status: "sent" });
|
||||
const order = await mkIssued({
|
||||
status: "sent",
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
expect(order.po_number).toBe(before);
|
||||
expect(order.status).toBe("sent");
|
||||
});
|
||||
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
|
||||
|
||||
describe("updateIssuedOrder status transitions", () => {
|
||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||
const order = await mkIssued({});
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||
expect("error" in ok).toBe(false);
|
||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||
@@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
});
|
||||
|
||||
it("status-only transition payloads still work when not editable", async () => {
|
||||
const order = await mkIssued({});
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
||||
@@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||
});
|
||||
|
||||
it("finalizes a sections-only order (no items) successfully", async () => {
|
||||
const order = await mkIssued({});
|
||||
const res = await updateIssuedOrder(order.id, {
|
||||
status: "sent",
|
||||
items: [],
|
||||
sections: [
|
||||
{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" },
|
||||
],
|
||||
});
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: order.id },
|
||||
});
|
||||
expect(row!.status).toBe("sent");
|
||||
expect(row!.po_number).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects finalizing a completely blank order (no items, no sections)", async () => {
|
||||
const order = await mkIssued({});
|
||||
const res = await updateIssuedOrder(order.id, {
|
||||
status: "sent",
|
||||
items: [],
|
||||
sections: [],
|
||||
});
|
||||
expect("error" in res && res.error).toBe("empty_document");
|
||||
// Stays a draft — the finalize was rejected before any write.
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: order.id },
|
||||
});
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.po_number).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects creating a non-draft order with no content", async () => {
|
||||
const res = await createIssuedOrder({ status: "sent" });
|
||||
expect("error" in res && res.error).toBe("empty_document");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrder", () => {
|
||||
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
|
||||
|
||||
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
||||
const order = await mkIssued({});
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||
|
||||
@@ -543,7 +590,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||
// full payload + status in ONE PUT while the order is still editable
|
||||
// (sent). The server must apply the items AND the transition together.
|
||||
const order = await mkIssued({});
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
|
||||
const res = await app!.inject({
|
||||
@@ -574,7 +623,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||
});
|
||||
|
||||
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
||||
const order = await mkIssued({});
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||
|
||||
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
|
||||
expect(html).toContain("Odběratel");
|
||||
});
|
||||
|
||||
it("omits the items table and total when there are no items (sections-only order)", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
[],
|
||||
supplier,
|
||||
{ company_name: "Naše firma" },
|
||||
"cs",
|
||||
issuer,
|
||||
[{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" }],
|
||||
);
|
||||
// The section renders…
|
||||
expect(html).toContain("Rozsah prací");
|
||||
// …but the items table, the billing heading and the total row do not.
|
||||
expect(html).not.toContain('class="items"');
|
||||
expect(html).not.toContain("Celkem bez DPH");
|
||||
});
|
||||
|
||||
it("renders the structured supplier address plus IČO and DIČ", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
|
||||
Reference in New Issue
Block a user