# 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("