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 <noreply@anthropic.com>
248 lines
8.0 KiB
TypeScript
248 lines
8.0 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||
import Fastify from "fastify";
|
||
import jwt from "jsonwebtoken";
|
||
import prisma from "../config/database";
|
||
import { config } from "../config/env";
|
||
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||
import { createInvoice } from "../services/invoices.service";
|
||
|
||
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||
// tax documents — VAT must never appear on them. These tests pin that contract
|
||
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||
// is covered in issued-orders.test.ts.)
|
||
|
||
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||
// total label alone carries the information. Pin its absence too.
|
||
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||
const EN_NOTE = "Prices are exclusive of VAT";
|
||
|
||
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||
const order = {
|
||
order_number: "26730042",
|
||
customer_order_number: "PO-XYZ",
|
||
created_at: new Date("2026-06-09T12:00:00"),
|
||
currency: "CZK",
|
||
notes: null,
|
||
customers: null,
|
||
};
|
||
const items = [
|
||
{
|
||
description: "Materiál",
|
||
quantity: 2,
|
||
unit: "ks",
|
||
unit_price: 100,
|
||
is_included_in_total: true,
|
||
},
|
||
];
|
||
|
||
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||
expect(html).not.toContain("%DPH");
|
||
expect(html).not.toContain(">DPH<");
|
||
expect(html).not.toContain("Mezisoučet");
|
||
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||
expect(html).toContain("Celkem bez DPH");
|
||
expect(html).not.toContain(CS_NOTE);
|
||
});
|
||
|
||
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||
expect(html).not.toContain("VAT%");
|
||
expect(html).toContain("Total excl. VAT");
|
||
expect(html).not.toContain(EN_NOTE);
|
||
});
|
||
|
||
it("excludes not-included lines from the NET total", () => {
|
||
const html = renderOrderConfirmationHtml(
|
||
order,
|
||
[
|
||
...items,
|
||
{
|
||
description: "Mimo cenu",
|
||
quantity: 1,
|
||
unit: "ks",
|
||
unit_price: 999,
|
||
is_included_in_total: false,
|
||
},
|
||
],
|
||
null,
|
||
"cs",
|
||
"Jan",
|
||
);
|
||
// Grand total stays 200,00 — the excluded line doesn't count.
|
||
expect(html).toContain("200,00 CZK");
|
||
});
|
||
|
||
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||
const html = renderOrderConfirmationHtml(
|
||
order,
|
||
[
|
||
{
|
||
description: "Velká položka",
|
||
quantity: 2,
|
||
unit: "ks",
|
||
unit_price: 1000,
|
||
is_included_in_total: true,
|
||
},
|
||
],
|
||
null,
|
||
"cs",
|
||
"Jan",
|
||
);
|
||
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||
expect(html).toContain("2 000,00 CZK");
|
||
expect(html).not.toContain("1 199,00 CZK");
|
||
});
|
||
});
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Offer PDF route (returns the HTML for the print view) */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
let app: ReturnType<typeof Fastify> | null = null;
|
||
let adminToken = "";
|
||
const createdQuotationIds: number[] = [];
|
||
const createdInvoiceIds: number[] = [];
|
||
|
||
beforeAll(async () => {
|
||
app = Fastify({ logger: false });
|
||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||
|
||
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 () => {
|
||
for (const id of createdQuotationIds)
|
||
await prisma.quotations.deleteMany({ where: { id } });
|
||
for (const id of createdInvoiceIds)
|
||
await prisma.invoices.deleteMany({ where: { id } });
|
||
if (app) await app.close();
|
||
});
|
||
|
||
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||
// Direct prisma insert (explicit number → no sequence consumed).
|
||
const quotation = await prisma.quotations.create({
|
||
data: {
|
||
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||
status: "active",
|
||
currency: "CZK",
|
||
language: "cs",
|
||
quotation_items: {
|
||
create: [
|
||
{
|
||
description: "Položka",
|
||
quantity: 2,
|
||
unit: "ks",
|
||
unit_price: 1000,
|
||
is_included_in_total: true,
|
||
position: 0,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
createdQuotationIds.push(quotation.id);
|
||
|
||
const res = await app!.inject({
|
||
method: "GET",
|
||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||
headers: { Authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const html = res.body;
|
||
expect(html).toContain("Celkem bez DPH");
|
||
expect(html).not.toContain(CS_NOTE);
|
||
expect(html).not.toContain("%DPH");
|
||
expect(html).not.toContain("Mezisoučet");
|
||
expect(html).not.toContain("Celkem k úhradě");
|
||
});
|
||
|
||
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||
const quotation = await prisma.quotations.create({
|
||
data: {
|
||
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||
status: "active",
|
||
currency: "CZK",
|
||
language: "cs",
|
||
quotation_items: {
|
||
create: [
|
||
{
|
||
description: "Půldenní sazba",
|
||
quantity: 1.5,
|
||
unit: "ks",
|
||
unit_price: 1000,
|
||
is_included_in_total: true,
|
||
position: 0,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
createdQuotationIds.push(quotation.id);
|
||
|
||
const res = await app!.inject({
|
||
method: "GET",
|
||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||
headers: { Authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const html = res.body;
|
||
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||
// decimals elsewhere (covered by the previous test's qty 2).
|
||
expect(html).toContain("1,50 / ks");
|
||
expect(html).not.toContain(">2 / ks<");
|
||
});
|
||
});
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||
const invoice = await createInvoice({
|
||
status: "draft",
|
||
currency: "CZK",
|
||
apply_vat: true,
|
||
vat_rate: 21,
|
||
items: [
|
||
{
|
||
description: "PDF-SHARED-MARKER",
|
||
quantity: 2,
|
||
unit_price: 1000,
|
||
vat_rate: 21,
|
||
},
|
||
],
|
||
});
|
||
createdInvoiceIds.push(invoice.id);
|
||
|
||
const res = await app!.inject({
|
||
method: "GET",
|
||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||
headers: { Authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const html = res.body;
|
||
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
|
||
expect(html).toContain("PDF-SHARED-MARKER");
|
||
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||
expect(html).toContain("2 000,00");
|
||
expect(html).toContain("Celkem k úhradě");
|
||
});
|
||
});
|