# 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 `<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.
