From 4072197f4add1eae3ed08e335ba655414149f949 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 08:35:02 +0200 Subject: [PATCH] =?UTF-8?q?docs(orders):=20design=20spec=20for=20issued=20?= =?UTF-8?q?purchase=20orders=20(objedn=C3=A1vky=20vydan=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed design for a new 'objednávky vydané' document type — purchase orders you author with line items and export to PDF, alongside the existing customer orders (which become the 'přijaté' tab). Key decisions: dedicated issued_orders/issued_order_items models mirroring invoices; customers reused as supplier/partner; NET+VAT-on-top; dedicated numbering sequence; reuse of orders.* permissions; standalone v1 (no warehouse/received-invoice links). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-09-issued-orders-design.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-issued-orders-design.md diff --git a/docs/superpowers/specs/2026-06-09-issued-orders-design.md b/docs/superpowers/specs/2026-06-09-issued-orders-design.md new file mode 100644 index 0000000..2d6a9b3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-issued-orders-design.md @@ -0,0 +1,356 @@ +# Objednávky vydané (Issued Purchase Orders) — Design Spec + +**Date:** 2026-06-09 +**Status:** Approved (brainstorm) → ready for implementation plan +**Author:** brainstorm session + +--- + +## 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** (`Přijaté | Vydané`), mirroring + the Invoices page. +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 + +New route + template `src/routes/admin/issued-orders-pdf.ts`, modeled on +`invoices-pdf.ts`, reusing `htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`: + +- **Sender block** = your company (`company_settings`: name, IČO, DIČ, address, + logo as base64). **Recipient block** = the selected supplier (the `customers` + record: name, IČO/`company_id`, DIČ/`vat_id`, address). +- 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 `Přijaté | Vydané` 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. +- **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.