From 4072197f4add1eae3ed08e335ba655414149f949 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 08:35:02 +0200 Subject: [PATCH 01/18] =?UTF-8?q?docs(orders):=20design=20spec=20for=20iss?= =?UTF-8?q?ued=20purchase=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. From 15a2da38cc6430e2c09d2c07d4cb4fe01ebe1380 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 10:25:36 +0200 Subject: [PATCH 02/18] docs(orders): implementation plan for issued purchase orders Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-09-issued-orders.md | 2414 +++++++++++++++++ 1 file changed, 2414 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-issued-orders.md diff --git a/docs/superpowers/plans/2026-06-09-issued-orders.md b/docs/superpowers/plans/2026-06-09-issued-orders.md new file mode 100644 index 0000000..119973a --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-issued-orders.md @@ -0,0 +1,2414 @@ +# Objednávky vydané (Issued Purchase Orders) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `objednávky vydané` (issued purchase order) document type — authored with line items, auto-numbered, status-tracked, and exportable to PDF — surfaced as a second tab on the existing Orders page. + +**Architecture:** A self-contained vertical slice mirroring the existing `invoices` document (dedicated `issued_orders` + `issued_order_items` tables, a dedicated number sequence, a service returning `{data}`/`{error,status}`, thin routes using the shared helpers, a Puppeteer PDF route, and React Query + MUI frontend). The existing customer-order `orders` module is preserved verbatim and becomes the `Přijaté` tab. No cross-module wiring (warehouse / received-invoice links are a later phase). + +**Tech Stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React Query, Puppeteer, Vitest. + +**Source spec:** `docs/superpowers/specs/2026-06-09-issued-orders-design.md` + +**Conventions for every task:** + +- Branch is `feat/issued-orders` (already created off `master`; the spec commit is the first commit on it). +- All commits end with the trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Tests live in `src/__tests__/issued-orders.test.ts` and run against the throwaway `app_test` DB via `npx vitest run`. They import service functions directly and clean up in `afterEach` (the established pattern from `src/__tests__/manual-create.test.ts`). Do **not** mock Prisma. +- Quality gates after the relevant tasks: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`, `npm run lint` (0 errors). +- Czech for user-facing strings; English for identifiers. + +--- + +## File Structure + +| File | Responsibility | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prisma/schema.prisma` (modify) | Add `issued_orders`, `issued_order_items` models + `issued_orders_status` enum + 2 `company_settings` columns | +| `prisma/migrations/_issued_orders/` (create) | The generated migration | +| `src/services/numbering.service.ts` (modify) | Add `generateIssuedOrderNumber` / `previewIssuedOrderNumber` / `releaseIssuedOrderNumber` / `isIssuedOrderNumberTaken`; extend `getSettings` + `releaseSequence` | +| `src/schemas/issued-orders.schema.ts` (create) | `CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema` | +| `src/services/issued-orders.service.ts` (create) | List/get/create/update/delete + `computeIssuedOrderTotals` + `VALID_TRANSITIONS` | +| `src/routes/admin/issued-orders.ts` (create) | CRUD + next-number endpoints | +| `src/routes/admin/issued-orders-pdf.ts` (create) | `renderIssuedOrderHtml` (pure) + PDF route | +| `src/server.ts` (modify) | Register both route plugins | +| `src/types/index.ts` (modify) | Add `"issued_order"` to `EntityType` | +| `src/admin/lib/entityTypeLabels.ts` (modify) | Add `issued_order` Czech label | +| `src/admin/lib/queries/issued-orders.ts` (create) | React Query options + TS types | +| `src/admin/pages/OrdersReceived.tsx` (create, from move) | The current Orders page, moved verbatim | +| `src/admin/pages/Orders.tsx` (rewrite) | Tab shell: `Přijaté` \| `Vydané` | +| `src/admin/pages/IssuedOrders.tsx` (create) | The Vydané list | +| `src/admin/pages/IssuedOrderDetail.tsx` (create) | Create/edit form + PDF export | +| `src/admin/AdminApp.tsx` (modify) | Lazy routes for the detail page | +| `src/__tests__/issued-orders.test.ts` (create, grown per task) | All backend tests | + +--- + +## Task 1: Database schema & migration + +**Files:** + +- Modify: `prisma/schema.prisma` (add two models + one enum after the `orders` model; add two columns to `company_settings`) +- Create: `prisma/migrations/_issued_orders/migration.sql` (generated) + +- [ ] **Step 1: Add the two new columns to `company_settings`** + +In `prisma/schema.prisma`, inside `model company_settings { … }`, add these two lines next to the other `*_number_pattern` / `*_type_code` fields: + +```prisma + issued_order_number_pattern String? @db.VarChar(100) + issued_order_type_code String? @db.VarChar(10) +``` + +- [ ] **Step 2: Add the two new models + enum** + +Add immediately after the `orders` model block: + +```prisma +model issued_orders { + id Int @id @default(autoincrement()) + po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) + customer_id Int? + status issued_orders_status @default(draft) + currency String? @default("CZK") @db.VarChar(10) + vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) + apply_vat Boolean? @default(true) + exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) + order_date DateTime? @db.Date + delivery_date DateTime? @db.Date + language String? @default("cs") @db.VarChar(5) + delivery_terms String? @db.VarChar(500) + payment_terms String? @db.VarChar(500) + issued_by String? @db.VarChar(255) + notes String? @db.Text + internal_notes String? @db.Text + created_at DateTime? @default(now()) @db.DateTime(0) + modified_at DateTime? @db.DateTime(0) + issued_order_items issued_order_items[] + customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1") + + @@index([customer_id], map: "issued_orders_customer_id") + @@index([status, order_date], map: "idx_issued_orders_status_date") +} + +model issued_order_items { + id Int @id @default(autoincrement()) + issued_order_id Int + description String? @db.VarChar(500) + item_description String? @db.Text + quantity Decimal? @default(1.000) @db.Decimal(12, 3) + unit String? @db.VarChar(20) + unit_price Decimal? @default(0.00) @db.Decimal(12, 2) + vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) + position Int? @default(0) + issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1") + + @@index([issued_order_id], map: "issued_order_id") +} + +enum issued_orders_status { + draft + sent + confirmed + completed + cancelled +} +``` + +- [ ] **Step 3: Add the back-relation on `customers`** + +In `model customers { … }`, add a back-relation line alongside its existing `orders`/`invoices` relations: + +```prisma + issued_orders issued_orders[] +``` + +- [ ] **Step 4: Ask the user to stop the dev server, then create the migration** + +Tell the user: "I need to run `prisma migrate dev` — please stop your dev server and confirm." Wait for confirmation. Then run: + +``` +npx prisma migrate dev --name issued_orders +``` + +Expected: a new folder `prisma/migrations/_issued_orders/` is created and applied to the dev `app` DB. Open `migration.sql` and confirm it `CREATE TABLE issued_orders`, `CREATE TABLE issued_order_items`, and `ALTER TABLE company_settings ADD COLUMN issued_order_number_pattern …` / `… issued_order_type_code …`. + +- [ ] **Step 5: Give the two new settings columns their defaults in the migration SQL** + +Edit the generated `migration.sql` so the two new `company_settings` columns carry their baseline defaults (production needs them without a manual step). Ensure these statements are present (adjust to match the generated column DDL — `ALTER` the defaults if Prisma created them nullable without a default): + +```sql +ALTER TABLE `company_settings` + ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL DEFAULT '{YY}{CODE}{NNNN}', + ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL DEFAULT '72'; + +UPDATE `company_settings` + SET `issued_order_number_pattern` = '{YY}{CODE}{NNNN}' + WHERE `issued_order_number_pattern` IS NULL; +UPDATE `company_settings` + SET `issued_order_type_code` = '72' + WHERE `issued_order_type_code` IS NULL; +``` + +Re-apply if you edited after generation: `npx prisma migrate dev` (it will detect the edited pending migration is already applied; if it reports drift, `npx prisma migrate reset` on the **dev** DB is acceptable since dev data is disposable — never on prod). + +- [ ] **Step 6: Regenerate the Prisma client** + +``` +npx prisma generate +``` + +- [ ] **Step 7: Apply the migration to the `app_test` DB so tests can run** + +In PowerShell, point `DATABASE_URL` at the `app_test` database (the value is in `.env.test`) and deploy: + +```powershell +$env:DATABASE_URL = "" +npx prisma migrate deploy +Remove-Item Env:DATABASE_URL +``` + +Expected: "All migrations have been applied" (including `_issued_orders`). + +- [ ] **Step 8: Verify it compiles** + +``` +npx tsc -b --noEmit +``` + +Expected: PASS (the generated client now exposes `prisma.issued_orders` / `prisma.issued_order_items`). + +- [ ] **Step 9: Commit** + +``` +git add prisma/schema.prisma prisma/migrations +git commit -m "feat(orders): issued_orders schema + migration" +``` + +--- + +## Task 2: Numbering — issued-order number sequence + +**Files:** + +- Modify: `src/services/numbering.service.ts` +- Test: `src/__tests__/issued-orders.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/issued-orders.test.ts`: + +```typescript +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { + generateIssuedOrderNumber, + previewIssuedOrderNumber, + releaseIssuedOrderNumber, +} from "../services/numbering.service"; + +afterEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); +}); + +describe("issued-order numbering", () => { + it("generates sequential numbers", async () => { + const year = new Date().getFullYear(); + const a = await generateIssuedOrderNumber(year); + const b = await generateIssuedOrderNumber(year); + expect(Number(b.number)).toBe(Number(a.number) + 1); + }); + + it("preview does not consume the sequence", async () => { + const year = new Date().getFullYear(); + const p1 = await previewIssuedOrderNumber(year); + const p2 = await previewIssuedOrderNumber(year); + expect(p1.number).toBe(p2.number); + }); + + it("release frees only the highest number", async () => { + const year = new Date().getFullYear(); + const a = await generateIssuedOrderNumber(year); + const b = await generateIssuedOrderNumber(year); + await releaseIssuedOrderNumber(year, b.number); + expect((await previewIssuedOrderNumber(year)).number).toBe(b.number); + await releaseIssuedOrderNumber(year, a.number); // not highest → no-op + expect((await previewIssuedOrderNumber(year)).number).toBe(b.number); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: FAIL — `generateIssuedOrderNumber` is not exported. + +- [ ] **Step 3: Add the default pattern constant** + +In `src/services/numbering.service.ts`, next to the existing `DEFAULT_*` constants (after `DEFAULT_INVOICE_PATTERN`): + +```typescript +const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}"; +``` + +- [ ] **Step 4: Select the new settings columns in `getSettings()`** + +Add these two lines to the `select` object inside `getSettings()`: + +```typescript + issued_order_number_pattern: true, + issued_order_type_code: true, +``` + +- [ ] **Step 5: Teach `releaseSequence` about the new type** + +Inside `releaseSequence()`, extend the `pattern` and `code` ternaries to add an `issued_order` branch (insert the new branch before the final `offer` fallback): + +```typescript +const pattern = + type === "shared" + ? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN + : type === "invoice" + ? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN + : type === "issued_order" + ? settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN + : settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; +const code = + type === "shared" + ? settings?.order_type_code || "71" + : type === "invoice" + ? settings?.invoice_type_code || "81" + : type === "issued_order" + ? settings?.issued_order_type_code || "72" + : ""; +``` + +- [ ] **Step 6: Add the taken-check + the three public functions** + +Add near the other `is*NumberTaken` helpers and the invoice number functions: + +```typescript +/** Verify an issued-order (PO) number is not already used. */ +async function isIssuedOrderNumberTaken(number: string): Promise { + const existing = await prisma.issued_orders.findFirst({ + where: { po_number: number }, + }); + return !!existing; +} + +export async function generateIssuedOrderNumber( + _year?: number, + tx?: TxClient, +): Promise<{ number: string; next_number: string }> { + const settings = await getSettings(); + const pattern = + settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN; + const code = settings?.issued_order_type_code || "72"; + const year = _year || new Date().getFullYear(); + + for (let attempt = 0; attempt < 100; attempt++) { + const seq = await getNextSequence("issued_order", year, tx); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + if (!(await isIssuedOrderNumberTaken(number))) { + return { number, next_number: number }; + } + } + throw new Error("Nepodařilo se vygenerovat jedinečné číslo objednávky"); +} + +export async function previewIssuedOrderNumber( + _year?: number, +): Promise<{ number: string; next_number: string }> { + const settings = await getSettings(); + const pattern = + settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN; + const code = settings?.issued_order_type_code || "72"; + const year = _year || new Date().getFullYear(); + + const seq = await previewNextSequence("issued_order", year); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + return { number, next_number: number }; +} + +export async function releaseIssuedOrderNumber( + year?: number, + deletedNumber?: string, +) { + await releaseSequence( + "issued_order", + year || new Date().getFullYear(), + deletedNumber, + ); +} +``` + +> Note: `getNextSequence`, `previewNextSequence`, `releaseSequence`, `applyPattern`, and `TxClient` already exist in this file — do not redefine them. + +- [ ] **Step 7: Run the test to confirm it passes** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: PASS (3 passing). + +- [ ] **Step 8: Commit** + +``` +git add src/services/numbering.service.ts src/__tests__/issued-orders.test.ts +git commit -m "feat(orders): issued-order number sequence" +``` + +--- + +## Task 3: Zod validation schemas + +**Files:** + +- Create: `src/schemas/issued-orders.schema.ts` +- Test: `src/__tests__/issued-orders.test.ts` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `src/__tests__/issued-orders.test.ts`: + +```typescript +import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema"; + +describe("CreateIssuedOrderSchema", () => { + it("coerces string form numbers and rejects an out-of-range VAT", () => { + const ok = CreateIssuedOrderSchema.safeParse({ + customer_id: "5", + vat_rate: "21", + items: [{ description: "X", quantity: "2", unit_price: "100" }], + }); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data.customer_id).toBe(5); + + const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" }); + expect(bad.success).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: FAIL — module `../schemas/issued-orders.schema` not found. + +- [ ] **Step 3: Create the schema file** + +Create `src/schemas/issued-orders.schema.ts`: + +```typescript +import { z } from "zod"; +import { + numberInRange, + nonNegativeNumberFromForm, + positiveNumberFromForm, + nullableIntIdFromForm, + booleanFromForm, + isoDateString, +} from "./common"; + +export const IssuedOrderItemSchema = z.object({ + description: z.string().max(500).nullish(), + item_description: z.string().max(5000).nullish(), + quantity: positiveNumberFromForm.optional(), + unit: z.string().max(20).nullish(), + unit_price: nonNegativeNumberFromForm.optional(), + vat_rate: numberInRange(0, 100).optional(), + position: z.number().int().nonnegative().optional(), +}); + +const ISSUED_ORDER_STATUSES = [ + "draft", + "sent", + "confirmed", + "completed", + "cancelled", +] as const; + +export const CreateIssuedOrderSchema = z.object({ + po_number: z.string().max(50).nullish(), + customer_id: nullableIntIdFromForm.nullish(), + status: z.enum(ISSUED_ORDER_STATUSES).optional(), + currency: z.string().max(10).optional(), + vat_rate: numberInRange(0, 100).optional(), + apply_vat: booleanFromForm.optional(), + exchange_rate: nonNegativeNumberFromForm.optional(), + order_date: isoDateString.nullish(), + delivery_date: isoDateString.nullish(), + language: z.string().max(5).optional(), + delivery_terms: z.string().max(500).nullish(), + payment_terms: z.string().max(500).nullish(), + issued_by: z.string().max(255).nullish(), + notes: z.string().nullish(), + internal_notes: z.string().nullish(), + items: z.array(IssuedOrderItemSchema).optional(), +}); + +export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial(); +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/schemas/issued-orders.schema.ts src/__tests__/issued-orders.test.ts +git commit -m "feat(orders): issued-order Zod schemas" +``` + +--- + +## Task 4: Service layer + +**Files:** + +- Create: `src/services/issued-orders.service.ts` +- Test: `src/__tests__/issued-orders.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/issued-orders.test.ts`: + +```typescript +import { + computeIssuedOrderTotals, + createIssuedOrder, + getIssuedOrder, + updateIssuedOrder, + deleteIssuedOrder, +} from "../services/issued-orders.service"; + +const createdIds: number[] = []; +const createdCustomerIds: number[] = []; + +afterEach(async () => { + for (const id of createdIds) + await prisma.issued_orders.deleteMany({ where: { id } }); + for (const id of createdCustomerIds) + await prisma.customers.deleteMany({ where: { id } }); + createdIds.length = 0; + createdCustomerIds.length = 0; +}); + +async function makeCustomer() { + const c = await prisma.customers.create({ + data: { name: "Dodavatel s.r.o." }, + }); + createdCustomerIds.push(c.id); + return c; +} + +describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => { + it("adds VAT on top of net, rounded per line", () => { + const t = computeIssuedOrderTotals( + [ + { quantity: 2, unit_price: 100, vat_rate: 21 }, + { quantity: 1, unit_price: 50, vat_rate: 12 }, + ], + true, + 21, + ); + expect(t.subtotal).toBe(250); + expect(t.vat_amount).toBe(48); + expect(t.total).toBe(298); + }); + + it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => { + const t = computeIssuedOrderTotals( + [{ quantity: 3, unit_price: 100, vat_rate: 21 }], + false, + 21, + ); + expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 }); + }); + + it("falls back to the document rate when a line rate is null", () => { + const t = computeIssuedOrderTotals( + [{ quantity: 1, unit_price: 100, vat_rate: null }], + true, + 15, + ); + expect(t.vat_amount).toBe(15); + }); +}); + +describe("createIssuedOrder", () => { + it("assigns a PO number, defaults to draft, stores items", async () => { + const c = await makeCustomer(); + const order = await createIssuedOrder({ + customer_id: c.id, + items: [ + { description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 }, + ], + }); + createdIds.push(order.id); + expect(order.po_number).toBeTruthy(); + expect(order.status).toBe("draft"); + const items = await prisma.issued_order_items.findMany({ + where: { issued_order_id: order.id }, + }); + expect(items.length).toBe(1); + expect(Number(items[0].unit_price)).toBe(100); + }); +}); + +describe("updateIssuedOrder status transitions", () => { + it("allows draft → sent and rejects draft → completed", async () => { + const order = await createIssuedOrder({}); + createdIds.push(order.id); + const ok = await updateIssuedOrder(order.id, { status: "sent" }); + expect("error" in ok).toBe(false); + const bad = await updateIssuedOrder(order.id, { status: "completed" }); + expect("error" in bad && bad.error).toBe("invalid_transition"); + }); + + it("locks items once confirmed", async () => { + const order = await createIssuedOrder({ + items: [{ description: "A", quantity: 1, unit_price: 10 }], + }); + createdIds.push(order.id); + await updateIssuedOrder(order.id, { status: "sent" }); + await updateIssuedOrder(order.id, { status: "confirmed" }); + await updateIssuedOrder(order.id, { + items: [{ description: "B", quantity: 9, unit_price: 99 }], + }); + const items = await prisma.issued_order_items.findMany({ + where: { issued_order_id: order.id }, + }); + expect(items.length).toBe(1); + expect(items[0].description).toBe("A"); + }); +}); + +describe("getIssuedOrder", () => { + it("returns customer_name and valid_transitions", async () => { + const c = await makeCustomer(); + const order = await createIssuedOrder({ customer_id: c.id }); + createdIds.push(order.id); + const detail = await getIssuedOrder(order.id); + expect(detail?.customer_name).toBe("Dodavatel s.r.o."); + expect(detail?.valid_transitions).toContain("sent"); + }); +}); + +describe("deleteIssuedOrder", () => { + it("deletes, cascades items, frees the latest number", async () => { + const order = await createIssuedOrder({ + items: [{ description: "X", quantity: 1, unit_price: 1 }], + }); + const before = order.po_number; + await deleteIssuedOrder(order.id); + expect( + await prisma.issued_orders.findUnique({ where: { id: order.id } }), + ).toBeNull(); + expect( + ( + await prisma.issued_order_items.findMany({ + where: { issued_order_id: order.id }, + }) + ).length, + ).toBe(0); + expect((await previewIssuedOrderNumber()).number).toBe(before); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: FAIL — module `../services/issued-orders.service` not found. + +- [ ] **Step 3: Create the service file** + +Create `src/services/issued-orders.service.ts`: + +```typescript +import prisma from "../config/database"; +import { + generateIssuedOrderNumber, + previewIssuedOrderNumber, + releaseIssuedOrderNumber, +} from "./numbering.service"; + +export interface IssuedOrderItemInput { + description?: string | null; + item_description?: string | null; + quantity?: number | string | null; + unit?: string | null; + unit_price?: number | string | null; + vat_rate?: number | string | null; + position?: number | null; +} + +export interface IssuedOrderInput { + po_number?: string | number | null; + customer_id?: number | string | null; + status?: string; + currency?: string; + vat_rate?: number | string | null; + apply_vat?: boolean | number | string; + exchange_rate?: number | string | null; + order_date?: string | null; + delivery_date?: string | null; + language?: string; + delivery_terms?: string | null; + payment_terms?: string | null; + issued_by?: string | null; + notes?: string | null; + internal_notes?: string | null; + items?: IssuedOrderItemInput[]; + [key: string]: unknown; +} + +interface ListIssuedOrdersParams { + page: number; + limit: number; + skip: number; + sort: string; + order: string; + search?: string; + status?: string; + customer_id?: number; +} + +const VALID_TRANSITIONS: Record = { + draft: ["sent", "cancelled"], + sent: ["confirmed", "cancelled"], + confirmed: ["completed", "cancelled"], + completed: [], + cancelled: [], +}; + +const ALLOWED_SORT_FIELDS = [ + "id", + "po_number", + "status", + "order_date", + "currency", +]; + +/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */ +export function computeIssuedOrderTotals( + items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, + applyVat: boolean | null, + defaultVatRate: unknown, +) { + let subtotal = 0; + let vat = 0; + for (const it of items) { + const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); + subtotal += base; + if (applyVat) { + const rate = + it.vat_rate != null && it.vat_rate !== "" + ? Number(it.vat_rate) + : defaultVatRate != null + ? Number(defaultVatRate) + : 21; + vat += Math.round(base * (rate / 100) * 100) / 100; + } + } + return { + subtotal: Math.round(subtotal * 100) / 100, + vat_amount: Math.round(vat * 100) / 100, + total: Math.round((subtotal + vat) * 100) / 100, + }; +} + +export async function listIssuedOrders(params: ListIssuedOrdersParams) { + const { page, limit, skip, sort, order, search, status, customer_id } = + params; + const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; + + const where: Record = {}; + if (status) where.status = status; + if (customer_id) where.customer_id = customer_id; + if (search) { + where.OR = [ + { po_number: { contains: search } }, + { customers: { name: { contains: search } } }, + { customers: { company_id: { contains: search } } }, + ]; + } + + // Tiebreak on id so same-day order_date rows sort deterministically. + const orderBy: Record[] = [ + { [sortField]: order }, + { id: order }, + ]; + + const [rows, total] = await Promise.all([ + prisma.issued_orders.findMany({ + where, + skip, + take: limit, + orderBy, + include: { + customers: { select: { id: true, name: true } }, + issued_order_items: true, + }, + }), + prisma.issued_orders.count({ where }), + ]); + + const enriched = rows.map((o) => { + const totals = computeIssuedOrderTotals( + o.issued_order_items, + o.apply_vat, + o.vat_rate, + ); + const { issued_order_items, ...rest } = o; + return { + ...rest, + items: issued_order_items, + customer_name: o.customers?.name || null, + ...totals, + }; + }); + + return { data: enriched, total, page, limit }; +} + +export async function getIssuedOrder(id: number) { + const order = await prisma.issued_orders.findUnique({ + where: { id }, + include: { + customers: true, + issued_order_items: { orderBy: { position: "asc" } }, + }, + }); + if (!order) return null; + const { issued_order_items, ...rest } = order; + return { + ...rest, + items: issued_order_items, + customer: order.customers, + customer_name: order.customers?.name || null, + valid_transitions: VALID_TRANSITIONS[order.status as string] || [], + }; +} + +export async function createIssuedOrder(body: IssuedOrderInput) { + return prisma.$transaction(async (tx) => { + const poNumber = + body.po_number !== undefined && + body.po_number !== null && + body.po_number !== "" + ? String(body.po_number) + : (await generateIssuedOrderNumber(undefined, tx)).number; + + const order = await tx.issued_orders.create({ + data: { + po_number: poNumber, + customer_id: body.customer_id ? Number(body.customer_id) : null, + status: (body.status ? String(body.status) : "draft") as never, + currency: body.currency ? String(body.currency) : "CZK", + vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, + apply_vat: body.apply_vat !== false, + exchange_rate: + body.exchange_rate != null ? Number(body.exchange_rate) : 1.0, + order_date: body.order_date + ? new Date(String(body.order_date)) + : new Date(), + delivery_date: body.delivery_date + ? new Date(String(body.delivery_date)) + : null, + language: body.language ? String(body.language) : "cs", + delivery_terms: body.delivery_terms + ? String(body.delivery_terms) + : null, + payment_terms: body.payment_terms ? String(body.payment_terms) : null, + issued_by: body.issued_by ? String(body.issued_by) : null, + notes: body.notes ? String(body.notes) : null, + internal_notes: body.internal_notes + ? String(body.internal_notes) + : null, + }, + }); + + if (Array.isArray(body.items)) { + await tx.issued_order_items.createMany({ + data: body.items.map((item, i) => ({ + issued_order_id: order.id, + description: item.description ?? null, + item_description: item.item_description ?? null, + quantity: item.quantity ?? 1, + unit: item.unit ?? null, + unit_price: item.unit_price ?? 0, + vat_rate: item.vat_rate ?? 21.0, + position: item.position ?? i, + })), + }); + } + + return order; + }); +} + +export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { + const existing = await prisma.issued_orders.findUnique({ where: { id } }); + if (!existing) return { error: "not_found" as const }; + + const currentStatus = existing.status as string; + + if (body.status !== undefined && body.status !== currentStatus) { + const newStatus = String(body.status); + const allowed = VALID_TRANSITIONS[currentStatus] || []; + if (!allowed.includes(newStatus)) { + return { error: "invalid_transition" as const, currentStatus, newStatus }; + } + } + + const editable = currentStatus === "draft" || currentStatus === "sent"; + const data: Record = { modified_at: new Date() }; + + if (editable) { + const strFields = [ + "currency", + "language", + "delivery_terms", + "payment_terms", + "issued_by", + ]; + for (const f of strFields) { + if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; + } + if (body.customer_id !== undefined) + data.customer_id = body.customer_id ? Number(body.customer_id) : null; + if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate); + if (body.apply_vat !== undefined) + data.apply_vat = + body.apply_vat === true || + body.apply_vat === 1 || + body.apply_vat === "1"; + if (body.exchange_rate !== undefined) + data.exchange_rate = + body.exchange_rate != null ? Number(body.exchange_rate) : null; + if (body.order_date !== undefined) + data.order_date = body.order_date + ? new Date(String(body.order_date)) + : null; + if (body.delivery_date !== undefined) + data.delivery_date = body.delivery_date + ? new Date(String(body.delivery_date)) + : null; + if (body.notes !== undefined) + data.notes = body.notes ? String(body.notes) : null; + if (body.internal_notes !== undefined) + data.internal_notes = body.internal_notes + ? String(body.internal_notes) + : null; + } + + if (body.status !== undefined) data.status = String(body.status); + + await prisma.issued_orders.update({ where: { id }, data }); + + if (editable && Array.isArray(body.items)) { + await prisma.$transaction(async (tx) => { + await tx.issued_order_items.deleteMany({ + where: { issued_order_id: id }, + }); + await tx.issued_order_items.createMany({ + data: body.items!.map((item, i) => ({ + issued_order_id: id, + description: item.description ?? null, + item_description: item.item_description ?? null, + quantity: item.quantity ?? 1, + unit: item.unit ?? null, + unit_price: item.unit_price ?? 0, + vat_rate: item.vat_rate ?? 21.0, + position: item.position ?? i, + })), + }); + }); + } + + return { id, po_number: existing.po_number }; +} + +export async function deleteIssuedOrder(id: number) { + const existing = await prisma.issued_orders.findUnique({ where: { id } }); + if (!existing) return null; + await prisma.issued_orders.delete({ where: { id } }); + await releaseIssuedOrderNumber( + new Date().getFullYear(), + existing.po_number ?? undefined, + ); + return existing; +} + +export async function getNextIssuedOrderNumberPreview() { + return previewIssuedOrderNumber(); +} +``` + +> Note on the `status: ... as never` cast: Prisma types `status` as the generated `issued_orders_status` enum; the runtime string is valid but TypeScript needs the cast since the value originates from untyped input. This matches how other services coerce enum-typed columns. + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `npx vitest run src/__tests__/issued-orders.test.ts` +Expected: PASS (all describe blocks). + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc -b --noEmit` +Expected: PASS. + +- [ ] **Step 6: Commit** + +``` +git add src/services/issued-orders.service.ts src/__tests__/issued-orders.test.ts +git commit -m "feat(orders): issued-orders service (CRUD + totals + transitions)" +``` + +--- + +## Task 5: HTTP routes + +**Files:** + +- Create: `src/routes/admin/issued-orders.ts` +- Modify: `src/server.ts` (register the plugin) + +- [ ] **Step 1: Create the routes file** + +Create `src/routes/admin/issued-orders.ts`: + +```typescript +import { FastifyInstance } from "fastify"; +import { requirePermission } from "../../middleware/auth"; +import { logAudit } from "../../services/audit"; +import { success, error, parseId, paginated } from "../../utils/response"; +import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; +import { parseBody } from "../../schemas/common"; +import { + CreateIssuedOrderSchema, + UpdateIssuedOrderSchema, +} from "../../schemas/issued-orders.schema"; +import { + listIssuedOrders, + getIssuedOrder, + createIssuedOrder, + updateIssuedOrder, + deleteIssuedOrder, + getNextIssuedOrderNumberPreview, +} from "../../services/issued-orders.service"; + +export default async function issuedOrdersRoutes(fastify: FastifyInstance) { + // GET /api/admin/issued-orders + fastify.get( + "/", + { preHandler: requirePermission("orders.view") }, + async (request, reply) => { + const query = request.query as Record; + const { page, limit, skip, order, search } = parsePagination(query); + const result = await listIssuedOrders({ + page, + limit, + skip, + sort: String(query.sort || ""), + order, + search, + status: query.status ? String(query.status) : undefined, + customer_id: query.customer_id ? Number(query.customer_id) : undefined, + }); + return paginated( + reply, + result.data, + buildPaginationMeta(result.total, page, limit), + ); + }, + ); + + // GET /api/admin/issued-orders/next-number + fastify.get( + "/next-number", + { preHandler: requirePermission("orders.create") }, + async (_request, reply) => { + return success(reply, await getNextIssuedOrderNumberPreview()); + }, + ); + + // GET /api/admin/issued-orders/:id + fastify.get<{ Params: { id: string } }>( + "/:id", + { preHandler: requirePermission("orders.view") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const order = await getIssuedOrder(id); + if (!order) return error(reply, "Objednávka nenalezena", 404); + return success(reply, order); + }, + ); + + // POST /api/admin/issued-orders + fastify.post( + "/", + { preHandler: requirePermission("orders.create") }, + async (request, reply) => { + const parsed = parseBody(CreateIssuedOrderSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const order = await createIssuedOrder(parsed.data); + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "issued_order", + entityId: order.id, + description: `Vytvořena vydaná objednávka ${order.po_number}`, + }); + return success( + reply, + { id: order.id, po_number: order.po_number }, + 201, + "Objednávka byla vytvořena", + ); + }, + ); + + // PUT /api/admin/issued-orders/:id + fastify.put<{ Params: { id: string } }>( + "/:id", + { preHandler: requirePermission("orders.edit") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const parsed = parseBody(UpdateIssuedOrderSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const result = await updateIssuedOrder(id, parsed.data); + if ("error" in result) { + if (result.error === "not_found") + return error(reply, "Objednávka nenalezena", 404); + if (result.error === "invalid_transition") + return error( + reply, + `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, + 400, + ); + return error(reply, "Neznámá chyba", 500); + } + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "issued_order", + entityId: id, + description: `Upravena vydaná objednávka ${result.po_number}`, + }); + return success(reply, { id }, 200, "Objednávka byla aktualizována"); + }, + ); + + // DELETE /api/admin/issued-orders/:id + fastify.delete<{ Params: { id: string } }>( + "/:id", + { preHandler: requirePermission("orders.delete") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const existing = await deleteIssuedOrder(id); + if (!existing) return error(reply, "Objednávka nenalezena", 404); + await logAudit({ + request, + authData: request.authData, + action: "delete", + entityType: "issued_order", + entityId: id, + description: `Smazána vydaná objednávka ${existing.po_number}`, + }); + return success(reply, null, 200, "Objednávka smazána"); + }, + ); +} +``` + +- [ ] **Step 2: Register the plugin in `src/server.ts`** + +Find the line that registers the invoices routes (`await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });`). Add the import next to the other route imports at the top: + +```typescript +import issuedOrdersRoutes from "./routes/admin/issued-orders"; +``` + +And add the registration right after the invoices registration: + +```typescript +await app.register(issuedOrdersRoutes, { prefix: "/api/admin/issued-orders" }); +``` + +- [ ] **Step 3: Typecheck + build** + +Run: `npx tsc -b --noEmit` then `npm run build` +Expected: both PASS. (The route compiles against the `entityType: "issued_order"` value — if `tsc` flags it as not assignable to `EntityType`, complete Task 6 first; you may do Task 6 before this step.) + +- [ ] **Step 4: Commit** + +``` +git add src/routes/admin/issued-orders.ts src/server.ts +git commit -m "feat(orders): issued-orders CRUD routes" +``` + +--- + +## Task 6: Audit entity type + label + +**Files:** + +- Modify: `src/types/index.ts` +- Modify: `src/admin/lib/entityTypeLabels.ts` + +- [ ] **Step 1: Extend the server `EntityType` union** + +In `src/types/index.ts`, add a member to the `EntityType` union (next to `"order"`): + +```typescript + | "issued_order" +``` + +- [ ] **Step 2: Add the Czech label** + +In `src/admin/lib/entityTypeLabels.ts`, add to the `ENTITY_TYPE_LABELS` map (next to `order`): + +```typescript + issued_order: "Objednávka vydaná", +``` + +- [ ] **Step 3: Typecheck** + +Run: `npx tsc -b --noEmit` +Expected: PASS (the routes' `entityType: "issued_order"` now type-checks). + +- [ ] **Step 4: Commit** + +``` +git add src/types/index.ts src/admin/lib/entityTypeLabels.ts +git commit -m "feat(orders): audit entity type for issued orders" +``` + +--- + +## Task 7: PDF export route + +**Files:** + +- Create: `src/routes/admin/issued-orders-pdf.ts` +- Modify: `src/server.ts` (register the plugin) +- Test: `src/__tests__/issued-orders.test.ts` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `src/__tests__/issued-orders.test.ts`: + +```typescript +import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf"; + +describe("renderIssuedOrderHtml", () => { + const order = { + po_number: "26720001", + order_date: new Date("2026-06-09T12:00:00"), + delivery_date: null, + currency: "CZK", + apply_vat: true, + vat_rate: 21, + notes: "

Pozn

", + delivery_terms: null, + payment_terms: null, + issued_by: null, + }; + const items = [ + { + description: "Materiál", + item_description: null, + quantity: 2, + unit: "ks", + unit_price: 100, + vat_rate: 21, + }, + ]; + + it("renders the PO number, items, and both party names", () => { + const html = renderIssuedOrderHtml( + order, + items, + { name: "Dodavatel" }, + { company_name: "Naše firma" }, + "cs", + ); + expect(html).toContain("26720001"); + expect(html).toContain("Materiál"); + expect(html).toContain("Dodavatel"); + expect(html).toContain("Naše firma"); + }); + + it("strips script tags from notes", () => { + const html = renderIssuedOrderHtml(order, items, null, null, "cs"); + expect(html).not.toContain("", + delivery_terms: null, + payment_terms: null, + issued_by: null, + }; + const items = [ + { + description: "Materiál", + item_description: null, + quantity: 2, + unit: "ks", + unit_price: 100, + vat_rate: 21, + }, + ]; + + it("renders the PO number, items, and both party names", () => { + const html = renderIssuedOrderHtml( + order, + items, + { name: "Dodavatel" }, + { company_name: "Naše firma" }, + "cs", + ); + expect(html).toContain("26720001"); + expect(html).toContain("Materiál"); + expect(html).toContain("Dodavatel"); + expect(html).toContain("Naše firma"); + }); + + it("strips script tags from notes", () => { + const html = renderIssuedOrderHtml(order, items, null, null, "cs"); + expect(html).not.toContain("