feat(orders): issued-order PDF export route
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
293
src/routes/admin/issued-orders-pdf.ts
Normal file
293
src/routes/admin/issued-orders-pdf.ts
Normal 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, "&")
|
||||||
|
.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>");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user