feat(documents)!: unify offers and issued orders end to end
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>
This commit is contained in:
@@ -2,65 +2,23 @@ import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
/* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
@@ -202,7 +160,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_total: "Celkem",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
delivery_terms: "Dodací podmínky:",
|
||||
payment_terms: "Platební podmínky:",
|
||||
@@ -224,7 +181,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
col_unit_price: "Unit price",
|
||||
col_total: "Total",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
delivery_terms: "Delivery terms:",
|
||||
payment_terms: "Payment terms:",
|
||||
@@ -255,6 +211,17 @@ interface IssuedOrderPdfItem {
|
||||
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();
|
||||
}
|
||||
|
||||
export function renderIssuedOrderHtml(
|
||||
order: IssuedOrderPdfData,
|
||||
items: IssuedOrderPdfItem[],
|
||||
@@ -262,6 +229,7 @@ export function renderIssuedOrderHtml(
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: Lang,
|
||||
issuer: { name: string },
|
||||
sections: IssuedOrderPdfSection[] = [],
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const currency = order.currency || "CZK";
|
||||
@@ -311,8 +279,8 @@ export function renderIssuedOrderHtml(
|
||||
<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">${formatNum(unitPrice)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
<td class="right">${formatCurrency(unitPrice, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -344,12 +312,53 @@ export function renderIssuedOrderHtml(
|
||||
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
// ── Sections (scope) page — mirrors offers-pdf's .scope-page, but repeats
|
||||
// THIS template's red PO header (logo + heading + dates), not the offers'
|
||||
// monochrome one. The whole page is suppressed 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-pdf).
|
||||
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 a scope page holding nothing but the header.
|
||||
const visibleSections = sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
||||
);
|
||||
const hasSectionContent = visibleSections.length > 0;
|
||||
|
||||
let scopeHtml = "";
|
||||
if (hasSectionContent) {
|
||||
const scopeDates = `<div class="scope-dates"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span>${
|
||||
deliveryDateStr
|
||||
? ` <span class="sep">·</span> <span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span>`
|
||||
: ""
|
||||
}</div>`;
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `
|
||||
<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>
|
||||
${scopeDates}`;
|
||||
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>
|
||||
@@ -583,13 +592,6 @@ export function renderIssuedOrderHtml(
|
||||
border-bottom: 2.5pt solid #de3a3a;
|
||||
padding-bottom: 1mm;
|
||||
}
|
||||
.totals .currency-note {
|
||||
text-align: right;
|
||||
font-size: 8pt;
|
||||
color: #1a1a1a;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
/* Dodaci / platebni podminky (PO-specificke) */
|
||||
.terms {
|
||||
margin-top: 4mm;
|
||||
@@ -646,6 +648,49 @@ export function renderIssuedOrderHtml(
|
||||
.invoice-notes-content h3 { font-size: 16px; }
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
|
||||
/* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page,
|
||||
hlavicka se opakuje v cervenem stylu teto sablony */
|
||||
.scope-page {
|
||||
page-break-before: always;
|
||||
}
|
||||
.scope-dates {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin: 1mm 0 4mm 0;
|
||||
}
|
||||
.scope-dates .val { font-weight: 600; color: #1a1a1a; }
|
||||
.scope-dates .sep { color: #d0d0d0; }
|
||||
.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; }
|
||||
@@ -666,10 +711,11 @@ ${indentCSS}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.invoice-page {
|
||||
.invoice-page, .scope-page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 15mm;
|
||||
@@ -733,14 +779,15 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||
<!-- 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">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||
<span class="value">${formatCurrency(total, currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -757,6 +804,8 @@ ${indentCSS}
|
||||
</div><!-- /.invoice-footer -->
|
||||
</div><!-- /.invoice-page -->
|
||||
|
||||
${scopeHtml}
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -773,7 +822,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
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 } });
|
||||
@@ -783,10 +831,23 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
.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 },
|
||||
@@ -811,6 +872,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
sections,
|
||||
);
|
||||
|
||||
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
||||
|
||||
Reference in New Issue
Block a user