Compare commits

...

3 Commits

Author SHA1 Message Date
BOHA
81293ae543 chore(release): v2.4.40 — optional line items on issued orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:03:54 +02:00
BOHA
237ebf3ef8 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>
2026-06-24 21:36:35 +02:00
BOHA
983a1408f1 docs(release): clarify nginx reload is only for nginx config changes, not every deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:44:47 +02:00
10 changed files with 188 additions and 20 deletions

View File

@@ -46,6 +46,17 @@ without explicit user confirmation.**
errors from prior pids). errors from prior pids).
9. Clean up tarballs locally and in `/tmp/` on the server. 9. Clean up tarballs locally and in `/tmp/` on the server.
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
`client_max_body_size`, rate limits, etc.). When you do change it, always
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
nginx` (graceful re-read, keeps active connections; a bad config on reload is
rejected and the old one keeps running).
Risky releases (framework jumps, FK/constraint-altering migrations) get a Risky releases (framework jumps, FK/constraint-altering migrations) get a
read-only pre-flight first: `prisma migrate status` on prod, verify FK read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new constraint names the migration DROPs, check no data violates new

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.39", "version": "2.4.40",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.39", "version": "2.4.40",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.39", "version": "2.4.40",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -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);

View File

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

View File

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

View File

@@ -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 */}

View File

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

View File

@@ -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({

View File

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