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:
BOHA
2026-06-24 21:36:35 +02:00
parent 983a1408f1
commit 237ebf3ef8
7 changed files with 174 additions and 17 deletions

View File

@@ -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,