20 KiB
Objednávky vydané (Issued Purchase Orders) — Design Spec
Date: 2026-06-09 Status: Approved (brainstorm) → ready for implementation plan Author: brainstorm session
Updated 2026-06-09 (as-built): Shipped with three deviations from this brainstorm — Vydané-first tabs (Vydané | Přijaté, mirroring Invoices), a shared chevron month/year filter above the tabs that filters both lists, and a PDF rebuilt on the shared invoice/order-confirmation template rather than the custom layout sketched below. See the affected sections (UI placement, PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
Goal
Add a new document type — objednávky vydané (purchase orders you issue to a supplier) — alongside the existing customer orders. You author the PO with line items, it gets an auto-generated number, you track its status, and you export it to PDF to send to the supplier. The existing orders module becomes the přijaté (customer-order) side of a two-tab UI, mirroring how the Invoices page already splits Vydané | Přijaté.
Non-goals (v1)
- No cross-module wiring. A PO does not link to warehouse goods receipts
(
sklad_receipts) or to received invoices (faktury přijaté). The full procurement loop (ordered → received → invoiced) is a deliberate later phase. - No new supplier master. POs reuse the existing
customerstable as generic business partners. - No NAS persistence of the PO PDF in v1 (generated on demand only).
- No "scope sections" (the CZ/EN rich-text blocks orders/quotations carry).
- No AI/Odin authoring. POs are authored by hand; Odin extraction is a received-document concept and does not apply to documents you create.
Decisions log (resolved during brainstorm)
- Direction mapping. Today's
orders(status defaultprijata, flowing quotation → order → project → invoice) stay as the Přijaté side, largely unchanged — only surfaced under a new tab. The new Vydané side is a brand-new document type. (Not adirectionflag on the existing table.) - Supplier source. A PO references the existing
customerstable (FK), used as a generic business partner. No new supplier/vendor master. - Linkage. Standalone document in v1. No automatic links to warehouse or received invoices.
- UI placement. Tabs on the Orders page, mirroring the Invoices page.
(As-built: Vydané first / Přijaté second —
Vydané | Přijaté, tab valuesvydane/prijate, persisted in?tab=— matching the Invoices page order. A shared chevron month/year selector sits above the tabs and filters BOTH lists: Vydané byorder_date, Přijaté bycreated_at. The pre-existing Přijaté search bug — search term sent but ignored by the backend — was also fixed.) - Model shape. A dedicated
issued_orders+issued_order_itemspair, mirroringinvoices/invoice_items— not an extension ofordersand not an upload-only record likereceived_invoices. - VAT regime. NET base + VAT-on-top (the issued-invoice convention),
the opposite of
received_invoices(which are gross/VAT-inclusive). Totals use the same per-line-rounded math ascomputeInvoiceTotals. - Numbering. A dedicated
number_sequencestype ("issued_order"), separate from the"shared"orders/projects pool and from"invoice". Number assigned at creation (even for adraft), with release-on-delete. - Permissions. Reuse the existing
orders.*set (view/create/edit/delete) plusorders.exportfor the PDF — mirrors invoices sharing one permission set across both tabs; no permission migration needed.
Architecture
A self-contained vertical slice following the codebase's established document
pattern (the same shape invoices uses):
prisma/schema.prisma → models issued_orders, issued_order_items + enum
src/schemas/issued-orders.schema.ts
src/services/issued-orders.service.ts
src/services/numbering.service.ts (extend: issued-order number fns)
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
src/admin/lib/queries/issued-orders.ts
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
src/admin/AdminApp.tsx (lazy routes for detail/new)
src/__tests__/issued-orders.test.ts
Tech stack: Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
(success/error helpers, parseBody/parseId, shared Zod coercers, broad query
invalidation, deterministic ordering with an id tiebreak, locked
read-modify-write for numbering).
Data model
issued_orders (parallels invoices)
| Field | Type | Notes |
|---|---|---|
id |
Int PK autoincrement | |
po_number |
VARCHAR(50) unique | auto-assigned (see Numbering) |
customer_id |
Int FK → customers (onDelete Restrict, nullable) |
the supplier/partner being ordered from |
status |
enum issued_orders_status |
default draft |
currency |
VARCHAR(10) default "CZK" |
|
vat_rate |
Decimal(5,2) default 21.00 |
doc-level default rate |
apply_vat |
Boolean default true |
|
exchange_rate |
Decimal(12,4) nullable | foreign-currency, like orders |
order_date |
Date | date the PO is issued |
delivery_date |
Date nullable | requested delivery / required-by |
language |
VARCHAR(5) default "cs" |
drives PDF language |
delivery_terms |
VARCHAR(500) nullable | optional, shown on PDF |
payment_terms |
VARCHAR(500) nullable | optional, shown on PDF |
issued_by |
VARCHAR(255) nullable | signatory name |
notes |
Text nullable | rich text, rendered on PDF (sanitized) |
internal_notes |
Text nullable | private, not on PDF |
created_at |
DateTime default now | |
modified_at |
DateTime @updatedAt |
Relations: issued_order_items[], customers?.
Indexes: (customer_id), (status, order_date).
issued_order_items (parallels invoice_items)
| Field | Type | Notes |
|---|---|---|
id |
Int PK | |
issued_order_id |
Int FK → issued_orders (onDelete Cascade) |
|
description |
VARCHAR(500) | main line text |
item_description |
Text nullable | optional long detail |
quantity |
Decimal(12,3) default 1.000 |
|
unit |
VARCHAR(20) nullable | e.g. ks, hod, kg |
unit_price |
Decimal(12,2) default 0.00 |
NET unit price |
vat_rate |
Decimal(5,2) default 21.00 |
per-line; overrides doc default |
position |
Int default 0 |
ordering |
Index: (issued_order_id).
issued_orders_status enum
draft, sent, confirmed, completed, cancelled
(Czech labels: Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná.)
Numbering
A dedicated sequence, exactly like invoices:
- New
number_sequencestype string"issued_order"(unique on(type, year), as the table already enforces). Separate from"shared"and"invoice". - New
company_settingscolumns:issued_order_number_patternVARCHAR default"{YY}{CODE}{NNNN}"issued_order_type_codeVARCHAR default"72"(orders use71, invoices81;72is free and configurable).
- New functions in
src/services/numbering.service.ts, reusing the existingapplyPattern+getNextSequence(SELECT … FOR UPDATElock, P2002 first-of-year retry):generateIssuedOrderNumber(tx)— atomically consumes the next number.previewIssuedOrderNumber()— non-consuming peek (for the create form).releaseIssuedOrderNumber(year, number)— decrement only if the deleted PO held the current highest number that year (gap prevention), mirroringreleaseInvoiceNumber.
- The number is assigned at creation, including for a
draft. Deleting a PO releases the number per the rule above.
Backend API
Schema — src/schemas/issued-orders.schema.ts
CreateIssuedOrderSchema, UpdateIssuedOrderSchema, IssuedOrderItemSchema,
built only from the shared coercers in src/schemas/common.ts:
customer_id→nullableIntIdFromFormquantity→positiveNumberFromForm;unit_price→nonNegativeNumberFromFormvat_rate→numberInRange(0, 100)order_date/delivery_date→isoDateString(lenient)- text fields →
z.string().max(...); optional email-like none here - Czech user-facing messages; English identifiers.
No raw z.union([z.number(), z.string()]).transform(Number) — that idiom is
banned (silent NaN).
Service — src/services/issued-orders.service.ts
Plain exported async functions returning { data } or { error, status }:
listIssuedOrders(params)— paginated; filtersstatus,customer_id,search(po_number / supplier name); whitelist sort (id/po_number/status/order_date/created_at) with a deterministic{ id }tiebreak; each row enriched viacomputeIssuedOrderTotals.getIssuedOrder(id)— full detail with items, supplier name, andvalid_transitions.createIssuedOrder(body)— insideprisma.$transaction:generateIssuedOrderNumber(tx)→ insert header → batch-insert items.updateIssuedOrder(id, body)— validates the status transition againstVALID_TRANSITIONS; replaces items (delete-all + recreate) only while the doc is editable (draft/sent); blocks anypo_numberchange.deleteIssuedOrder(id)— delete (items cascade) thenreleaseIssuedOrderNumber(year, po_number).computeIssuedOrderTotals(items, applyVat, docRate)— NET + VAT-on-top, rounded per line before accumulation (same shape ascomputeInvoiceTotals):base = qty × unit_price;lineVat = round(base × rate/100)whenapplyVat;subtotal = Σ base,vat = Σ lineVat,total = subtotal + vat. Falls back linevat_rate→ docvat_rate→ 21.getNextIssuedOrderNumber()— proxiespreviewIssuedOrderNumber().
VALID_TRANSITIONS:
draft → [sent, cancelled]
sent → [confirmed, cancelled]
confirmed → [completed, cancelled]
completed → [] // terminal
cancelled → [] // terminal
Routes — src/routes/admin/issued-orders.ts
Registered in src/routes/admin/index.ts. Every handler uses success()/
error(), parseBody(Schema, …), parseId((request.params as any).id, reply),
and logAudit with a new entity type "issued_order" (create/update/delete,
with oldValues/newValues). Adding this entity type requires also registering
its Czech label in src/admin/lib/entityTypeLabels.ts (keyed to the server's
EntityType value), per the single-source-of-truth convention.
| Method · Path | Guard | Purpose |
|---|---|---|
GET /api/admin/issued-orders |
orders.view |
list (paginated, filtered) |
GET /api/admin/issued-orders/next-number |
orders.create |
preview next PO number |
GET /api/admin/issued-orders/:id |
orders.view |
detail |
POST /api/admin/issued-orders |
orders.create |
create |
PUT /api/admin/issued-orders/:id |
orders.edit |
update |
DELETE /api/admin/issued-orders/:id |
orders.delete |
delete |
GET /api/admin/issued-orders/:id/pdf |
orders.export |
render + stream PDF |
PDF export
As-built (2026-06-09): rather than the custom/compact layout this section originally sketched, the issued-order PDF was rebuilt on the shared invoice / order-confirmation red-accent (#de3a3a) template (
src/routes/admin/issued-orders-pdf.ts), titled OBJEDNÁVKA, for visual consistency with the rest of the document suite. Because a PO is a buying document, the direction is flipped vs an invoice: the selected customer record renders as "Dodavatel" (supplier) and your company as "Odběratel" (buyer). It is generated on demand and streamed asapplication/pdf— no NAS persistence. Sanitization, on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
New route + template src/routes/admin/issued-orders-pdf.ts, reusing
htmlToPdf (Puppeteer) from src/utils/pdf.ts:
- Dodavatel (supplier) block = the selected
customersrecord (name, IČO/company_id, DIČ/vat_id, address). Odběratel (buyer) block = your company (company_settings: name, IČO, DIČ, address, logo as base64). - Body: items table → VAT recap grouped by rate → NET total + VAT + gross.
cs/enstrings selected bylanguage. Czech date/currency via the existing formatters.notespassed through the samecleanQuillHtml+ DOMPurify sanitization used by invoices/orders before going into the template (mandatory for any new rich-text-on-PDF field).- v1 behavior: generate on demand and stream
application/pdfwithContent-Dispositionfilename{po_number}.pdf. No NAS write. (A future?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdfadd-on is a small, isolated follow-up.)
Frontend
src/admin/pages/Orders.tsx— addVydané | Přijatétabs using the same MUI Tabs pattern asInvoices.tsx. The current orders list moves verbatim into the Přijaté tab — every hook, mutation,invalidatearray, validation, and permission check preserved unchanged; only presentation is reorganized. The Vydané tab renders the new list. (As-built:Orders.tsxbecame a parent shell that owns thePageHeader+ the shared month/year selector + the tabs;IssuedOrders.tsx(Vydané) andOrdersReceived.tsx(Přijaté) are content-only children receivingmonth/yearprops.OrdersReceived's create modal is hoisted to the shell viacreateOpen/setCreateOpen, the ReceivedInvoices pattern. No KPI/stat cards on the Orders landing — deliberate, since orders have no paid/overdue semantics.)- Vydané list —
DataTablecolumns:po_number, supplier (customer name), status chip (color per status),order_date, total (currency), actions (open, Export PDF, delete).FilterBarwith status + customerSelects and search at standard widths; pagination + sortable headers; an "Vytvořit objednávku vydanou" button gated onorders.create. src/admin/pages/IssuedOrderDetail.tsx(lazy routes/orders/issued/newand/orders/issued/:idinAdminApp.tsx): supplierSelect,order_date/delivery_dateDateFields, currency / VAT rate /apply_vat, a line-items table (add/remove rows +@dnd-kitreorder, likeInvoiceDetail),RichEditornotes, internal notes,delivery_terms/payment_terms,issued_by, live totals (net / VAT / gross), status-transition buttons, and an Export PDF button gated onorders.export. Top-level sections wrapped in<PageEnter>; imports come from theui/kit.- Queries
src/admin/lib/queries/issued-orders.ts—issuedOrderListOptions,issuedOrderDetailOptions,issuedOrderNextNumberOptions. All mutations invalidate the broad["issued-orders"]domain key. - Rules of Hooks: all hooks run before any early permission
return(<Forbidden/>/<Navigate/>go after every hook) —npm run lintenforcesreact-hooks/rules-of-hooksas an error. - Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
Permissions
Reuse the existing orders.* set for the Vydané tab:
orders.view— list + detail + PDF vieworders.create— create + next-number previeworders.edit— updateorders.delete— deleteorders.export— PDF export (already seeded)
This mirrors invoices (issued + received share one invoices.* set) and needs
no permission migration. _(Future option, out of scope for v1: a dedicated
purchase-orders._ set via a seed + migration if purchasing must be locked down
separately from sales orders.)*
Status lifecycle
draft → sent → confirmed → completed, plus any non-terminal → cancelled.
- Editable (header + items):
draft,sent. - Read-only except a permitted status change:
confirmed,completed,cancelled. - Enforced server-side by the
VALID_TRANSITIONSguard in the service; the detail page only renders buttons for valid next states.
Testing
src/__tests__/issued-orders.test.ts, against the real app_test DB via
buildApp() (no Prisma mocks):
- Numbering: sequential allocation; release-only-if-highest on delete; the generated number matches the configured pattern.
- Create + totals: create with multiple items, assert NET+VAT-on-top totals
with per-line rounding pinned to exact 2-decimal values; mixed per-line VAT
rates;
apply_vat=falsezeroes VAT but preserves the NET subtotal. - Status transitions: every valid transition succeeds; invalid transitions
return the proper error; items become read-only after
confirmed. - Permissions: each endpoint rejects a caller lacking the required
orders.*permission (not bare auth). - PDF:
GET /:id/pdfreturns200withcontent-type: application/pdf.
Migration
A single prisma migrate dev --name issued_orders:
- Creates
issued_ordersandissued_order_itemstables + theissued_orders_statusenum. - Adds
issued_order_number_patternandissued_order_type_codecolumns tocompany_settingswith SQL defaults ("{YY}{CODE}{NNNN}","72").
Per project rules: ask the user to stop the dev server first; commit both
schema.prisma and the generated migration folder; run prisma generate. No raw
SQL on prod — the column defaults ship inside the migration's migration.sql.
Quality gates before "done": npx tsc -b --noEmit, npm run build,
npx vitest run, npm run lint (0 errors).
Future phases (explicitly out of scope for v1)
- Procurement links: PO ↔ warehouse goods receipt (
sklad_receipts) and PO ↔ received invoice matching (ordered → received → invoiced rollups), with Odin pre-filling a received invoice from a PO. - NAS persistence of the generated PO PDF (
?save=1). - Dedicated
purchase-orders.*permission set. - Scope sections (CZ/EN rich-text blocks) if POs ever need narrative content.