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>
357 lines
18 KiB
Markdown
357 lines
18 KiB
Markdown
# 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.
|