From 15a2da38cc6430e2c09d2c07d4cb4fe01ebe1380 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 10:25:36 +0200 Subject: [PATCH] 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("