# 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é second** — `Vydané | 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_items` — _not_ 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_id` → `nullableIntIdFromForm` - `quantity` → `positiveNumberFromForm`; `unit_price` → `nonNegativeNumberFromForm` - `vat_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; 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/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 `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é list** — `DataTable` columns: `po_number`, supplier (customer name), status chip (color per status), `order_date`, total (currency), actions (open, **Export PDF**, delete). `FilterBar` with status + customer `Select`s 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` `DateField`s, 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 ``; imports come from the `ui/` 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` (``/`` 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.