Files
app/src/routes/admin/issued-orders-pdf.ts
BOHA 237ebf3ef8 feat(issued-orders): make line items optional (issue by sections alone)
Items are no longer required on issued orders — an order can be issued
purely from its rich-text sections (Obsah).

- DocumentItemsEditor: opt-in allowEmpty prop lets the list go to zero
  rows (default false keeps the offers/invoices at-least-one-item rule).
- IssuedOrderDetail: allowEmpty on the editor; a saved order with no
  items reopens empty (not a blank starter row); submit guard now
  requires at least one item OR a non-empty section, not an item.
- PDF: with no items the billing heading, items table and total row are
  omitted entirely — a sections-only order shows only its Obsah.
- Service: finalize (draft->sent) and create-as-non-draft reject a
  completely blank order (no items AND no sections) with a Czech 400
  (empty_document); drafts may still be saved empty as WIP.
- Tests: sections-only finalize + blank-order rejection + sections-only
  PDF render; existing finalize fixtures given minimal content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:36:35 +02:00

911 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
/* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record<string, unknown> | null,
isCompany: boolean,
tObj: Record<string, string>,
selectedCustomFields?: number[],
): AddressResult {
if (!entity) return { name: "", lines: [] };
const nameKey = isCompany ? "company_name" : "name";
const name = String(entity[nameKey] || "");
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: "name",
CompanyName: "company_name",
Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
};
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || "", entity.postal_code || ""]
.filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id)
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder!.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
/**
* Address block for the sklad_suppliers counterparty — full customer model
* (structured street/city/postal_code/country + custom_fields JSON; the old
* dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
* the supplier's ico/dic columns, prefixed with the same translated labels
* the customer block uses; custom fields render as appended lines in array
* order (suppliers have no PDF field-order picker).
*/
function buildSupplierLines(
supplier: Record<string, unknown> | null,
tObj: Record<string, string>,
): AddressResult {
if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || "");
const lines: string[] = [];
if (supplier.street) lines.push(String(supplier.street));
const cityLine = [supplier.postal_code, supplier.city]
.filter(Boolean)
.map(String)
.join(" ")
.trim();
if (cityLine) lines.push(cityLine);
if (supplier.country) lines.push(String(supplier.country));
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
// Custom fields ("Vlastní pole") — same blob format as customers; malformed
// JSON degrades to no extra lines rather than failing the PDF.
if (supplier.custom_fields) {
let parsed: unknown;
try {
parsed =
typeof supplier.custom_fields === "string"
? JSON.parse(supplier.custom_fields)
: supplier.custom_fields;
} catch {
parsed = null;
}
const fields =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? ((parsed as Record<string, unknown>).fields as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: Array.isArray(parsed)
? (parsed as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: [];
for (const f of fields ?? []) {
const value = (f?.value || "").trim();
if (!value) continue;
const label = (f?.name || "").trim();
lines.push(
f?.showLabel !== false && label ? `${label}: ${value}` : value,
);
}
}
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en";
const translations: Record<Lang, Record<string, string>> = {
cs: {
title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the
// selected sklad_suppliers record is the supplier (Dodavatel).
supplier: "Dodavatel",
buyer: "Odběratel",
issue_date: "Datum vystavení:",
delivery_date: "Požadované dodání:",
billing: "Objednáváme si u Vás:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_total: "Celkem",
total_no_vat: "Celkem bez DPH",
issued_by: "Vystavil:",
ico: "IČ: ",
dic: "DIČ: ",
},
en: {
title: "PURCHASE ORDER",
heading: "PURCHASE ORDER No.",
supplier: "Supplier",
buyer: "Buyer",
issue_date: "Issue date:",
delivery_date: "Requested delivery:",
billing: "We order from you:",
col_no: "No.",
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_total: "Total",
total_no_vat: "Total excl. VAT",
issued_by: "Issued by:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
},
};
interface IssuedOrderPdfData {
po_number: string | null;
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
// Editable heading above the items table; null → t.billing default.
order_text?: string | null;
}
interface IssuedOrderPdfItem {
description: string | null;
item_description: string | null;
quantity: unknown;
unit: string | null;
unit_price: unknown;
}
export interface IssuedOrderPdfSection {
title: string | null;
title_cz: string | null;
content: string | null;
}
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
function stripTags(html: string | null | undefined): string {
return (html || "").replace(/<[^>]*>/g, "").trim();
}
/**
* Repeating per-page footer for the issued-order PDF, rendered by Puppeteer
* in the bottom page margin (the only true repeating footer Chromium
* supports): "Vystavil: <name>" left, "Strana X z Y" / "Page X of Y"
* (by document language) centered. Styles must stay inline and the
* font-size explicit — Chromium defaults footer text to 0.
*/
export function buildIssuedOrderPdfFooter(
lang: Lang,
issuerName: string,
): string {
const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
</div>`;
}
/**
* Repeating per-page header for the issued-order PDF — the unified
* red-accent family header (shared with invoices): logo left, red
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
* own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildIssuedOrderPdfHeader(
lang: Lang,
logoDataUri: string,
poNumber: string,
): string {
const t = translations[lang];
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
// Kept for signature stability across the many call sites: the issuer no
// longer renders in the BODY — it prints in the Puppeteer footer template
// (buildIssuedOrderPdfFooter) on every page.
_issuer: { name: string },
sections: IssuedOrderPdfSection[] = [],
): string {
const t = translations[lang];
const currency = order.currency || "CZK";
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
const logoDataUri = logoDataUriFromSettings(settings);
const logoImg = logoDataUri
? `<img src="${logoDataUri}" class="logo" />`
: "";
// PO direction: our company (settings) = Odběratel (buyer);
// the sklad_suppliers record = Dodavatel (supplier).
const buyer = buildAddressLines(
settings,
true,
t,
parseSelectedCustomFields(
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
),
); // company → Odběratel
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const supplierLinesHtml = supplierAddr.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Items — NET only. A purchase order is not a tax document: no VAT columns,
// no VAT math (same as the service computeIssuedOrderTotals).
let total = 0;
const itemsHtml = items
.map((it, i) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const lineTotal = qty * unitPrice;
total += lineTotal;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${
it.item_description
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
: ""
}`;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatCurrency(unitPrice, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
})
.join("");
total = Math.round(total * 100) / 100;
const issueDateStr = formatDate(order.order_date);
const deliveryDateStr = formatDate(order.delivery_date);
// Quill indent CSS
const indentCSS = buildQuillIndentCss();
// ── Sections ("Obsah") — flow INLINE after the items/totals block (user
// decision: no separate page, no repeated header). Each section keeps
// break-inside: avoid; long content paginates naturally and the Puppeteer
// footer template stamps every page. Suppressed entirely when every
// section is empty (tag-stripped check, so an empty-Quill "<p><br></p>"
// doesn't count). cs prefers the Czech title when present; en (or a
// missing Czech title) falls back to the EN title (same rule as offers).
const resolveSectionTitle = (s: IssuedOrderPdfSection): string =>
lang === "cs" && (s.title_cz || "").trim()
? s.title_cz || ""
: s.title || "";
// Visibility must use the SAME per-language title rule as the render loop —
// otherwise a section with only the other language's title would count as
// content and produce an empty sections block.
const visibleSections = sections.filter(
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
);
const hasSectionContent = visibleSections.length > 0;
let scopeHtml = "";
if (hasSectionContent) {
scopeHtml += '<div class="scope-sections">';
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
scopeHtml += '<div class="scope-section">';
if (title.trim())
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(t.heading)} ${poNumber}</title>
<style>
@page {
size: A4;
/* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the invoice PDF. */
margin: 32mm 12mm 18mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 186mm;
}
/* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
footerTemplate (bottom page margin), NOT in the body — the old flex
min-height pinning is gone with it. */
.accent { color: #de3a3a; }
/* ── Hlavicka ── */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1mm;
padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a;
}
.invoice-header .left {
display: flex;
align-items: center;
gap: 3mm;
}
.logo-header { text-align: left; }
.company-title {
font-size: 12pt;
font-weight: 700;
}
.invoice-title {
font-size: 13pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
letter-spacing: 0.03em;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* ── Adresy ── */
.header-grid {
border: 0.5pt solid #d0d0d0;
border-collapse: collapse;
width: 100%;
margin-bottom: 1mm;
}
.header-grid td {
padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0;
vertical-align: top;
width: 50%;
}
.header-grid td.addr-supplier {
background: #f5f5f5;
}
.header-grid td.details-bank {
background: #f5f5f5;
}
.address-label {
font-size: 8pt;
font-weight: 700;
color: #de3a3a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1mm;
}
.address-name {
font-size: 10pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 1mm;
}
.address-line {
font-size: 9pt;
color: #444;
line-height: 1.5;
}
/* ── Detaily (banka + datumy) — inside header-grid ── */
.info-row {
display: flex;
align-items: baseline;
font-size: 9pt;
padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-row .lbl {
color: #666;
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
margin-right: 3mm;
}
.info-row .val {
font-weight: 600;
color: #1a1a1a;
text-align: right;
margin-left: auto;
}
/* VS/KS blok */
.vs-block {
font-size: 9pt;
line-height: 1.4;
padding-top: 2mm;
}
/* ── Polozky ── */
.billing-label {
font-weight: 700;
color: #1a1a1a;
font-size: 10pt;
padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8.5pt;
font-weight: 600;
color: #646464;
padding: 4px 4px;
text-align: left;
border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 9pt;
}
table.items tbody td.desc {
font-size: 9pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.desc .item-sub {
font-weight: 400;
font-size: 8.5pt;
color: #666;
margin-top: 0.5mm;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 9.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 10.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
/* Upozorneni */
.notice {
font-size: 8pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
stranu. */
.scope-sections {
margin-top: 6mm;
}
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm;
}
.section-content {
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
}
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
.section-content h2 { font-size: 18px; }
.section-content h3 { font-size: 16px; }
.section-content h4 { font-size: 15px; }
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
<div class="address-label">${escapeHtml(t.buyer)}</div>
<div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml}
</td>
<td class="addr-supplier">
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
${supplierLinesHtml}
</td>
</tr>
<tr>
<td class="details-bank">
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span></div>
${deliveryDateStr ? `<div class="info-row"><span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span></div>` : ""}
</td>
<td></td>
</tr>
</table>
<!-- Polozky — an order may be issued by sections alone; with no items the
heading, items table and total row are omitted entirely. -->
${
items.length > 0
? `<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval;
mena je soucasti castky pres formatCurrency, takze zadna "Částky jsou
uvedeny v …" poznamka) -->
<div class="totals-wrapper">
<div class="totals">
<div class="grand">
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>
</div>
</div>`
: ""
}
${scopeHtml}
</div><!-- /.invoice-content -->
</div><!-- /.invoice-page -->
</body>
</html>`;
}
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
fastify.get<{ Params: { id: string } }>(
"/:id",
// Mirror invoices-pdf (invoices.view): view-level access serves BOTH the
// user-facing Export PDF and the ?save=1 NAS archive. There is no separate
// orders.export permission (it was dead — gated nothing — and was removed);
// orders.view is the single gate for both paths.
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
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>");
}
// Language comes from the document's own column; ?lang= is an
// explicit override only (mirrors the /:id/file fallback semantics).
const lang: Lang =
query.lang === "en" || query.lang === "cs"
? query.lang
: order.language === "en"
? "en"
: "cs";
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
// id tiebreak: position can repeat, keep the order deterministic.
const sections = await prisma.issued_order_sections.findMany({
where: { issued_order_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
// Footer issuer = the logged-in user (there is no user FK on the
// order). The route runs under requirePermission("orders.view") so
// authData is always present here. Mirrors orders-pdf.ts userName.
const issuer = {
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
};
const html = renderIssuedOrderHtml(
order,
items,
supplier,
settings,
lang,
issuer,
sections,
);
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
// invoices-pdf). A draft has no po_number → the guard skips it.
const saveMode = query.save === "1";
if (saveMode && nasOrdersManager.isConfigured() && order.po_number) {
const baseDate = order.order_date
? new Date(order.order_date)
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
nasOrdersManager.saveIssuedPdf(
order.po_number,
baseDate.getFullYear(),
baseDate.getMonth() + 1,
pdfBuffer,
);
return success(reply, null, 200, "PDF uloženo");
}
const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
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>");
}
},
);
}