The dodavatele modal is now an exact mirror of the customers modal (minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme, ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md). Two migrations on sklad_suppliers: - structured street/city/postal_code/country replace the free-text address blob (best-effort split: street / PSC regex / city) - custom_fields LONGTEXT replaces contact_person/email/phone/notes; existing values are preserved as labeled custom fields The encode/decode of the custom_fields blob is shared with customers via the new utils/custom-fields.ts. The PO PDF supplier block renders the structured address + custom-field lines like the customer block; the supplier picker/detail selects and types follow. Legacy clients sending the dropped keys are silently stripped (tested). Suppliers list shows Ulice/Mesto instead of the dropped contact columns; search covers name/ico/city. BREAKING CHANGE: sklad_suppliers.address, contact_person, email, phone, notes columns dropped (data migrated); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
895 lines
28 KiB
TypeScript
895 lines
28 KiB
TypeScript
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 {
|
||
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>,
|
||
): 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}`;
|
||
|
||
cfData.forEach((cf, i) => {
|
||
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:Tahoma, 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); // 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: Tahoma, 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: Tahoma, 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 -->
|
||
<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>");
|
||
}
|
||
},
|
||
);
|
||
}
|