Files
app/docs/superpowers/specs/2026-06-09-issued-orders-design.md

20 KiB
Raw Permalink Blame History

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 customers table 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)

  1. Direction mapping. Today's orders (status default prijata, 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 a direction flag on the existing table.)
  2. Supplier source. A PO references the existing customers table (FK), used as a generic business partner. No new supplier/vendor master.
  3. Linkage. Standalone document in v1. No automatic links to warehouse or received invoices.
  4. UI placement. Tabs on the Orders page, mirroring the Invoices page. (As-built: Vydané first / Přijaté secondVydané | Přijaté, tab values vydane/prijate, persisted in ?tab= — matching the Invoices page order. A shared chevron month/year selector sits above the tabs and filters BOTH lists: Vydané by order_date, Přijaté by created_at. The pre-existing Přijaté search bug — search term sent but ignored by the backend — was also fixed.)
  5. Model shape. A dedicated issued_orders + issued_order_items pair, mirroring invoices/invoice_itemsnot an extension of orders and not an upload-only record like received_invoices.
  6. 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 as computeInvoiceTotals.
  7. Numbering. A dedicated number_sequences type ("issued_order"), separate from the "shared" orders/projects pool and from "invoice". Number assigned at creation (even for a draft), with release-on-delete.
  8. Permissions. Reuse the existing orders.* set (view/create/edit/ delete) plus orders.export for 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_sequences type string "issued_order" (unique on (type, year), as the table already enforces). Separate from "shared" and "invoice".
  • New company_settings columns:
    • issued_order_number_pattern VARCHAR default "{YY}{CODE}{NNNN}"
    • issued_order_type_code VARCHAR default "72" (orders use 71, invoices 81; 72 is free and configurable).
  • New functions in src/services/numbering.service.ts, reusing the existing applyPattern + getNextSequence (SELECT … FOR UPDATE lock, 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), mirroring releaseInvoiceNumber.
  • 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_idnullableIntIdFromForm
  • quantitypositiveNumberFromForm; unit_pricenonNegativeNumberFromForm
  • vat_ratenumberInRange(0, 100)
  • order_date / delivery_dateisoDateString (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; filters status, 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 via computeIssuedOrderTotals.
  • getIssuedOrder(id) — full detail with items, supplier name, and valid_transitions.
  • createIssuedOrder(body) — inside prisma.$transaction: generateIssuedOrderNumber(tx) → insert header → batch-insert items.
  • updateIssuedOrder(id, body) — validates the status transition against VALID_TRANSITIONS; replaces items (delete-all + recreate) only while the doc is editable (draft/sent); blocks any po_number change.
  • deleteIssuedOrder(id) — delete (items cascade) then releaseIssuedOrderNumber(year, po_number).
  • computeIssuedOrderTotals(items, applyVat, docRate)NET + VAT-on-top, rounded per line before accumulation (same shape as computeInvoiceTotals): base = qty × unit_price; lineVat = round(base × rate/100) when applyVat; subtotal = Σ base, vat = Σ lineVat, total = subtotal + vat. Falls back line vat_rate → doc vat_rate → 21.
  • getNextIssuedOrderNumber() — proxies previewIssuedOrderNumber().

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 as application/pdfno 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 customers record (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 / en strings selected by language. Czech date/currency via the existing formatters.
  • notes passed through the same cleanQuillHtml + 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/pdf with Content-Disposition filename {po_number}.pdf. No NAS write. (A future ?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf add-on is a small, isolated follow-up.)

Frontend

  • src/admin/pages/Orders.tsx — add Vydané | Přijaté tabs using the same MUI Tabs pattern as Invoices.tsx. The current orders list moves verbatim into the Přijaté tab — every hook, mutation, invalidate array, validation, and permission check preserved unchanged; only presentation is reorganized. The Vydané tab renders the new list. (As-built: Orders.tsx became a parent shell that owns the PageHeader + the shared month/year selector + the tabs; IssuedOrders.tsx (Vydané) and OrdersReceived.tsx (Přijaté) are content-only children receiving month/year props. OrdersReceived's create modal is hoisted to the shell via createOpen/setCreateOpen, the ReceivedInvoices pattern. No KPI/stat cards on the Orders landing — deliberate, since orders have no paid/overdue semantics.)
  • Vydané listDataTable columns: po_number, supplier (customer name), status chip (color per status), order_date, total (currency), actions (open, Export PDF, delete). FilterBar with status + customer Selects and search at standard widths; pagination + sortable headers; an "Vytvořit objednávku vydanou" button gated on orders.create.
  • src/admin/pages/IssuedOrderDetail.tsx (lazy routes /orders/issued/new and /orders/issued/:id in AdminApp.tsx): supplier Select, order_date / delivery_date DateFields, currency / VAT rate / apply_vat, a line-items table (add/remove rows + @dnd-kit reorder, like InvoiceDetail), RichEditor notes, internal notes, delivery_terms / payment_terms, issued_by, live totals (net / VAT / gross), status-transition buttons, and an Export PDF button gated on orders.export. Top-level sections wrapped in <PageEnter>; imports come from the ui/ kit.
  • Queries src/admin/lib/queries/issued-orders.tsissuedOrderListOptions, 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 lint enforces react-hooks/rules-of-hooks as 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 view
  • orders.create — create + next-number preview
  • orders.edit — update
  • orders.delete — delete
  • orders.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_TRANSITIONS guard 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=false zeroes 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/pdf returns 200 with content-type: application/pdf.

Migration

A single prisma migrate dev --name issued_orders:

  1. Creates issued_orders and issued_order_items tables + the issued_orders_status enum.
  2. Adds issued_order_number_pattern and issued_order_type_code columns to company_settings with 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.