feat(orders): issued-order PDF export route

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 11:31:05 +02:00
parent 9f4b8a897a
commit 63ccf8fda7
3 changed files with 343 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import {
updateIssuedOrder, updateIssuedOrder,
deleteIssuedOrder, deleteIssuedOrder,
} from "../services/issued-orders.service"; } from "../services/issued-orders.service";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
afterEach(async () => { afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
@@ -217,3 +218,48 @@ describe("deleteIssuedOrder", () => {
expect(seq?.last_number).toBe(0); expect(seq?.last_number).toBe(0);
}); });
}); });
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)");
});
});

View File

@@ -0,0 +1,293 @@
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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(/(&nbsp;)/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 ? ` &nbsp;·&nbsp; ${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>");
}
},
);
}

View File

@@ -31,6 +31,7 @@ import sessionsRoutes from "./routes/admin/sessions";
import totpRoutes from "./routes/admin/totp"; import totpRoutes from "./routes/admin/totp";
import scopeTemplatesRoutes from "./routes/admin/scope-templates"; import scopeTemplatesRoutes from "./routes/admin/scope-templates";
import invoicesPdfRoutes from "./routes/admin/invoices-pdf"; import invoicesPdfRoutes from "./routes/admin/invoices-pdf";
import issuedOrdersPdfRoutes from "./routes/admin/issued-orders-pdf";
import offersPdfRoutes from "./routes/admin/offers-pdf"; import offersPdfRoutes from "./routes/admin/offers-pdf";
import ordersPdfRoutes from "./routes/admin/orders-pdf"; import ordersPdfRoutes from "./routes/admin/orders-pdf";
import projectFilesRoutes from "./routes/admin/project-files"; import projectFilesRoutes from "./routes/admin/project-files";
@@ -124,6 +125,9 @@ async function start() {
await app.register(issuedOrdersRoutes, { await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders", prefix: "/api/admin/issued-orders",
}); });
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" }); await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
await app.register(ordersRoutes, { prefix: "/api/admin/orders" }); await app.register(ordersRoutes, { prefix: "/api/admin/orders" });
await app.register(projectsRoutes, { prefix: "/api/admin/projects" }); await app.register(projectsRoutes, { prefix: "/api/admin/projects" });