82 KiB
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 offmaster; the spec commit is the first commit on it). - All commits end with the trailer:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. - Tests live in
src/__tests__/issued-orders.test.tsand run against the throwawayapp_testDB vianpx vitest run. They import service functions directly and clean up inafterEach(the established pattern fromsrc/__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/<ts>_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 theordersmodel; add two columns tocompany_settings) -
Create:
prisma/migrations/<timestamp>_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:
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:
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:
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/<timestamp>_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: Confirm the migration needs NO manual SQL edits
Do not hand-edit the generated migration.sql. The two new company_settings
columns are nullable with no DB default — exactly like the existing
order_number_pattern / invoice_number_pattern / *_type_code columns. The
baseline values are supplied in code by the numbering service (Task 2:
settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN and
settings?.issued_order_type_code || "72"), so a NULL column resolves correctly
on dev, test, and prod with zero migration surgery.
Editing an already-applied migration would change its checksum and make the
next prisma migrate dev demand a reset — avoid that entirely. Just verify the
generated migration.sql looks sane (two CREATE TABLEs, the enum, and two
ADD COLUMNs) and move on.
- Step 6: Regenerate the Prisma client
npx prisma generate
- Step 7: Apply the migration to the
app_testDB so tests can run
In PowerShell, point DATABASE_URL at the app_test database (the value is in .env.test) and deploy:
$env:DATABASE_URL = "<the DATABASE_URL line from .env.test>"
npx prisma migrate deploy
Remove-Item Env:DATABASE_URL
Expected: "All migrations have been applied" (including <timestamp>_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:
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):
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():
issued_order_number_pattern: true,
issued_order_type_code: true,
- Step 5: Teach
releaseSequenceabout 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):
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:
/** Verify an issued-order (PO) number is not already used. */
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
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, andTxClientalready 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:
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:
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:
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:
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<string, string[]> = {
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<string, unknown> = {};
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<string, string>[] = [
{ [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<string, unknown> = { 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 nevercast: Prisma typesstatusas the generatedissued_orders_statusenum; 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:
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<string, unknown>;
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:
import issuedOrdersRoutes from "./routes/admin/issued-orders";
And add the registration right after the invoices registration:
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
EntityTypeunion
In src/types/index.ts, add a member to the EntityType union (next to "order"):
| "issued_order"
- Step 2: Add the Czech label
In src/admin/lib/entityTypeLabels.ts, add to the ENTITY_TYPE_LABELS map (next to order):
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:
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: "<p>Pozn</p><script>alert(1)</script>",
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("<script>");
expect(html).not.toContain("alert(1)");
});
});
- Step 2: Run it to confirm it fails
Run: npx vitest run src/__tests__/issued-orders.test.ts
Expected: FAIL — module ../routes/admin/issued-orders-pdf not found.
- Step 3: Create the PDF route file
Create src/routes/admin/issued-orders-pdf.ts:
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { parseId } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function formatNum(n: number, decimals = 2): string {
return new Intl.NumberFormat("cs-CZ", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n);
}
// Copied from invoices-pdf.ts: strips scripts/handlers/inline styles from Quill HTML.
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
s = s.replace(/( )/g, " ");
return s;
}
type Lang = "cs" | "en";
const T = {
cs: {
title: "Objednávka",
supplier: "Dodavatel",
recipient: "Odběratel",
desc: "Popis",
qty: "Množství",
unitPrice: "Cena/MJ",
base: "Bez DPH",
vat: "DPH",
lineTotal: "Celkem",
subtotal: "Základ",
vatTotal: "DPH celkem",
total: "Celkem",
orderDate: "Datum vystavení",
deliveryDate: "Požadované dodání",
notes: "Poznámka",
deliveryTerms: "Dodací podmínky",
paymentTerms: "Platební podmínky",
issuedBy: "Vystavil",
},
en: {
title: "Purchase Order",
supplier: "Supplier",
recipient: "Buyer",
desc: "Description",
qty: "Quantity",
unitPrice: "Unit price",
base: "Net",
vat: "VAT",
lineTotal: "Total",
subtotal: "Subtotal",
vatTotal: "VAT total",
total: "Total",
orderDate: "Order date",
deliveryDate: "Requested delivery",
notes: "Note",
deliveryTerms: "Delivery terms",
paymentTerms: "Payment terms",
issuedBy: "Issued by",
},
} as const;
interface IssuedOrderPdfData {
po_number: string | null;
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
apply_vat: boolean | null;
vat_rate: unknown;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
}
interface IssuedOrderPdfItem {
description: string | null;
item_description: string | null;
quantity: unknown;
unit: string | null;
unit_price: unknown;
vat_rate: unknown;
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
): string {
const t = T[lang];
const applyVat = order.apply_vat !== false;
const currency = escapeHtml(order.currency || "CZK");
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
let subtotal = 0;
let vatTotal = 0;
const rows = items
.map((it) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const base = qty * unitPrice;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: docRate;
const lineVat = applyVat
? Math.round(base * (rate / 100) * 100) / 100
: 0;
subtotal += base;
vatTotal += lineVat;
return `<tr>
<td>${escapeHtml(it.description)}${it.item_description ? `<div class="sub">${escapeHtml(it.item_description)}</div>` : ""}</td>
<td class="r">${formatNum(qty, Number.isInteger(qty) ? 0 : 2)}${it.unit ? " " + escapeHtml(it.unit) : ""}</td>
<td class="r">${formatNum(unitPrice)}</td>
<td class="r">${formatNum(base)}</td>
<td class="c">${applyVat ? Math.round(rate) + "%" : "0%"}</td>
<td class="r">${formatNum(lineVat)}</td>
<td class="r">${formatNum(base + lineVat)}</td>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 100) / 100;
vatTotal = Math.round(vatTotal * 100) / 100;
const total = Math.round((subtotal + vatTotal) * 100) / 100;
const company = (k: string) => escapeHtml(settings?.[k] ?? "");
const cust = (k: string) => escapeHtml(customer?.[k] ?? "");
const notesRaw = order.notes ?? "";
const notesHtml = notesRaw.replace(/<[^>]*>/g, "").trim()
? `<div class="notes"><div class="label">${escapeHtml(t.notes)}</div><div>${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div></div>`
: "";
return `<!DOCTYPE html><html lang="${lang}"><head><meta charset="utf-8"/>
<title>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</title>
<style>
@page { size: A4; margin: 14mm; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #222; }
h1 { font-size: 20px; margin: 0 0 4px; }
.meta { color: #666; margin-bottom: 12px; }
.parties { display: flex; justify-content: space-between; margin: 14px 0; }
.party { width: 48%; }
.label { font-weight: bold; text-transform: uppercase; font-size: 9px; color: #888; }
table.items { width: 100%; border-collapse: collapse; margin-top: 10px; }
table.items th, table.items td { border-bottom: 1px solid #ddd; padding: 5px 6px; text-align: left; }
table.items th { background: #f4f4f4; font-size: 9px; text-transform: uppercase; }
.r { text-align: right; } .c { text-align: center; }
.sub { color: #888; font-size: 9px; }
.totals { margin-top: 12px; width: 42%; margin-left: auto; border-collapse: collapse; }
.totals td { padding: 3px 6px; }
.totals .grand { font-weight: bold; border-top: 2px solid #333; font-size: 13px; }
.notes { margin-top: 16px; }
.terms { margin-top: 8px; color: #555; }
</style></head><body>
<h1>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</h1>
<div class="meta">${escapeHtml(t.orderDate)}: ${order.order_date ? localDateCzStr(order.order_date) : "—"}${order.delivery_date ? ` · ${escapeHtml(t.deliveryDate)}: ${localDateCzStr(order.delivery_date)}` : ""}</div>
<div class="parties">
<div class="party">
<div class="label">${escapeHtml(t.supplier)}</div>
<div><strong>${cust("name")}</strong></div>
<div>${cust("street")}</div>
<div>${cust("postal_code")} ${cust("city")}</div>
${cust("company_id") ? `<div>IČO: ${cust("company_id")}</div>` : ""}
${cust("vat_id") ? `<div>DIČ: ${cust("vat_id")}</div>` : ""}
</div>
<div class="party">
<div class="label">${escapeHtml(t.recipient)}</div>
<div><strong>${company("company_name")}</strong></div>
<div>${company("street")}</div>
<div>${company("postal_code")} ${company("city")}</div>
${company("company_id") ? `<div>IČO: ${company("company_id")}</div>` : ""}
${company("vat_id") ? `<div>DIČ: ${company("vat_id")}</div>` : ""}
</div>
</div>
<table class="items">
<thead><tr>
<th>${escapeHtml(t.desc)}</th>
<th class="r">${escapeHtml(t.qty)}</th>
<th class="r">${escapeHtml(t.unitPrice)}</th>
<th class="r">${escapeHtml(t.base)}</th>
<th class="c">${escapeHtml(t.vat)}</th>
<th class="r">${escapeHtml(t.vat)}</th>
<th class="r">${escapeHtml(t.lineTotal)}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<table class="totals">
<tr><td>${escapeHtml(t.subtotal)}</td><td class="r">${formatNum(subtotal)} ${currency}</td></tr>
<tr><td>${escapeHtml(t.vatTotal)}</td><td class="r">${formatNum(vatTotal)} ${currency}</td></tr>
<tr class="grand"><td>${escapeHtml(t.total)}</td><td class="r">${formatNum(total)} ${currency}</td></tr>
</table>
${notesHtml}
${order.delivery_terms ? `<div class="terms"><span class="label">${escapeHtml(t.deliveryTerms)}:</span> ${escapeHtml(order.delivery_terms)}</div>` : ""}
${order.payment_terms ? `<div class="terms"><span class="label">${escapeHtml(t.paymentTerms)}:</span> ${escapeHtml(order.payment_terms)}</div>` : ""}
${order.issued_by ? `<div class="terms"><span class="label">${escapeHtml(t.issuedBy)}:</span> ${escapeHtml(order.issued_by)}</div>` : ""}
</body></html>`;
}
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.export") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
const lang: Lang = query.lang === "en" ? "en" : "cs";
try {
const order = await prisma.issued_orders.findUnique({ where: { id } });
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const customer = order.customer_id
? ((await prisma.customers.findUnique({
where: { id: order.customer_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
const html = renderIssuedOrderHtml(
order,
items,
customer,
settings,
lang,
);
const pdf = await htmlToPdf(html);
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
return reply
.type("application/pdf")
.header("Content-Disposition", `attachment; filename="${filename}"`)
.send(pdf);
} catch (err) {
request.log.error(err, "Issued order PDF generation failed");
return reply
.status(500)
.type("text/html")
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
}
},
);
}
- Step 4: Register the PDF plugin in
src/server.ts
Add the import with the other route imports:
import issuedOrdersPdfRoutes from "./routes/admin/issued-orders-pdf";
And register it after the issued-orders CRUD registration:
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
- Step 5: Run the test to confirm it passes
Run: npx vitest run src/__tests__/issued-orders.test.ts
Expected: PASS (the two renderIssuedOrderHtml cases included).
- Step 6: Typecheck + build
Run: npx tsc -b --noEmit then npm run build
Expected: both PASS.
- Step 7: Commit
git add src/routes/admin/issued-orders-pdf.ts src/server.ts src/__tests__/issued-orders.test.ts
git commit -m "feat(orders): issued-order PDF export route"
Task 8: Frontend — React Query options + types
Files:
- Create:
src/admin/lib/queries/issued-orders.ts
Frontend tasks have no component tests in this project (server-side tests only). Verify each with
npx tsc -b --noEmit,npm run build,npm run lint, plus the manual smoke test noted at the end.
- Step 1: Create the query options file
Create src/admin/lib/queries/issued-orders.ts. First open src/admin/lib/queries/invoices.ts and copy its exact import line(s) for queryOptions, jsonQuery, and paginatedJsonQuery (paths differ per project layout — match them verbatim). Then add:
// import { queryOptions } from "@tanstack/react-query";
// import { jsonQuery, paginatedJsonQuery } from "<same path as invoices.ts>";
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
subtotal: number;
vat_amount: number;
total: number;
}
export interface IssuedOrderItem {
id?: number;
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;
}
export interface IssuedOrderDetail extends IssuedOrder {
apply_vat: boolean | null;
vat_rate: number | string | null;
exchange_rate: number | string | null;
delivery_date: string | null;
language: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
valid_transitions: string[];
}
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["issued-orders", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<IssuedOrder>(
`/api/admin/issued-orders${qs ? `?${qs}` : ""}`,
);
},
});
export const issuedOrderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["issued-orders", id],
queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!id,
});
- Step 2: Typecheck
Run: npx tsc -b --noEmit
Expected: PASS.
- Step 3: Commit
git add src/admin/lib/queries/issued-orders.ts
git commit -m "feat(orders): issued-order query options + types"
Task 9: Frontend — Orders page tabs + Vydané list
Files:
-
Create:
src/admin/pages/OrdersReceived.tsx(the current Orders page, moved verbatim) -
Rewrite:
src/admin/pages/Orders.tsx(tab shell) -
Create:
src/admin/pages/IssuedOrders.tsx(the Vydané list) -
Step 1: Move the current Orders page verbatim into
OrdersReceived.tsx
Copy the entire current contents of src/admin/pages/Orders.tsx into a new file src/admin/pages/OrdersReceived.tsx. Change only the component function name and the default export from Orders (or whatever it is named) to OrdersReceived. Change nothing else — preserve every hook, query, mutation, invalidate array, permission check, and JSX exactly.
- Step 2: Rewrite
Orders.tsxas the tab shell
Replace the entire contents of src/admin/pages/Orders.tsx with the tab shell below. Open src/admin/pages/Invoices.tsx to confirm the exact import path + props of the Tabs component and useSearchParams, and match them (the snippet uses the same Tabs component Invoices.tsx uses):
import { lazy, Suspense } from "react";
import { Box } from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Tabs } from "../ui"; // ← match the import path used by Invoices.tsx
import { LoadingState } from "../ui"; // ← match the import used by Invoices.tsx
import OrdersReceived from "./OrdersReceived";
const IssuedOrders = lazy(() => import("./IssuedOrders"));
export default function Orders() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "vydane" ? "vydane" : "prijate";
const setActiveTab = (tab: string) =>
setSearchParams({ tab }, { replace: true });
return (
<>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v)}
tabs={[
{ value: "prijate", label: "Přijaté" },
{ value: "vydane", label: "Vydané" },
]}
/>
</Box>
{activeTab === "vydane" ? (
<Suspense fallback={<LoadingState />}>
<IssuedOrders />
</Suspense>
) : (
<OrdersReceived />
)}
</>
);
}
If
Tabs/LoadingStateare imported from specific files (not a barrel) in Invoices.tsx, use those exact paths.
- Step 3: Create the Vydané list
IssuedOrders.tsx
Create src/admin/pages/IssuedOrders.tsx. Match the kit-component imports (PageHeader, Button, DataTable, FilterBar, Select, StatusChip, ConfirmDialog, PageEnter, EmptyState) to how src/admin/pages/Invoices.tsx / the existing OrdersReceived.tsx import them. The status-label map and columns are below:
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useApiMutation } from "../hooks/useApiMutation"; // ← match path used by InvoiceDetail.tsx
import {
PageEnter,
PageHeader,
Button,
DataTable,
FilterBar,
Select,
StatusChip,
ConfirmDialog,
EmptyState,
} from "../ui"; // ← match the import paths used elsewhere
import {
issuedOrderListOptions,
type IssuedOrder,
} from "../lib/queries/issued-orders";
const STATUS_LABELS: Record<string, string> = {
draft: "Koncept",
sent: "Odeslaná",
confirmed: "Potvrzená",
completed: "Dokončená",
cancelled: "Stornovaná",
};
const STATUS_COLORS: Record<string, string> = {
draft: "default",
sent: "info",
confirmed: "warning",
completed: "success",
cancelled: "error",
};
const API_BASE = "/api/admin";
export default function IssuedOrders() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canCreate = hasPermission("orders.create");
const canEdit = hasPermission("orders.edit");
const canDelete = hasPermission("orders.delete");
const canExport = hasPermission("orders.export");
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [toDelete, setToDelete] = useState<IssuedOrder | null>(null);
const listQuery = useQuery(
issuedOrderListOptions({ search, status, page, perPage: 20 }),
);
const deleteMutation = useApiMutation<unknown, unknown>({
url: (o: IssuedOrder) => `${API_BASE}/issued-orders/${o.id}`,
method: () => "DELETE",
invalidate: ["issued-orders"],
onSuccess: () => setToDelete(null),
});
const rows = listQuery.data?.data ?? [];
const columns = useMemo(
() => [
{ key: "po_number", label: "Číslo", sortable: true },
{ key: "customer_name", label: "Dodavatel" },
{
key: "status",
label: "Stav",
render: (o: IssuedOrder) => (
<StatusChip
label={STATUS_LABELS[o.status] ?? o.status}
color={STATUS_COLORS[o.status] ?? "default"}
/>
),
},
{ key: "order_date", label: "Datum", sortable: true },
{
key: "total",
label: "Celkem",
align: "right" as const,
render: (o: IssuedOrder) =>
`${o.total.toLocaleString("cs-CZ")} ${o.currency ?? "CZK"}`,
},
],
[],
);
return (
<PageEnter>
<PageHeader
title="Objednávky vydané"
actions={
canCreate ? (
<Button onClick={() => navigate("/orders/issued/new")}>
Vytvořit objednávku vydanou
</Button>
) : null
}
/>
<FilterBar>
<input
placeholder="Hledat…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<Select
value={status}
onChange={(v) => {
setStatus(v);
setPage(1);
}}
options={[
{ value: "", label: "Všechny stavy" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({
value,
label,
})),
]}
/>
</FilterBar>
{rows.length === 0 && !listQuery.isLoading ? (
<EmptyState text="Zatím žádné vydané objednávky. Vytvořte první tlačítkem „Vytvořit objednávku vydanou“." />
) : (
<DataTable
columns={columns}
rows={rows}
loading={listQuery.isLoading}
onRowClick={(o: IssuedOrder) => navigate(`/orders/issued/${o.id}`)}
rowActions={(o: IssuedOrder) =>
[
canExport && {
label: "PDF",
onClick: () =>
window.open(
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
"_blank",
),
},
canEdit && {
label: "Upravit",
onClick: () => navigate(`/orders/issued/${o.id}`),
},
canDelete && {
label: "Smazat",
danger: true,
onClick: () => setToDelete(o),
},
].filter(Boolean)
}
/>
)}
<ConfirmDialog
open={!!toDelete}
title="Smazat objednávku?"
onCancel={() => setToDelete(null)}
onConfirm={() => toDelete && deleteMutation.mutate(toDelete)}
>
{toDelete ? `Opravdu smazat objednávku ${toDelete.po_number}?` : ""}
</ConfirmDialog>
</PageEnter>
);
}
The exact prop names of
DataTable(columns/rows/rowActions/onRowClick),Select,StatusChip,FilterBar, anduseAuth().hasPermissionmust match the existing kit — openOrdersReceived.tsx(which you just created from the current Orders page) and reuse its exact patterns for the table, filter bar, status chip, and permission checks. Adapt the snippet's prop names if they differ.
- Step 4: Typecheck + lint + build
Run: npx tsc -b --noEmit then npm run lint then npm run build
Expected: all PASS, 0 lint errors. (All hooks are above any early return — keep it that way.)
- Step 5: Commit
git add src/admin/pages/OrdersReceived.tsx src/admin/pages/Orders.tsx src/admin/pages/IssuedOrders.tsx
git commit -m "feat(orders): Orders page Přijaté|Vydané tabs + issued list"
Task 10: Frontend — IssuedOrderDetail create/edit form
Files:
-
Create:
src/admin/pages/IssuedOrderDetail.tsx -
Modify:
src/admin/AdminApp.tsx(lazy routes) -
Step 1: Add the lazy routes in
AdminApp.tsx
Add the lazy import next to the other page imports:
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
Add two routes next to the existing orders route (which already renders <Orders />):
<Route path="orders/issued/new" element={<IssuedOrderDetail />} />
<Route path="orders/issued/:id" element={<IssuedOrderDetail />} />
Ensure these are registered before any
orders/:idcatch-all if one exists, so/orders/issued/newis not captured as an order id.
- Step 2: Create the detail/create form
IssuedOrderDetail.tsx
Create src/admin/pages/IssuedOrderDetail.tsx. Match all kit-component imports and the apiFetch/jsonQuery/useApiMutation paths to how src/admin/pages/InvoiceDetail.tsx imports them. The component:
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useApiMutation } from "../hooks/useApiMutation"; // ← match InvoiceDetail.tsx
import { apiFetch } from "../utils/api"; // ← match InvoiceDetail.tsx
import { jsonQuery } from "../lib/queries/_helpers"; // ← match InvoiceDetail.tsx import of jsonQuery
import {
PageEnter,
PageHeader,
Button,
TextField,
DateField,
Select,
CheckboxField,
RichEditor,
Card,
} from "../ui"; // ← match existing import paths
import {
issuedOrderDetailOptions,
type IssuedOrderDetail as IssuedOrderDetailType,
type IssuedOrderItem,
} from "../lib/queries/issued-orders";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
draft: "Koncept",
sent: "Odeslaná",
confirmed: "Potvrzená",
completed: "Dokončená",
cancelled: "Stornovaná",
};
interface CustomerOption {
id: number;
name: string;
}
function emptyItem(): IssuedOrderItem & { _key: string } {
return {
_key: Math.random().toString(36).slice(2),
description: "",
item_description: "",
quantity: 1,
unit: "",
unit_price: 0,
vat_rate: 21,
};
}
function todayStr(): string {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
export default function IssuedOrderDetail() {
const { id } = useParams();
const isEdit = !!id;
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canExport = hasPermission("orders.export");
const detailQuery = useQuery(issuedOrderDetailOptions(id));
const customersQuery = useQuery({
queryKey: ["customers", "options"],
queryFn: () =>
jsonQuery<CustomerOption[]>(`${API_BASE}/customers?per_page=1000`),
});
const nextNumberQuery = useQuery({
queryKey: ["issued-orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
`${API_BASE}/issued-orders/next-number`,
).then((d) => d?.next_number || d?.number || ""),
enabled: !isEdit,
});
const loaded = detailQuery.data;
const [form, setForm] = useState({
customer_id: "",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: todayStr(),
delivery_date: "",
language: "cs",
delivery_terms: "",
payment_terms: "",
issued_by: "",
notes: "",
internal_notes: "",
status: "draft",
});
const [items, setItems] = useState<Array<IssuedOrderItem & { _key: string }>>(
[emptyItem()],
);
const [hydrated, setHydrated] = useState(false);
// Hydrate the form once from the loaded order (edit mode).
if (isEdit && loaded && !hydrated) {
setForm({
customer_id: loaded.customer_id ? String(loaded.customer_id) : "",
currency: loaded.currency ?? "CZK",
vat_rate: Number(loaded.vat_rate ?? 21),
apply_vat: loaded.apply_vat !== false,
order_date: (loaded.order_date ?? "").slice(0, 10),
delivery_date: (loaded.delivery_date ?? "").slice(0, 10),
language: loaded.language ?? "cs",
delivery_terms: loaded.delivery_terms ?? "",
payment_terms: loaded.payment_terms ?? "",
issued_by: loaded.issued_by ?? "",
notes: loaded.notes ?? "",
internal_notes: loaded.internal_notes ?? "",
status: loaded.status,
});
setItems(
(loaded.items.length ? loaded.items : [emptyItem()]).map((it) => ({
...it,
_key: Math.random().toString(36).slice(2),
})),
);
setHydrated(true);
}
const editable = !isEdit || form.status === "draft" || form.status === "sent";
const totals = useMemo(() => {
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 (form.apply_vat) {
const rate = Number(it.vat_rate) || 0;
vat += Math.round(base * (rate / 100) * 100) / 100;
}
}
return {
subtotal: Math.round(subtotal * 100) / 100,
vat: Math.round(vat * 100) / 100,
total: Math.round((subtotal + vat) * 100) / 100,
};
}, [items, form.apply_vat]);
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
url: () =>
isEdit ? `${API_BASE}/issued-orders/${id}` : `${API_BASE}/issued-orders`,
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["issued-orders"],
onSuccess: () => navigate("/orders?tab=vydane"),
});
const addItem = () => setItems((prev) => [...prev, emptyItem()]);
const removeItem = (index: number) => {
if (items.length <= 1) return;
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof IssuedOrderItem,
value: unknown,
) =>
setItems((prev) =>
prev.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
);
const submit = () => {
saveMutation.mutate({
...form,
customer_id: form.customer_id || null,
delivery_date: form.delivery_date || null,
items: items.map((it, i) => ({
description: it.description,
item_description: it.item_description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
vat_rate: it.vat_rate,
position: i,
})),
});
};
const transition = (status: string) => {
if (!isEdit) return;
saveMutation.mutate({ status });
};
const customers = customersQuery.data ?? [];
const headingNumber = isEdit
? (loaded?.po_number ?? "")
: (nextNumberQuery.data ?? "");
return (
<PageEnter>
<PageHeader
title={`Objednávka vydaná ${headingNumber}`}
actions={
isEdit && canExport ? (
<Button
variant="secondary"
onClick={() =>
window.open(
`${API_BASE}/issued-orders-pdf/${id}?lang=${form.language}`,
"_blank",
)
}
>
Export PDF
</Button>
) : null
}
/>
<Card>
<Select
label="Dodavatel"
value={form.customer_id}
onChange={(v) => setForm((f) => ({ ...f, customer_id: v }))}
disabled={!editable}
options={[
{ value: "", label: "— vyberte —" },
...customers.map((c) => ({ value: String(c.id), label: c.name })),
]}
/>
<DateField
label="Datum vystavení"
value={form.order_date}
onChange={(v) => setForm((f) => ({ ...f, order_date: v }))}
disabled={!editable}
/>
<DateField
label="Požadované dodání"
value={form.delivery_date}
onChange={(v) => setForm((f) => ({ ...f, delivery_date: v }))}
disabled={!editable}
/>
<TextField
label="Měna"
value={form.currency}
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
disabled={!editable}
/>
<TextField
label="Sazba DPH %"
type="number"
value={String(form.vat_rate)}
onChange={(e) =>
setForm((f) => ({ ...f, vat_rate: Number(e.target.value) }))
}
disabled={!editable}
/>
<CheckboxField
label="Uplatnit DPH"
checked={form.apply_vat}
onChange={(v) => setForm((f) => ({ ...f, apply_vat: v }))}
disabled={!editable}
/>
</Card>
<Card>
{items.map((it, i) => (
<div key={it._key} style={{ display: "flex", gap: 8 }}>
<TextField
placeholder="Popis položky…"
value={it.description ?? ""}
onChange={(e) => updateItem(i, "description", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="Množství"
type="number"
value={String(it.quantity ?? "")}
onChange={(e) => updateItem(i, "quantity", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="MJ"
value={it.unit ?? ""}
onChange={(e) => updateItem(i, "unit", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="Cena/MJ (bez DPH)"
type="number"
value={String(it.unit_price ?? "")}
onChange={(e) => updateItem(i, "unit_price", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="DPH %"
type="number"
value={String(it.vat_rate ?? "")}
onChange={(e) => updateItem(i, "vat_rate", e.target.value)}
disabled={!editable}
/>
{editable && (
<Button variant="ghost" onClick={() => removeItem(i)}>
×
</Button>
)}
</div>
))}
{editable && (
<Button variant="secondary" onClick={addItem}>
Přidat položku
</Button>
)}
<div style={{ textAlign: "right", marginTop: 8 }}>
Základ: {totals.subtotal.toLocaleString("cs-CZ")} {form.currency} ·
DPH: {totals.vat.toLocaleString("cs-CZ")} {form.currency} ·{" "}
<strong>
Celkem: {totals.total.toLocaleString("cs-CZ")} {form.currency}
</strong>
</div>
</Card>
<Card>
<RichEditor
value={form.notes}
onChange={(v) => setForm((f) => ({ ...f, notes: v }))}
readOnly={!editable}
/>
<TextField
label="Interní poznámka"
value={form.internal_notes}
onChange={(e) =>
setForm((f) => ({ ...f, internal_notes: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Dodací podmínky"
value={form.delivery_terms}
onChange={(e) =>
setForm((f) => ({ ...f, delivery_terms: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Platební podmínky"
value={form.payment_terms}
onChange={(e) =>
setForm((f) => ({ ...f, payment_terms: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Vystavil"
value={form.issued_by}
onChange={(e) =>
setForm((f) => ({ ...f, issued_by: e.target.value }))
}
disabled={!editable}
/>
</Card>
<div style={{ display: "flex", gap: 8 }}>
{editable && (
<Button onClick={submit} disabled={saveMutation.isPending}>
Uložit
</Button>
)}
{isEdit &&
(loaded?.valid_transitions ?? []).map((s) => (
<Button key={s} variant="secondary" onClick={() => transition(s)}>
{STATUS_LABELS[s] ?? s}
</Button>
))}
<Button variant="ghost" onClick={() => navigate("/orders?tab=vydane")}>
Zpět
</Button>
</div>
</PageEnter>
);
}
The "hydrate once" pattern (
if (isEdit && loaded && !hydrated) { … }) sets state during render guarded by a flag — acceptable and lint-clean. IfOrdersReceived.tsx/InvoiceDetail.tsxuse a different hydration idiom (e.g. akeyprop oruseEffect), prefer matching that. AlluseState/useQuery/useMemocalls stay above any early return.
Prop-name reality check:
DateField,Select,CheckboxField,RichEditor,Button(variant), anduseApiMutation(url/method/invalidate/onSuccess) must match the existing kit. OpenInvoiceDetail.tsxand adjust prop names where they differ — do not invent props.
- Step 3: Typecheck + lint + build
Run: npx tsc -b --noEmit then npm run lint then npm run build
Expected: all PASS, 0 lint errors.
- Step 4: Commit
git add src/admin/pages/IssuedOrderDetail.tsx src/admin/AdminApp.tsx
git commit -m "feat(orders): issued-order create/edit form + PDF export button"
Task 11: Full verification & manual smoke test
Files: none (verification only)
- Step 1: Run every quality gate
npx tsc -b --noEmit
npm run build
npx vitest run
npm run lint
Expected: typecheck PASS; build PASS; all tests pass (the existing suite + the new issued-orders.test.ts); lint 0 errors.
- Step 2: Manual smoke test (ask the user to exercise the running app)
Since this project's dev server is user-managed and there are no component tests, ask the user to verify in the browser:
- Open Objednávky → the Přijaté tab shows the existing orders unchanged; the Vydané tab loads.
- Vytvořit objednávku vydanou → pick a supplier (customer), add a couple of line items, save → it appears in the Vydané list with the right total.
- Open it → Export PDF downloads a PDF showing your company as recipient, the supplier block, the items, and NET+VAT totals.
- Move it
draft → sent → confirmed; confirm the line items lock afterconfirmed. - Delete a draft → it disappears and its number is freed.
- Step 3: Final commit (if any cleanup) and finish the branch
If the smoke test surfaced fixes, commit them. Then use superpowers:finishing-a-development-branch to wrap up feat/issued-orders (review the full diff, confirm the gates, and merge/PR per the user's preference). Do not push or merge without the user's go-ahead.
Self-review notes (for the author — checked against the spec)
- Spec coverage: data model (T1), numbering (T2), schemas (T3), service + totals + transitions (T4), routes + permissions (T5), audit entity type (T6), PDF (T7), queries (T8), tabs + list (T9), detail form + routes (T10), tests throughout, migration (T1), gates (T11). Future-phase items (warehouse/received-invoice links, NAS PDF persistence, dedicated
purchase-orders.*perms) are intentionally not tasked. - Permissions: reuse
orders.view/create/edit/delete/export(verified to exist inseed.ts) — no permission migration, matching the spec. - VAT regime: NET + VAT-on-top in
computeIssuedOrderTotals, the PDF, and the form's live totals — consistent across all three, and deliberately opposite toreceived_invoices(gross). - Naming consistency:
issued_orders/issued_order_items/po_number/issued_orders_status/generateIssuedOrderNumber/issuedOrderListOptions/entityType: "issued_order"are used identically in every task.