From 6a22195c7d0548133d160bd34006e8f65f68aca4 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 11:29:06 +0200 Subject: [PATCH] feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer Issued orders are purchase orders WE send - they must pick from suppliers (sklad_suppliers), not from customers. Per user decision customer_id was REPLACED (not kept alongside): migration drops issued_orders.customer_id and adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty - the feature is days old; re-point them in the UI). - service: input/filters/search (suppliers.name + ico)/enrichment/detail all supplier-based; create validates the supplier inside the transaction and update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500; detail returns a minimal supplier field set (no internal notes leak) - routes: supplier_id on list + stats; new GET /issued-orders/suppliers lookup (orders.view/create/edit guard - orders users lack warehouse.manage which guards the warehouse suppliers CRUD), active suppliers only, name+id ordering - PDF: Dodavatel block now renders the supplier (name, newline-split address, IC/DIC), layout and both language label sets unchanged - frontend: new SupplierPicker kit component (CustomerPicker untouched), IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the picker keeps a fallback option for orders whose supplier was later deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so the picker can't go stale - tests: issued-orders suite switched to supplier fixtures + new coverage (lookup shape + 403, nonexistent supplier 400, PDF supplier block) Co-Authored-By: Claude Fable 5 --- .../migration.sql | 16 + prisma/schema.prisma | 10 +- .../drafts-deferred-numbering.test.ts | 10 + src/__tests__/issued-orders.test.ts | 276 +++++++++++++++--- src/__tests__/order-totals.test.ts | 7 + src/admin/lib/queries/issued-orders.ts | 26 +- src/admin/pages/IssuedOrderDetail.tsx | 85 ++++-- src/admin/pages/IssuedOrders.tsx | 4 +- src/admin/pages/WarehouseSuppliers.tsx | 12 +- src/admin/ui/SupplierPicker.tsx | 88 ++++++ src/admin/ui/index.ts | 1 + src/routes/admin/issued-orders-pdf.ts | 51 +++- src/routes/admin/issued-orders.ts | 48 ++- src/schemas/issued-orders.schema.ts | 2 +- src/services/issued-orders.service.ts | 74 +++-- 15 files changed, 598 insertions(+), 112 deletions(-) create mode 100644 prisma/migrations/20260610104514_issued_orders_customer_to_supplier/migration.sql create mode 100644 src/admin/ui/SupplierPicker.tsx diff --git a/prisma/migrations/20260610104514_issued_orders_customer_to_supplier/migration.sql b/prisma/migrations/20260610104514_issued_orders_customer_to_supplier/migration.sql new file mode 100644 index 0000000..b996497 --- /dev/null +++ b/prisma/migrations/20260610104514_issued_orders_customer_to_supplier/migration.sql @@ -0,0 +1,16 @@ +-- DropForeignKey +ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`; + +-- DropIndex +DROP INDEX `issued_orders_customer_id` ON `issued_orders`; + +-- AlterTable +ALTER TABLE `issued_orders` DROP COLUMN `customer_id`, + ADD COLUMN `supplier_id` INTEGER NULL; + +-- CreateIndex +CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`); + +-- AddForeignKey +ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddded80..834a989 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,7 +193,6 @@ model customers { sync_version Int? @default(0) invoices invoices[] orders orders[] - issued_orders issued_orders[] projects projects[] quotations quotations[] } @@ -370,7 +369,7 @@ model orders { model issued_orders { id Int @id @default(autoincrement()) po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) - customer_id Int? + supplier_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) @@ -387,9 +386,9 @@ model issued_orders { 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") + suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk") - @@index([customer_id], map: "issued_orders_customer_id") + @@index([supplier_id], map: "issued_orders_supplier_id") @@index([status, order_date], map: "idx_issued_orders_status_date") } @@ -796,7 +795,8 @@ model sklad_suppliers { created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) - receipts sklad_receipts[] + receipts sklad_receipts[] + issued_orders issued_orders[] @@map("sklad_suppliers") } diff --git a/src/__tests__/drafts-deferred-numbering.test.ts b/src/__tests__/drafts-deferred-numbering.test.ts index cb124e5..8f1a645 100644 --- a/src/__tests__/drafts-deferred-numbering.test.ts +++ b/src/__tests__/drafts-deferred-numbering.test.ts @@ -51,6 +51,8 @@ describe("issued-order drafts — deferred numbering", () => { it("a draft has no number and does NOT consume the sequence", async () => { const before = (await previewIssuedOrderNumber()).number; const draft = await createIssuedOrder({}); // defaults to draft + if ("error" in draft) + throw new Error(`createIssuedOrder failed: ${draft.error}`); issuedOrderIds.push(draft.id); expect(draft.status).toBe("draft"); expect(draft.po_number).toBeNull(); @@ -62,6 +64,8 @@ describe("issued-order drafts — deferred numbering", () => { it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => { const expected = (await previewIssuedOrderNumber()).number; const draft = await createIssuedOrder({}); + if ("error" in draft) + throw new Error(`createIssuedOrder failed: ${draft.error}`); issuedOrderIds.push(draft.id); const res = await updateIssuedOrder(draft.id, { status: "sent" }); @@ -77,6 +81,8 @@ describe("issued-order drafts — deferred numbering", () => { it("finalizing is idempotent — re-finalizing does not re-number", async () => { const draft = await createIssuedOrder({}); + if ("error" in draft) + throw new Error(`createIssuedOrder failed: ${draft.error}`); issuedOrderIds.push(draft.id); await updateIssuedOrder(draft.id, { status: "sent" }); @@ -104,6 +110,8 @@ describe("issued-order drafts — deferred numbering", () => { it("two drafts coexist with null numbers (no unique violation)", async () => { const a = await createIssuedOrder({}); const b = await createIssuedOrder({}); + if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`); + if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`); issuedOrderIds.push(a.id, b.id); expect(a.po_number).toBeNull(); expect(b.po_number).toBeNull(); @@ -112,6 +120,8 @@ describe("issued-order drafts — deferred numbering", () => { it("deleting a draft releases nothing (no number was consumed)", async () => { const before = (await previewIssuedOrderNumber()).number; const draft = await createIssuedOrder({}); + if ("error" in draft) + throw new Error(`createIssuedOrder failed: ${draft.error}`); await deleteIssuedOrder(draft.id); const after = (await previewIssuedOrderNumber()).number; expect(after).toBe(before); diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index 6f58695..023e18e 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; import prisma from "../config/database"; +import { config } from "../config/env"; import { generateIssuedOrderNumber, previewIssuedOrderNumber, @@ -13,7 +16,9 @@ import { listIssuedOrders, updateIssuedOrder, deleteIssuedOrder, + type IssuedOrderInput, } from "../services/issued-orders.service"; +import issuedOrdersRoutes from "../routes/admin/issued-orders"; import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf"; afterEach(async () => { @@ -53,12 +58,12 @@ describe("issued-order numbering", () => { describe("CreateIssuedOrderSchema", () => { it("coerces string form numbers and rejects an out-of-range VAT", () => { const ok = CreateIssuedOrderSchema.safeParse({ - customer_id: "5", + supplier_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); + if (ok.success) expect(ok.data.supplier_id).toBe(5); const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" }); expect(bad.success).toBe(false); @@ -66,23 +71,38 @@ describe("CreateIssuedOrderSchema", () => { }); const createdIds: number[] = []; -const createdCustomerIds: number[] = []; +const createdSupplierIds: number[] = []; afterEach(async () => { + // Orders first — the supplier FK is onDelete Restrict. for (const id of createdIds) await prisma.issued_orders.deleteMany({ where: { id } }); - for (const id of createdCustomerIds) - await prisma.customers.deleteMany({ where: { id } }); + for (const id of createdSupplierIds) + await prisma.sklad_suppliers.deleteMany({ where: { id } }); createdIds.length = 0; - createdCustomerIds.length = 0; + createdSupplierIds.length = 0; }); -async function makeCustomer() { - const c = await prisma.customers.create({ - data: { name: "Dodavatel s.r.o." }, +async function makeSupplier( + data: Partial<{ name: string; ico: string; is_active: boolean }> = {}, +) { + const s = await prisma.sklad_suppliers.create({ + data: { + name: data.name ?? "io_test_Dodavatel s.r.o.", + ico: data.ico ?? null, + is_active: data.is_active ?? true, + }, }); - createdCustomerIds.push(c.id); - return c; + createdSupplierIds.push(s.id); + return s; +} + +/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */ +async function mkIssued(input: IssuedOrderInput = {}) { + const res = await createIssuedOrder(input); + if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`); + createdIds.push(res.id); + return res; } describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => { @@ -120,18 +140,18 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => { }); describe("createIssuedOrder", () => { - it("defaults to draft with NO PO number, stores items", async () => { - const c = await makeCustomer(); - const order = await createIssuedOrder({ - customer_id: c.id, + it("defaults to draft with NO PO number, stores supplier_id + items", async () => { + const s = await makeSupplier(); + const order = await mkIssued({ + supplier_id: s.id, items: [ { description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 }, ], }); - createdIds.push(order.id); // Deferred numbering: a draft carries no number. expect(order.po_number).toBeNull(); expect(order.status).toBe("draft"); + expect(order.supplier_id).toBe(s.id); const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, }); @@ -141,17 +161,20 @@ describe("createIssuedOrder", () => { it("numbers immediately when created already-finalized (status sent)", async () => { const before = (await previewIssuedOrderNumber()).number; - const order = await createIssuedOrder({ status: "sent" }); - createdIds.push(order.id); + const order = await mkIssued({ status: "sent" }); expect(order.po_number).toBe(before); expect(order.status).toBe("sent"); }); + + it("rejects a nonexistent supplier_id instead of throwing P2003", async () => { + const res = await createIssuedOrder({ supplier_id: 99999999 }); + expect("error" in res && res.error).toBe("supplier_not_found"); + }); }); describe("updateIssuedOrder status transitions", () => { it("allows draft -> sent and rejects draft -> completed", async () => { - const order = await createIssuedOrder({}); - createdIds.push(order.id); + const order = await mkIssued({}); const ok = await updateIssuedOrder(order.id, { status: "sent" }); expect("error" in ok).toBe(false); const bad = await updateIssuedOrder(order.id, { status: "completed" }); @@ -159,10 +182,9 @@ describe("updateIssuedOrder status transitions", () => { }); it("locks items once confirmed", async () => { - const order = await createIssuedOrder({ + const order = await mkIssued({ 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, { @@ -174,22 +196,28 @@ describe("updateIssuedOrder status transitions", () => { expect(items.length).toBe(1); expect(items[0].description).toBe("A"); }); + + it("rejects a nonexistent supplier_id on update", async () => { + const order = await mkIssued({}); + const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); + expect("error" in res && res.error).toBe("supplier_not_found"); + }); }); 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); + it("returns supplier, supplier_name and valid_transitions", async () => { + const s = await makeSupplier(); + const order = await mkIssued({ supplier_id: s.id }); const detail = await getIssuedOrder(order.id); - expect(detail?.customer_name).toBe("Dodavatel s.r.o."); + expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o."); + expect(detail?.supplier?.id).toBe(s.id); expect(detail?.valid_transitions).toContain("sent"); }); }); describe("deleteIssuedOrder", () => { it("deletes, cascades items, frees the latest number", async () => { - const order = await createIssuedOrder({ + const order = await mkIssued({ items: [{ description: "X", quantity: 1, unit_price: 1 }], }); // Finalize so the order has a real (consumed) number to free on delete. @@ -215,8 +243,7 @@ describe("deleteIssuedOrder", () => { // Allocate a real prior-year number (consumes that year's sequence: 0 -> 1) // so the PO number matches the prior year's current highest. const { number: poNumber } = await generateIssuedOrderNumber(priorYear); - const order = await createIssuedOrder({ po_number: poNumber }); - createdIds.push(order.id); + const order = await mkIssued({ po_number: poNumber }); // Backdate creation into the prior year so delete derives that year. await prisma.issued_orders.update({ where: { id: order.id }, @@ -234,9 +261,8 @@ describe("deleteIssuedOrder", () => { describe("listIssuedOrders month filter", () => { it("returns only orders whose order_date is in the given month", async () => { - const inMonth = await createIssuedOrder({ order_date: "2026-03-15" }); - const outMonth = await createIssuedOrder({ order_date: "2026-04-15" }); - createdIds.push(inMonth.id, outMonth.id); + const inMonth = await mkIssued({ order_date: "2026-03-15" }); + const outMonth = await mkIssued({ order_date: "2026-04-15" }); const res = await listIssuedOrders({ page: 1, limit: 50, @@ -252,6 +278,142 @@ describe("listIssuedOrders month filter", () => { }); }); +/* -------------------------------------------------------------------------- */ +/* Route-level: suppliers lookup endpoint + supplier validation */ +/* -------------------------------------------------------------------------- */ + +let app: ReturnType | null = null; +let adminToken = ""; +let noPermToken = ""; +let noPermUserId = 0; +let noPermRoleId = 0; + +function generateToken(user: { + id: number; + username: string; + roleName: string | null; +}): string { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(issuedOrdersRoutes, { + prefix: "/api/admin/issued-orders", + }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = generateToken({ + id: admin.id, + username: admin.username, + roleName: "admin", + }); + + // A role with NO orders permissions, to prove the lookup endpoint is gated. + const noPermRole = await prisma.roles.create({ + data: { + name: "io_test_no_orders", + display_name: "Test No Orders", + description: "Test role without orders permissions", + }, + }); + noPermRoleId = noPermRole.id; + const noPermUser = await prisma.users.create({ + data: { + username: "io_test_noperm", + email: "io_test_noperm@test.local", + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "No", + last_name: "Orders", + is_active: true, + role_id: noPermRole.id, + }, + }); + noPermUserId = noPermUser.id; + noPermToken = generateToken({ + id: noPermUser.id, + username: noPermUser.username, + roleName: noPermRole.name, + }); +}); + +afterAll(async () => { + if (noPermUserId) + await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {}); + if (noPermRoleId) + await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {}); + if (app) await app.close(); +}); + +describe("GET /api/admin/issued-orders/suppliers", () => { + it("returns active suppliers only (with the picker fields)", async () => { + const active = await makeSupplier({ + name: "io_test_Aktivní dodavatel", + ico: "12345678", + }); + const inactive = await makeSupplier({ + name: "io_test_Neaktivní dodavatel", + is_active: false, + }); + + const res = await app!.inject({ + method: "GET", + url: "/api/admin/issued-orders/suppliers", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + const ids = body.data.map((s: { id: number }) => s.id); + expect(ids).toContain(active.id); + expect(ids).not.toContain(inactive.id); + const row = body.data.find((s: { id: number }) => s.id === active.id); + expect(row.name).toBe("io_test_Aktivní dodavatel"); + expect(row.ico).toBe("12345678"); + // Lightweight lookup shape — all picker fields present. + expect(Object.keys(row).sort()).toEqual( + ["address", "dic", "email", "ico", "id", "name", "phone"].sort(), + ); + }); + + it("is denied (403) to a user with no orders permissions", async () => { + const res = await app!.inject({ + method: "GET", + url: "/api/admin/issued-orders/suppliers", + headers: { Authorization: `Bearer ${noPermToken}` }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().success).toBe(false); + }); +}); + +describe("POST /api/admin/issued-orders supplier validation", () => { + it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => { + const res = await app!.inject({ + method: "POST", + url: "/api/admin/issued-orders", + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { supplier_id: 99999999 }, + }); + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body.success).toBe(false); + expect(body.error).toBe("Dodavatel nenalezen"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* PDF render */ +/* -------------------------------------------------------------------------- */ + describe("renderIssuedOrderHtml", () => { const order = { po_number: "26720001", @@ -278,11 +440,19 @@ describe("renderIssuedOrderHtml", () => { const issuer = { name: "Jan Novák" }; + // sklad_suppliers shape: single Text address blob + ico/dic columns. + const supplier = { + name: "Dodavatel s.r.o.", + ico: "12345678", + dic: "CZ12345678", + address: "Průmyslová 5\n190 00 Praha", + }; + it("renders the PO number, items, and both party names (PO direction)", () => { const html = renderIssuedOrderHtml( order, items, - { name: "Dodavatel s.r.o." }, + supplier, { company_name: "Naše firma" }, "cs", issuer, @@ -291,14 +461,46 @@ describe("renderIssuedOrderHtml", () => { expect(html).toContain("Materiál"); // Optional item sub-line. expect(html).toContain("Detailní popis položky"); - // PO direction: the customer record is the Dodavatel (supplier), our - // company is the Odběratel (buyer). + // PO direction: the sklad_suppliers record is the Dodavatel (supplier), + // our company is the Odběratel (buyer). expect(html).toContain("Dodavatel s.r.o."); expect(html).toContain("Naše firma"); expect(html).toContain("Dodavatel"); expect(html).toContain("Odběratel"); }); + it("renders the supplier address (split on newlines) plus IČO and DIČ", () => { + const html = renderIssuedOrderHtml( + order, + items, + supplier, + null, + "cs", + issuer, + ); + // The single Text address blob becomes one address line per newline. + expect(html).toContain('
Průmyslová 5
'); + expect(html).toContain('
190 00 Praha
'); + expect(html).toContain("IČ: 12345678"); + expect(html).toContain("DIČ: CZ12345678"); + }); + + it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => { + const html = renderIssuedOrderHtml( + order, + items, + { name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" }, + null, + "cs", + issuer, + ); + expect(html).toContain( + '
Ulice 1, 100 00 Praha
', + ); + expect(html).not.toContain("IČ: "); + expect(html).not.toContain("DIČ: "); + }); + it("strips script tags from notes", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); expect(html).not.toContain("