From d1533ffc4db5fad2eacfbc9e7f34e7e04d3bd438 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 16:22:39 +0200 Subject: [PATCH] feat(documents)!: unify offers and issued orders end to end One coherent pass over the two sibling document models, driven by a 3-lens 1:1 scan (~60 divergences inventoried) + user decisions. Migration adds issued_orders.locked_by/locked_at and the issued_order_sections table (mirror of scope_sections; applied to dev + test DBs). Issued orders gained (offers as reference implementation): - rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in the PO template's red style (shared DocumentSectionSchema, one-transaction create/update incl. items - fixes a torn-write bug) - edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL = 3 missed 10s heartbeats; locked_by enrichment on detail) - archived-PDF serving: GET /:id/file reads the NAS copy (new readIssuedByNumber sweep) with live-render fallback + re-archive; offers' /file got the same fallback (kills the 'ulozte nabidku' dead end) - NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing number previews (also invoice previews), PUT returns assigned po_number Offers hardened (issued as reference): - VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate follows the table; order creation only from active offers) + valid_transitions on detail - explicit 400 instead of silent edits outside draft/active (mirrored on issued: explicit 400 replaced silent drops) - customer existence check + Number(null)->0 clear bug fixed; delete of a linked offer -> 409 instead of P2003 500; error-token convention; Zod caps DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString dates, ints for positions); audit old/new values + koncept fallbacks; list id tiebreaks; stats include trimmed; NAS delete dedupe Frontend unification (6 new shared modules): - components/document/{DocumentItemsEditor,SectionsEditor,LockBanner} - hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)} - OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only document number (form field removed), one form layout/readonly convention, view-permission opens read-only everywhere (issued's editable-for-viewers hole closed), server-driven transition buttons, dirty guard + Enter submit on both, useApiMutation everywhere (new opt-in envelope mode), fixed infinite spinner on failed detail fetch, draft PDFs hidden (no number yet) - lists: supplier filter + count line on issued, Mena column dropped + mono numbers on offers, shared hardened per-row PDF flow (spinner, double-click guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty states, query-lib cleanups (["offers","customers"] key, typed list rows, shared CurrencyAmount, retry:false on details) - pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/ formatCurrency/formatDate for all four PDF routes; offer PDF keeps its monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued PDF language now comes from the document column +49 tests (suite 364 -> 413). Each stage passed an independent review; final cross-stage integration review verified the FE<->BE contracts. Co-Authored-By: Claude Fable 5 --- .../migration.sql | 21 + prisma/schema.prisma | 19 + .../drafts-deferred-numbering.test.ts | 305 ++- src/__tests__/issued-orders.test.ts | 664 +++++- src/__tests__/manual-create.test.ts | 40 + src/__tests__/pdf-no-vat.test.ts | 100 + .../document/DocumentItemsEditor.tsx | 653 ++++++ src/admin/components/document/LockBanner.tsx | 55 + .../components/document/SectionsEditor.tsx | 315 +++ src/admin/hooks/useDocumentLock.ts | 81 + src/admin/hooks/useDocumentPdf.ts | 86 + src/admin/hooks/useUnsavedChangesGuard.ts | 41 + src/admin/lib/queries/issued-orders.ts | 45 +- src/admin/lib/queries/mutations.ts | 40 +- src/admin/lib/queries/offers.ts | 25 +- src/admin/pages/IssuedOrderDetail.tsx | 987 +++------ src/admin/pages/IssuedOrders.tsx | 202 +- src/admin/pages/OfferDetail.tsx | 1837 +++++------------ src/admin/pages/Offers.tsx | 101 +- src/admin/pages/OffersCustomers.tsx | 6 +- src/routes/admin/invoices-pdf.ts | 72 +- src/routes/admin/issued-orders-pdf.ts | 208 +- src/routes/admin/issued-orders.ts | 250 ++- src/routes/admin/offers-pdf.ts | 355 ++-- src/routes/admin/orders-pdf.ts | 70 +- src/routes/admin/quotations.ts | 172 +- src/schemas/common.ts | 22 + src/schemas/issued-orders.schema.ts | 11 +- src/schemas/offers.schema.ts | 62 +- src/services/issued-orders.service.ts | 328 ++- src/services/nas-financials-manager.ts | 48 + src/services/numbering.service.ts | 50 +- src/services/offers.service.ts | 194 +- src/services/orders.service.ts | 16 +- src/utils/pdf-shared.ts | 116 ++ 35 files changed, 4762 insertions(+), 2835 deletions(-) create mode 100644 prisma/migrations/20260610135626_issued_orders_lock_and_sections/migration.sql create mode 100644 src/admin/components/document/DocumentItemsEditor.tsx create mode 100644 src/admin/components/document/LockBanner.tsx create mode 100644 src/admin/components/document/SectionsEditor.tsx create mode 100644 src/admin/hooks/useDocumentLock.ts create mode 100644 src/admin/hooks/useDocumentPdf.ts create mode 100644 src/admin/hooks/useUnsavedChangesGuard.ts create mode 100644 src/utils/pdf-shared.ts diff --git a/prisma/migrations/20260610135626_issued_orders_lock_and_sections/migration.sql b/prisma/migrations/20260610135626_issued_orders_lock_and_sections/migration.sql new file mode 100644 index 0000000..a57ffaa --- /dev/null +++ b/prisma/migrations/20260610135626_issued_orders_lock_and_sections/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL, + ADD COLUMN `locked_by` INTEGER NULL; + +-- CreateTable +CREATE TABLE `issued_order_sections` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `issued_order_id` INTEGER NOT NULL, + `position` INTEGER NULL DEFAULT 0, + `title` VARCHAR(500) NULL, + `title_cz` VARCHAR(500) NULL, + `content` TEXT NULL, + `modified_at` DATETIME(0) NULL, + + INDEX `issued_order_sections_order_id`(`issued_order_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d782991..03b1c40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,15 +380,34 @@ model issued_orders { order_text String? @db.VarChar(500) notes String? @db.Text internal_notes String? @db.Text + locked_by Int? + locked_at DateTime? @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) issued_order_items issued_order_items[] + issued_order_sections issued_order_sections[] suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk") @@index([supplier_id], map: "issued_orders_supplier_id") @@index([status, order_date], map: "idx_issued_orders_status_date") } +// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections +// rendered on their own PDF page. Kept as a separate table (not shared) so the +// FK cascade and per-document ordering stay simple. +model issued_order_sections { + id Int @id @default(autoincrement()) + issued_order_id Int + position Int? @default(0) + title String? @db.VarChar(500) + title_cz String? @db.VarChar(500) + content String? @db.Text + modified_at DateTime? @db.DateTime(0) + issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk") + + @@index([issued_order_id], map: "issued_order_sections_order_id") +} + model issued_order_items { id Int @id @default(autoincrement()) issued_order_id Int diff --git a/src/__tests__/drafts-deferred-numbering.test.ts b/src/__tests__/drafts-deferred-numbering.test.ts index 8f1a645..d260460 100644 --- a/src/__tests__/drafts-deferred-numbering.test.ts +++ b/src/__tests__/drafts-deferred-numbering.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; import prisma from "../config/database"; +import { config } from "../config/env"; import { previewIssuedOrderNumber, previewInvoiceNumber, @@ -19,23 +22,36 @@ import { createOffer, updateOffer, deleteOffer, + invalidateOffer, + getOffer, + listOffers, } from "../services/offers.service"; +import quotationsRoutes from "../routes/admin/quotations"; // Track every row we create so the suite leaves the (real) app_test DB clean. const issuedOrderIds: number[] = []; const invoiceIds: number[] = []; const offerIds: number[] = []; +const customerIds: number[] = []; +const linkedOrderIds: number[] = []; afterEach(async () => { for (const id of issuedOrderIds) await prisma.issued_orders.deleteMany({ where: { id } }); for (const id of invoiceIds) await prisma.invoices.deleteMany({ where: { id } }); + // Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations. + for (const id of linkedOrderIds) + await prisma.orders.deleteMany({ where: { id } }); for (const id of offerIds) await prisma.quotations.deleteMany({ where: { id } }); + for (const id of customerIds) + await prisma.customers.deleteMany({ where: { id } }); issuedOrderIds.length = 0; invoiceIds.length = 0; + linkedOrderIds.length = 0; offerIds.length = 0; + customerIds.length = 0; // Reset every sequence this suite may have touched so preview values are // stable across tests and we don't leak consumed numbers into other files. await prisma.number_sequences.deleteMany({ @@ -43,6 +59,14 @@ afterEach(async () => { }); }); +/** createOffer + narrow the token union + track cleanup. */ +async function mkOffer(body: Record = {}) { + const res = await createOffer(body); + if ("error" in res) throw new Error(`createOffer failed: ${res.error}`); + offerIds.push(res.id); + return res; +} + /* -------------------------------------------------------------------------- */ /* Issued orders */ /* -------------------------------------------------------------------------- */ @@ -283,3 +307,282 @@ describe("offer drafts — deferred numbering", () => { expect(after).toBe(before); }); }); + +/* -------------------------------------------------------------------------- */ +/* Offers — status transition table (service) */ +/* -------------------------------------------------------------------------- */ + +describe("offer status transitions (service)", () => { + it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => { + const draft = await mkOffer({}); + const res = await updateOffer(draft.id, { status: "ordered" }); + expect("error" in res && res.error).toBe("invalid_transition"); + const row = await prisma.quotations.findUnique({ where: { id: draft.id } }); + expect(row!.status).toBe("draft"); + expect(row!.quotation_number).toBeNull(); + }); + + it("allows active -> invalidated via update", async () => { + const offer = await mkOffer({ status: "active" }); + const res = await updateOffer(offer.id, { status: "invalidated" }); + expect("error" in res).toBe(false); + const row = await prisma.quotations.findUnique({ where: { id: offer.id } }); + expect(row!.status).toBe("invalidated"); + }); + + it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => { + const draft = await mkOffer({}); + const rejected = await invalidateOffer(draft.id); + expect("error" in rejected && rejected.error).toBe("invalid_transition"); + + const ordered = await mkOffer({ status: "ordered" }); + const ok = await invalidateOffer(ordered.id); + expect("error" in ok).toBe(false); + + // invalidated is terminal — a second invalidate is rejected too. + const again = await invalidateOffer(ordered.id); + expect("error" in again && again.error).toBe("invalid_transition"); + }); + + it("getOffer exposes valid_transitions computed from the current status", async () => { + const draft = await mkOffer({}); + expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]); + + const active = await mkOffer({ status: "active" }); + expect((await getOffer(active.id))?.valid_transitions).toEqual([ + "ordered", + "invalidated", + ]); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Offers — editable-state guard (service) */ +/* -------------------------------------------------------------------------- */ + +describe("offer editable-state guard (service)", () => { + it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => { + const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" }); + const res = await updateOffer(offer.id, { project_code: "ZMĚNA" }); + expect("error" in res && res.error).toBe("not_editable"); + const row = await prisma.quotations.findUnique({ where: { id: offer.id } }); + expect(row!.project_code).toBe("PŮVODNÍ"); + }); + + it("rejects a field edit on an invalidated offer", async () => { + const offer = await mkOffer({ status: "invalidated" }); + const res = await updateOffer(offer.id, { scope_title: "X" }); + expect("error" in res && res.error).toBe("not_editable"); + }); + + it("still allows the status-only ordered -> invalidated payload", async () => { + const offer = await mkOffer({ status: "ordered" }); + const res = await updateOffer(offer.id, { status: "invalidated" }); + expect("error" in res).toBe(false); + const row = await prisma.quotations.findUnique({ where: { id: offer.id } }); + expect(row!.status).toBe("invalidated"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Offers — customer FK handling (service) */ +/* -------------------------------------------------------------------------- */ + +describe("offer customer FK handling (service)", () => { + async function mkCustomer() { + const c = await prisma.customers.create({ + data: { name: "drafts_test_zákazník" }, + }); + customerIds.push(c.id); + return c; + } + + it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => { + const res = await createOffer({ customer_id: 99999999 }); + expect("error" in res && res.error).toBe("customer_not_found"); + }); + + it("update rejects a nonexistent customer with a clean token", async () => { + const offer = await mkOffer({}); + const res = await updateOffer(offer.id, { customer_id: 99999999 }); + expect("error" in res && res.error).toBe("customer_not_found"); + }); + + it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => { + const customer = await mkCustomer(); + const offer = await mkOffer({ customer_id: customer.id }); + expect(offer.customer_id).toBe(customer.id); + + const res = await updateOffer(offer.id, { customer_id: null }); + expect("error" in res).toBe(false); + const row = await prisma.quotations.findUnique({ where: { id: offer.id } }); + expect(row!.customer_id).toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Offers — deterministic list ordering */ +/* -------------------------------------------------------------------------- */ + +describe("listOffers deterministic ordering", () => { + it("tiebreaks same-created_at rows by id (stable in both directions)", async () => { + const MARK = `TIEBREAK-${Date.now()}`; + const ids: number[] = []; + for (let i = 0; i < 3; i++) { + const o = await mkOffer({ project_code: MARK }); + ids.push(o.id); + } + // created_at is second-precision; pin all three to the SAME timestamp so + // only the id tiebreak can order them. + await prisma.quotations.updateMany({ + where: { id: { in: ids } }, + data: { created_at: new Date("2026-01-15T10:00:00") }, + }); + + const base = { page: 1, limit: 10, skip: 0, search: MARK }; + const desc = await listOffers({ + ...base, + sort: "created_at", + order: "desc", + }); + expect(desc.data.map((o: { id: number }) => o.id)).toEqual( + [...ids].sort((a, b) => b - a), + ); + + const asc = await listOffers({ ...base, sort: "created_at", order: "asc" }); + expect(asc.data.map((o: { id: number }) => o.id)).toEqual( + [...ids].sort((a, b) => a - b), + ); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Offers — route-level hardening (Czech messages + status codes) */ +/* -------------------------------------------------------------------------- */ + +let app: ReturnType | null = null; +let adminToken = ""; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(quotationsRoutes, { prefix: "/api/admin/offers" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +}); + +afterAll(async () => { + if (app) await app.close(); +}); + +describe("offers routes — hardening (HTTP)", () => { + const auth = () => ({ Authorization: `Bearer ${adminToken}` }); + + it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => { + const offer = await mkOffer({ status: "ordered" }); + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/offers/${offer.id}`, + headers: auth(), + payload: { project_code: "ZMĚNA" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat"); + }); + + it("PUT rejects draft -> ordered with the transition message", async () => { + const draft = await mkOffer({}); + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/offers/${draft.id}`, + headers: auth(), + payload: { status: "ordered" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe( + 'Neplatný přechod stavu z "draft" na "ordered"', + ); + }); + + it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => { + const res = await app!.inject({ + method: "POST", + url: "/api/admin/offers", + headers: auth(), + payload: { items: [{ description: "x".repeat(501) }] }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().success).toBe(false); + }); + + it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => { + const res = await app!.inject({ + method: "POST", + url: "/api/admin/offers", + headers: auth(), + payload: { customer_id: 99999999 }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("Zákazník nenalezen"); + }); + + it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => { + const offer = await mkOffer({ status: "active" }); + const order = await prisma.orders.create({ + data: { + order_number: `DRAFTSLINK-${Date.now()}`, + quotation_id: offer.id, + }, + }); + linkedOrderIds.push(order.id); + + const res = await app!.inject({ + method: "DELETE", + url: `/api/admin/offers/${offer.id}`, + headers: auth(), + }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toBe( + "Nabídku nelze smazat, je navázána na objednávku nebo projekt", + ); + // The offer survived the rejected delete. + expect( + await prisma.quotations.findUnique({ where: { id: offer.id } }), + ).not.toBeNull(); + }); + + it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => { + const draft = await mkOffer({ project_code: "PŘED" }); + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/offers/${draft.id}`, + headers: auth(), + payload: { project_code: "PO" }, + }); + expect(res.statusCode).toBe(200); + + const audit = await prisma.audit_logs.findFirst({ + where: { + entity_type: "quotation", + entity_id: draft.id, + action: "update", + }, + orderBy: { id: "desc" }, + }); + expect(audit).not.toBeNull(); + expect(audit!.old_values).toBeTruthy(); + expect(audit!.new_values).toBeTruthy(); + expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED"); + expect(JSON.parse(audit!.new_values!).project_code).toBe("PO"); + // Unnumbered drafts read "koncept", never "null". + expect(audit!.description).toContain("koncept"); + expect(audit!.description).not.toContain("null"); + }); +}); diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index f1ba5cc..500b7ed 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, + vi, +} from "vitest"; import Fastify from "fastify"; import jwt from "jsonwebtoken"; import prisma from "../config/database"; @@ -19,7 +27,18 @@ import { type IssuedOrderInput, } from "../services/issued-orders.service"; import issuedOrdersRoutes from "../routes/admin/issued-orders"; -import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf"; +import issuedOrdersPdfRoutes, { + renderIssuedOrderHtml, +} from "../routes/admin/issued-orders-pdf"; +import { nasOrdersManager } from "../services/nas-financials-manager"; +import { htmlToPdf } from "../utils/html-to-pdf"; + +// Keep Puppeteer OUT of the tests: the /:id/file live-render fallback calls +// htmlToPdf — stub the whole module so no browser ever launches. +vi.mock("../utils/html-to-pdf", () => ({ + htmlToPdf: vi.fn(async () => Buffer.from("%PDF-FAKE issued-orders-test")), + closeBrowser: vi.fn(async () => {}), +})); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); @@ -186,15 +205,18 @@ describe("updateIssuedOrder status transitions", () => { expect("error" in bad && bad.error).toBe("invalid_transition"); }); - it("locks items once confirmed", async () => { + it("explicitly rejects a field edit once confirmed (not_editable), items unchanged", async () => { const order = await mkIssued({ items: [{ description: "A", quantity: 1, unit_price: 10 }], }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); - await updateIssuedOrder(order.id, { + // The old behavior silently DROPPED the field edit; now it is an explicit + // not_editable rejection (offers parity) — and the items stay unchanged. + const res = await updateIssuedOrder(order.id, { items: [{ description: "B", quantity: 9, unit_price: 99 }], }); + expect("error" in res && res.error).toBe("not_editable"); const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, }); @@ -202,6 +224,18 @@ describe("updateIssuedOrder status transitions", () => { expect(items[0].description).toBe("A"); }); + it("status-only transition payloads still work when not editable", async () => { + const order = await mkIssued({}); + await updateIssuedOrder(order.id, { status: "sent" }); + await updateIssuedOrder(order.id, { status: "confirmed" }); + const res = await updateIssuedOrder(order.id, { status: "completed" }); + expect("error" in res).toBe(false); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.status).toBe("completed"); + }); + it("rejects a nonexistent supplier_id on update", async () => { const order = await mkIssued({}); const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); @@ -289,6 +323,10 @@ describe("listIssuedOrders month filter", () => { let app: ReturnType | null = null; let adminToken = ""; +let adminUserId = 0; +let adminFullName = ""; +let lockerToken = ""; +let lockerUserId = 0; let noPermToken = ""; let noPermUserId = 0; let noPermRoleId = 0; @@ -310,6 +348,9 @@ beforeAll(async () => { await app.register(issuedOrdersRoutes, { prefix: "/api/admin/issued-orders", }); + await app.register(issuedOrdersPdfRoutes, { + prefix: "/api/admin/issued-orders-pdf", + }); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, @@ -320,6 +361,29 @@ beforeAll(async () => { username: admin.username, roleName: "admin", }); + adminUserId = admin.id; + adminFullName = `${admin.first_name} ${admin.last_name}`.trim(); + + // A SECOND user with edit rights (admin role → bypasses permission checks), + // to exercise the 423 lock conflict + the locked_by enrichment on GET /:id. + const locker = await prisma.users.create({ + data: { + username: "io_test_locker", + email: "io_test_locker@test.local", + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Druhý", + last_name: "Editor", + is_active: true, + role_id: admin.role_id, + }, + }); + lockerUserId = locker.id; + lockerToken = generateToken({ + id: locker.id, + username: locker.username, + roleName: "admin", + }); // A role with NO orders permissions, to prove the lookup endpoint is gated. const noPermRole = await prisma.roles.create({ @@ -351,6 +415,8 @@ beforeAll(async () => { }); afterAll(async () => { + if (lockerUserId) + await prisma.users.delete({ where: { id: lockerUserId } }).catch(() => {}); if (noPermUserId) await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {}); if (noPermRoleId) @@ -415,6 +481,127 @@ describe("POST /api/admin/issued-orders supplier validation", () => { }); }); +describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { + it("400s a field edit on a confirmed order with the explicit Czech message", async () => { + const order = await mkIssued({}); + await updateIssuedOrder(order.id, { status: "sent" }); + await updateIssuedOrder(order.id, { status: "confirmed" }); + + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { notes: "pokus o úpravu" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat"); + }); + + it("a status-only transition payload keeps working (confirmed -> completed)", async () => { + const order = await mkIssued({}); + await updateIssuedOrder(order.id, { status: "sent" }); + await updateIssuedOrder(order.id, { status: "confirmed" }); + + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { status: "completed" }, + }); + expect(res.statusCode).toBe(200); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.status).toBe("completed"); + }); +}); + +describe("issued-order audit old/new values", () => { + it("PUT writes an audit row carrying oldValues and newValues", async () => { + const order = await mkIssued({ notes: "před úpravou" }); + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { notes: "po úpravě" }, + }); + expect(res.statusCode).toBe(200); + + const audit = await prisma.audit_logs.findFirst({ + where: { + entity_type: "issued_order", + entity_id: order.id, + action: "update", + }, + orderBy: { id: "desc" }, + }); + expect(audit).not.toBeNull(); + expect(audit!.old_values).toBeTruthy(); + expect(audit!.new_values).toBeTruthy(); + expect(JSON.parse(audit!.old_values!).notes).toBe("před úpravou"); + expect(JSON.parse(audit!.new_values!).notes).toBe("po úpravě"); + // Unnumbered drafts read "koncept", never "null". + expect(audit!.description).toContain("koncept"); + expect(audit!.description).not.toContain("null"); + }); + + it("DELETE writes an audit row carrying oldValues", async () => { + const order = await mkIssued({ notes: "ke smazání" }); + const res = await app!.inject({ + method: "DELETE", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + + const audit = await prisma.audit_logs.findFirst({ + where: { + entity_type: "issued_order", + entity_id: order.id, + action: "delete", + }, + orderBy: { id: "desc" }, + }); + expect(audit).not.toBeNull(); + expect(JSON.parse(audit!.old_values!).notes).toBe("ke smazání"); + expect(audit!.description).toContain("koncept"); + }); +}); + +describe("GET /api/admin/issued-orders-pdf/:id language default", () => { + it("defaults to the order's language column; ?lang= is an explicit override", async () => { + const order = await mkIssued({ + language: "en", + items: [{ description: "Lang test", quantity: 1, unit_price: 10 }], + }); + + // Echo the rendered HTML through the htmlToPdf stub so the response body + // is assertable (still no Puppeteer). Once-scoped: the module-level fake + // ("%PDF-FAKE …") stays in place for the other tests. + vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) => + Buffer.from(html), + ); + const en = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders-pdf/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(en.statusCode).toBe(200); + expect(en.rawPayload.toString()).toContain("PURCHASE ORDER No."); + + vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) => + Buffer.from(html), + ); + const cs = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders-pdf/${order.id}?lang=cs`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(cs.statusCode).toBe(200); + expect(cs.rawPayload.toString()).toContain("OBJEDNÁVKA č."); + }); +}); + /* -------------------------------------------------------------------------- */ /* PDF render */ /* -------------------------------------------------------------------------- */ @@ -537,8 +724,8 @@ describe("renderIssuedOrderHtml", () => { // No VAT anywhere — the PO is not a tax document. expect(html).not.toContain("%DPH"); expect(html).not.toContain(">DPH<"); - // Line total = netto (2 × 100). - expect(html).toContain('200,00'); + // Line total = netto (2 × 100), formatted with the per-currency symbol. + expect(html).toContain('200,00 Kč'); expect(html).toContain("Celkem bez DPH"); // No Mezisoučet row (it would duplicate the grand total) and no // prices-excl.-VAT notice (user removed it — the total label suffices). @@ -553,6 +740,26 @@ describe("renderIssuedOrderHtml", () => { expect(html).not.toContain("Prices are exclusive of VAT"); }); + it("amounts carry the currency symbol; the 'Částky jsou uvedeny v' note is gone", () => { + const cz = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); + // Unit price, line total and grand total all go through formatCurrency. + expect(cz).toContain("100,00 Kč"); + expect(cz).toContain("200,00 Kč"); + expect(cz).not.toContain("Částky jsou uvedeny v"); + expect(cz).not.toContain("currency-note"); + + const en = renderIssuedOrderHtml( + { ...order, currency: "EUR" }, + items, + null, + null, + "en", + issuer, + ); + expect(en).toContain("200,00 €"); + expect(en).not.toContain("Amounts are in"); + }); + it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); // Footer: Vystavil from authData. No e-mail line. @@ -563,3 +770,448 @@ describe("renderIssuedOrderHtml", () => { expect(html).not.toContain("Schválil:"); }); }); + +/* -------------------------------------------------------------------------- */ +/* Sections (rich-text CZ/EN, mirrors offers' scope_sections) */ +/* -------------------------------------------------------------------------- */ + +describe("CreateIssuedOrderSchema sections", () => { + it("accepts sections with DB-aligned 500-char titles and coerces position", () => { + const longTitle = "T".repeat(400); // >255 — the old offers cap under-shot + const ok = CreateIssuedOrderSchema.safeParse({ + sections: [ + { title: longTitle, title_cz: "Rozsah", content: "

x

" }, + { title: null, position: "3" }, + ], + }); + expect(ok.success).toBe(true); + if (ok.success) { + expect(ok.data.sections![0].title).toBe(longTitle); + expect(ok.data.sections![1].position).toBe(3); + } + + const bad = CreateIssuedOrderSchema.safeParse({ + sections: [{ title: "T".repeat(501) }], + }); + expect(bad.success).toBe(false); + }); +}); + +describe("issued-order sections (service)", () => { + it("creates sections ordered by position and maps them on getIssuedOrder", async () => { + const order = await mkIssued({ + sections: [ + { title: "Scope EN", title_cz: "Rozsah CZ", content: "

Obsah A

" }, + { title: "Second", content: "

Obsah B

" }, + ], + }); + const detail = await getIssuedOrder(order.id); + expect(detail?.sections.length).toBe(2); + expect(detail!.sections[0].title).toBe("Scope EN"); + expect(detail!.sections[0].title_cz).toBe("Rozsah CZ"); + expect(detail!.sections[0].position).toBe(0); + expect(detail!.sections[1].title).toBe("Second"); + expect(detail!.sections[1].position).toBe(1); + }); + + it("full-replaces sections on update and cascades them on delete", async () => { + const order = await mkIssued({ + sections: [ + { title: "A", content: "

a

" }, + { title: "B", content: "

b

" }, + ], + }); + + await updateIssuedOrder(order.id, { + sections: [ + { title_cz: "Jediná sekce", content: "

Nové

", position: 5 }, + ], + }); + const detail = await getIssuedOrder(order.id); + expect(detail?.sections.length).toBe(1); + expect(detail!.sections[0].title_cz).toBe("Jediná sekce"); + expect(detail!.sections[0].position).toBe(5); + + await deleteIssuedOrder(order.id); + expect( + ( + await prisma.issued_order_sections.findMany({ + where: { issued_order_id: order.id }, + }) + ).length, + ).toBe(0); + }); +}); + +describe("updateIssuedOrder atomicity (single transaction)", () => { + it("rolls back the header write when the items replace fails", async () => { + const order = await mkIssued({ + notes: "původní", + items: [{ description: "A", quantity: 1, unit_price: 10 }], + }); + + // issued_order_items.description is VarChar(500) — 600 chars makes the + // in-tx createMany fail at the DB, which must roll back the header update + // (the old two-transaction shape left the header committed: torn write). + const tooLong = "x".repeat(600); + await expect( + updateIssuedOrder(order.id, { + notes: "změněno", + items: [{ description: tooLong, quantity: 1, unit_price: 1 }], + }), + ).rejects.toThrow(); + + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.notes).toBe("původní"); + 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"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* PO number uniqueness + preview collision-advance */ +/* -------------------------------------------------------------------------- */ + +describe("po_number uniqueness", () => { + it("returns a clean 409 for an explicit duplicate po_number, not a 500", async () => { + await mkIssued({ po_number: "IODUP1" }); + const res = await app!.inject({ + method: "POST", + url: "/api/admin/issued-orders", + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { po_number: "IODUP1" }, + }); + expect(res.statusCode).toBe(409); + const body = res.json(); + expect(body.success).toBe(false); + expect(body.error).toBe("Číslo objednávky je již použito"); + }); + + it("preview advances past a number already used by an existing order", async () => { + const p = await previewIssuedOrderNumber(); + const order = await mkIssued({ po_number: p.number }); + expect(order.po_number).toBe(p.number); + const p2 = await previewIssuedOrderNumber(); + expect(p2.number).not.toBe(p.number); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Edit locking (mirrors offers/quotations.ts — 10s lock + heartbeat) */ +/* -------------------------------------------------------------------------- */ + +describe("issued-order edit locking", () => { + const lockUrl = (id: number, action: string) => + `/api/admin/issued-orders/${id}/${action}`; + + it("locks for the first editor; a second user gets 423 with the holder's name", async () => { + const order = await mkIssued({}); + const r1 = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(r1.statusCode).toBe(200); + + const r2 = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${lockerToken}` }, + }); + expect(r2.statusCode).toBe(423); + expect(r2.json().error).toContain("Objednávku právě upravuje"); + expect(r2.json().error).toContain(adminFullName); + }); + + it("re-locking by the SAME holder succeeds (refresh, not conflict)", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + const again = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(again.statusCode).toBe(200); + }); + + it("heartbeat extends the lock", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + // Age the lock close to expiry, then heartbeat — locked_at must be fresh. + const aged = new Date(Date.now() - 9000); + await prisma.issued_orders.update({ + where: { id: order.id }, + data: { locked_at: aged }, + }); + const hb = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "heartbeat"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(hb.statusCode).toBe(200); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(Date.now() - new Date(row!.locked_at!).getTime()).toBeLessThan(5000); + }); + + it("unlock releases the lock so another user can take it", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + const ul = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "unlock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(ul.statusCode).toBe(200); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.locked_by).toBeNull(); + expect(row!.locked_at).toBeNull(); + + const r2 = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${lockerToken}` }, + }); + expect(r2.statusCode).toBe(200); + }); + + it("unlock by a non-holder does NOT release someone else's lock", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "unlock"), + headers: { Authorization: `Bearer ${lockerToken}` }, + }); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.locked_by).toBe(adminUserId); + }); + + it("a stale lock (>30s without heartbeat) is takeable by another user", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + await prisma.issued_orders.update({ + where: { id: order.id }, + data: { locked_at: new Date(Date.now() - 31000) }, + }); + const r2 = await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${lockerToken}` }, + }); + expect(r2.statusCode).toBe(200); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + }); + expect(row!.locked_by).toBe(lockerUserId); + }); + + it("GET /:id enriches locked_by ONLY for the other user", async () => { + const order = await mkIssued({}); + await app!.inject({ + method: "POST", + url: lockUrl(order.id, "lock"), + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + // The holder sees no lock banner (locked_by null). + const asHolder = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(asHolder.statusCode).toBe(200); + expect(asHolder.json().data.locked_by).toBeNull(); + + // The other user sees who holds the lock (offers' enrichment shape). + const asOther = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${lockerToken}` }, + }); + expect(asOther.statusCode).toBe(200); + const lockedBy = asOther.json().data.locked_by; + expect(lockedBy).not.toBeNull(); + expect(lockedBy.user_id).toBe(adminUserId); + expect(lockedBy.full_name).toBe(adminFullName); + expect(typeof lockedBy.username).toBe("string"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* GET /:id/file — archived PDF with live-render fallback */ +/* -------------------------------------------------------------------------- */ + +describe("GET /api/admin/issued-orders/:id/file", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("404s for a draft without a po_number", async () => { + const order = await mkIssued({}); + const res = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders/${order.id}/file`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().success).toBe(false); + }); + + it("serves the archived PDF bytes inline on an archive hit", async () => { + const order = await mkIssued({ po_number: "IOTESTPOHIT" }); + const bytes = Buffer.from("%PDF-ARCHIVED issued"); + const readSpy = vi + .spyOn(nasOrdersManager, "readIssuedByNumber") + .mockReturnValue({ data: bytes, fileName: "IOTESTPOHIT.pdf" }); + + const res = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders/${order.id}/file`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("application/pdf"); + expect(res.headers["content-disposition"]).toContain("inline"); + expect(res.headers["content-disposition"]).toContain( + "Objednavka-IOTESTPOHIT.pdf", + ); + expect(res.rawPayload.equals(bytes)).toBe(true); + expect(readSpy).toHaveBeenCalledWith("IOTESTPOHIT"); + }); + + it("falls back to a live render and re-archives on an archive miss", async () => { + const order = await mkIssued({ + po_number: "IOTESTPOMISS", + order_date: "2026-03-15", + items: [{ description: "Materiál", quantity: 2, unit_price: 100 }], + sections: [{ title_cz: "Rozsah", content: "

Obsah

" }], + }); + vi.spyOn(nasOrdersManager, "readIssuedByNumber").mockReturnValue(null); + vi.spyOn(nasOrdersManager, "isConfigured").mockReturnValue(true); + const cleanSpy = vi + .spyOn(nasOrdersManager, "cleanIssued") + .mockImplementation(() => {}); + const saveSpy = vi + .spyOn(nasOrdersManager, "saveIssuedPdf") + .mockReturnValue("Vydané/2026/03/IOTESTPOMISS.pdf"); + + const res = await app!.inject({ + method: "GET", + url: `/api/admin/issued-orders/${order.id}/file`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("application/pdf"); + // The stubbed htmlToPdf output is served (live render, no Puppeteer). + expect(res.rawPayload.toString()).toContain("%PDF-FAKE"); + // The fresh PDF was archived to the NAS under the order_date year/month. + expect(cleanSpy).toHaveBeenCalledWith("IOTESTPOMISS"); + expect(saveSpy).toHaveBeenCalledWith( + "IOTESTPOMISS", + 2026, + 3, + expect.any(Buffer), + ); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* PDF — sections scope page */ +/* -------------------------------------------------------------------------- */ + +describe("renderIssuedOrderHtml sections (scope page)", () => { + const order = { + po_number: "26720001", + order_date: new Date("2026-06-09T12:00:00"), + delivery_date: null, + currency: "CZK", + notes: null, + delivery_terms: null, + payment_terms: null, + issued_by: null, + }; + const items = [ + { + description: "Materiál", + item_description: null, + quantity: 1, + unit: null, + unit_price: 100, + }, + ]; + const issuer = { name: "Jan Novák" }; + + it("renders the scope page: cs prefers title_cz, sanitizes content, repeats the PO header", () => { + const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [ + { + title: "Scope EN", + title_cz: "Rozsah CZ", + content: "

Obsah

", + }, + { title: "Only EN", title_cz: null, content: null }, + ]); + expect(html).toContain('class="scope-page"'); + // cs → title_cz preferred when non-empty… + expect(html).toContain("Rozsah CZ"); + expect(html).not.toContain("Scope EN"); + // …and falls back to the EN title when title_cz is empty. + expect(html).toContain("Only EN"); + expect(html).toContain("Obsah"); + expect(html).not.toContain("