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:
@@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
|
|
||||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||||
const expected = (await previewIssuedOrderNumber()).number;
|
const expected = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
@@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
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.po_number).toBe(before);
|
||||||
expect(order.status).toBe("sent");
|
expect(order.status).toBe("sent");
|
||||||
});
|
});
|
||||||
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
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" });
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
expect("error" in ok).toBe(false);
|
expect("error" in ok).toBe(false);
|
||||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
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 () => {
|
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: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
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 });
|
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
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", () => {
|
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)", () => {
|
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 () => {
|
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: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
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
|
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||||
// full payload + status in ONE PUT while the order is still editable
|
// full payload + status in ONE PUT while the order is still editable
|
||||||
// (sent). The server must apply the items AND the transition together.
|
// (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" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
|
|
||||||
const res = await app!.inject({
|
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 () => {
|
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: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
|
|
||||||
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Odběratel");
|
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Č", () => {
|
it("renders the structured supplier address plus IČO and DIČ", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
|
|||||||
@@ -492,6 +492,12 @@ interface DocumentItemsEditorProps {
|
|||||||
itemDescriptionMaxLength?: number;
|
itemDescriptionMaxLength?: number;
|
||||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||||
templatesSlot?: ReactNode;
|
templatesSlot?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Allow removing every row so the list can be empty (issued orders may be
|
||||||
|
* issued by sections alone). Default false keeps the offers/invoices rule of
|
||||||
|
* at least one item.
|
||||||
|
*/
|
||||||
|
allowEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -509,6 +515,7 @@ export default function DocumentItemsEditor({
|
|||||||
showDiscount = false,
|
showDiscount = false,
|
||||||
itemDescriptionMaxLength = 5000,
|
itemDescriptionMaxLength = 5000,
|
||||||
templatesSlot,
|
templatesSlot,
|
||||||
|
allowEmpty = false,
|
||||||
}: DocumentItemsEditorProps) {
|
}: DocumentItemsEditorProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -538,10 +545,13 @@ export default function DocumentItemsEditor({
|
|||||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||||
|
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
if (items.length <= 1) return;
|
if (!allowEmpty && items.length <= 1) return;
|
||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When allowEmpty, even the last row is deletable (list may go to zero).
|
||||||
|
const canDeleteRow = allowEmpty || items.length > 1;
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -608,7 +618,7 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
showDiscount={showDiscount}
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
@@ -666,7 +676,7 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
showDiscount={showDiscount}
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
|
|||||||
@@ -301,7 +301,9 @@ export default function IssuedOrderDetail() {
|
|||||||
// Issued orders have no Sleva column; keep the shared item shape happy.
|
// Issued orders have no Sleva column; keep the shared item shape happy.
|
||||||
discount: 0,
|
discount: 0,
|
||||||
}))
|
}))
|
||||||
: [emptyDocumentItem()];
|
: // Issued orders may be issued by sections alone — a saved order with
|
||||||
|
// no items reopens with an empty list (not a blank starter row).
|
||||||
|
[];
|
||||||
setItems(mappedItems);
|
setItems(mappedItems);
|
||||||
|
|
||||||
const mappedSections =
|
const mappedSections =
|
||||||
@@ -401,8 +403,18 @@ export default function IssuedOrderDetail() {
|
|||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
||||||
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
||||||
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
// Items are optional (an order may be issued by sections alone), but a
|
||||||
newErrors.items = "Přidejte alespoň jednu položku";
|
// completely blank order must not be finalizable: require at least one
|
||||||
|
// item with a description OR one non-empty section (title or content).
|
||||||
|
const hasItem = items.some((i) => i.description.trim());
|
||||||
|
const hasSection = sections.some(
|
||||||
|
(s) =>
|
||||||
|
(s.title_cz || "").trim() ||
|
||||||
|
(s.title || "").trim() ||
|
||||||
|
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||||
|
);
|
||||||
|
if (!hasItem && !hasSection) {
|
||||||
|
newErrors.items = "Přidejte alespoň jednu položku nebo obsah";
|
||||||
}
|
}
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
@@ -899,6 +911,7 @@ export default function IssuedOrderDetail() {
|
|||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
readOnly={!editable}
|
readOnly={!editable}
|
||||||
error={errors.items}
|
error={errors.items}
|
||||||
|
allowEmpty
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rich-text PDF sections — the free-form PDF content lives here */}
|
{/* Rich-text PDF sections — the free-form PDF content lives here */}
|
||||||
|
|||||||
@@ -752,8 +752,11 @@ ${indentCSS}
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Polozky -->
|
<!-- Polozky — an order may be issued by sections alone; with no items the
|
||||||
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
heading, items table and total row are omitted entirely. -->
|
||||||
|
${
|
||||||
|
items.length > 0
|
||||||
|
? `<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||||
<table class="items">
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -779,7 +782,9 @@ ${indentCSS}
|
|||||||
<span class="value">${formatCurrency(total, currency)}</span>
|
<span class="value">${formatCurrency(total, currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
${scopeHtml}
|
${scopeHtml}
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
return error(reply, "Dodavatel nenalezen", 400);
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
if (order.error === "po_number_taken")
|
if (order.error === "po_number_taken")
|
||||||
return error(reply, "Číslo objednávky je již použito", 409);
|
return error(reply, "Číslo objednávky je již použito", 409);
|
||||||
|
if (order.error === "empty_document")
|
||||||
|
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
|
||||||
return error(reply, "Neznámá chyba", 500);
|
return error(reply, "Neznámá chyba", 500);
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -408,6 +410,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
|
if (result.error === "empty_document")
|
||||||
|
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
|
||||||
return error(reply, "Neznámá chyba", 500);
|
return error(reply, "Neznámá chyba", 500);
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -148,6 +148,32 @@ export function computeIssuedOrderTotals(
|
|||||||
return { total: Math.round(total * 100) / 100 };
|
return { total: Math.round(total * 100) / 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An issued order may be issued by sections alone (no line items), but a
|
||||||
|
* completely blank order must not be finalizable. Content = at least one item
|
||||||
|
* with a description OR one section with a title or stripped (non-tag) content.
|
||||||
|
* Mirrors the PDF visibility test and the frontend submit guard.
|
||||||
|
*/
|
||||||
|
function hasOrderContent(
|
||||||
|
items: Array<{ description?: string | null }> | undefined,
|
||||||
|
sections:
|
||||||
|
| Array<{
|
||||||
|
title?: string | null;
|
||||||
|
title_cz?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
): boolean {
|
||||||
|
const anyItem = (items || []).some((it) => (it.description || "").trim());
|
||||||
|
const anySection = (sections || []).some(
|
||||||
|
(s) =>
|
||||||
|
(s.title_cz || "").trim() ||
|
||||||
|
(s.title || "").trim() ||
|
||||||
|
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||||
|
);
|
||||||
|
return anyItem || anySection;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||||
const { page, limit, skip, sort, order } = params;
|
const { page, limit, skip, sort, order } = params;
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
@@ -269,6 +295,12 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const status = body.status ? String(body.status) : "draft";
|
const status = body.status ? String(body.status) : "draft";
|
||||||
|
|
||||||
|
// A non-draft (created-as-finalized) order must not be blank — require an
|
||||||
|
// item or a non-empty section. Drafts may be saved empty (WIP).
|
||||||
|
if (status !== "draft" && !hasOrderContent(body.items, body.sections)) {
|
||||||
|
return { error: "empty_document" as const };
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
@@ -452,6 +484,27 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
body.status !== undefined &&
|
body.status !== undefined &&
|
||||||
String(body.status) === "sent";
|
String(body.status) === "sent";
|
||||||
|
|
||||||
|
// Finalizing a blank order is rejected — require an item or a non-empty
|
||||||
|
// section. The finalize payload carries items/sections; fall back to the
|
||||||
|
// stored rows when a partial finalize omits them.
|
||||||
|
if (finalizing) {
|
||||||
|
const itemsForCheck = Array.isArray(body.items)
|
||||||
|
? body.items
|
||||||
|
: await prisma.issued_order_items.findMany({
|
||||||
|
where: { issued_order_id: id },
|
||||||
|
select: { description: true },
|
||||||
|
});
|
||||||
|
const sectionsForCheck = Array.isArray(body.sections)
|
||||||
|
? body.sections
|
||||||
|
: await prisma.issued_order_sections.findMany({
|
||||||
|
where: { issued_order_id: id },
|
||||||
|
select: { title: true, title_cz: true, content: true },
|
||||||
|
});
|
||||||
|
if (!hasOrderContent(itemsForCheck, sectionsForCheck)) {
|
||||||
|
return { error: "empty_document" as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ONE transaction for the header write, the finalize numbering AND the
|
// ONE transaction for the header write, the finalize numbering AND the
|
||||||
// items/sections full-replace. The items replace used to run in a SECOND
|
// items/sections full-replace. The items replace used to run in a SECOND
|
||||||
// transaction after the header tx — a failure between the two left a torn
|
// transaction after the header tx — a failure between the two left a torn
|
||||||
|
|||||||
Reference in New Issue
Block a user