docs(orders): design spec for issued purchase orders (objednávky vydané)

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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 08:35:02 +02:00
parent 1914fddb4d
commit 4072197f4a

View File

@@ -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 `<PageEnter>`;
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`
(`<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.