feat(vat)!: remove VAT entirely from offers, orders and issued orders
Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.
- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
{total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
removed from OfferDetail/OrderDetail/IssuedOrderDetail/
OrderConfirmationModal/ReceivedOrders manual-create; list footers read
'Celkem bez DPH'; invoice-from-order prefill now takes the company default
VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
exact cs+en note text and VAT-free layout on all three PDFs
Invoices and received invoices keep their VAT handling unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,16 +20,14 @@ const OrderPdfItemSchema = z.object({
|
||||
unit: z.string().max(255),
|
||||
unit_price: z.number().min(0).finite(),
|
||||
is_included_in_total: z.boolean().optional(),
|
||||
vat_rate: z.number().min(0).max(100).finite(),
|
||||
});
|
||||
|
||||
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
||||
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
|
||||
// handler no longer reads them as untyped passthrough keys.
|
||||
// `items` is strictly validated; `lang` is typed explicitly so the handler no
|
||||
// longer reads it as an untyped passthrough key.
|
||||
const OrderPdfBodySchema = z.looseObject({
|
||||
items: z.array(OrderPdfItemSchema).optional(),
|
||||
lang: z.string().max(10).optional(),
|
||||
applyVat: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
@@ -193,15 +191,11 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
vat_notice:
|
||||
"Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.",
|
||||
notes: "Poznámky",
|
||||
issued_by: "Vystavil:",
|
||||
received_by: "Převzal:",
|
||||
@@ -222,15 +216,11 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
amounts_in: "Amounts are in",
|
||||
vat_notice:
|
||||
"Prices are exclusive of VAT. VAT will be charged according to applicable regulations.",
|
||||
notes: "Notes",
|
||||
issued_by: "Issued by:",
|
||||
received_by: "Received by:",
|
||||
@@ -240,212 +230,119 @@ const translations: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
/* ── Template ────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
export interface OrderConfirmationPdfItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
interface OrderConfirmationPdfData {
|
||||
order_number: string | null;
|
||||
customer_order_number: string | null;
|
||||
created_at: Date | string | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
// Not a real orders column today — read defensively (PHP-era data carried it).
|
||||
payment_method?: string | null;
|
||||
customers?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
|
||||
* are NET only: no VAT columns or VAT summary, the grand total is labeled
|
||||
* "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT
|
||||
* notice. Exported for tests.
|
||||
*/
|
||||
export function renderOrderConfirmationHtml(
|
||||
order: OrderConfirmationPdfData,
|
||||
items: OrderConfirmationPdfItem[],
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: "cs" | "en",
|
||||
userName: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const currency = order.currency || "CZK";
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// NET-only total over the included lines — no VAT math anywhere.
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) total += item.quantity * item.unit_price;
|
||||
}
|
||||
total = Math.round(total * 100) / 100;
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
// Without "Uplatnit DPH" the VAT columns are dropped entirely
|
||||
// (the header does the same) instead of printing 0% / 0.00.
|
||||
const vatCells = applyVat
|
||||
? `
|
||||
<td class="center">${Math.floor(item.vat_rate)}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>`
|
||||
: "";
|
||||
return `<tr>
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(item.unit_price)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod =
|
||||
String((order as Record<string, unknown>).payment_method || "") ||
|
||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||
const paymentMethod =
|
||||
String(order.payment_method || "") ||
|
||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</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`;
|
||||
}
|
||||
// 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 html = `<!DOCTYPE html>
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -676,6 +573,12 @@ export default async function ordersPdfRoutes(
|
||||
color: #1a1a1a;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
.totals .vat-note {
|
||||
text-align: right;
|
||||
font-size: 8pt;
|
||||
color: #646464;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
|
||||
/* Vystavil */
|
||||
.issued-by {
|
||||
@@ -693,47 +596,6 @@ export default async function ordersPdfRoutes(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* DPH rekapitulace + QR */
|
||||
.recap-section {
|
||||
display: flex;
|
||||
gap: 5mm;
|
||||
align-items: flex-start;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.recap-section .qr {
|
||||
flex-shrink: 0;
|
||||
width: 28mm;
|
||||
}
|
||||
.recap-section .qr img,
|
||||
.recap-section .qr svg { width: 28mm; height: 28mm; }
|
||||
|
||||
.recap-section table {
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
flex: 1;
|
||||
}
|
||||
.recap-section table th {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #ccc;
|
||||
}
|
||||
.recap-section table td {
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #eee;
|
||||
}
|
||||
.recap-section table td.center { text-align: center; }
|
||||
.recap-section table td.cnb-rate {
|
||||
font-size: 8pt;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
border-bottom: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Prevzal / razitko */
|
||||
.footer-row {
|
||||
display: flex;
|
||||
@@ -855,17 +717,10 @@ ${indentCSS}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</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:10%">${escapeHtml(t.col_price)}</th>${
|
||||
applyVat
|
||||
? `
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||
: ""
|
||||
}
|
||||
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -873,24 +728,15 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">${
|
||||
applyVat
|
||||
? `
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="totals">
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
<div class="vat-note">${escapeHtml(t.vat_notice)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -915,9 +761,96 @@ ${indentCSS}
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
|
||||
let items: OrderConfirmationPdfItem[];
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
}));
|
||||
}
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
items,
|
||||
settings,
|
||||
lang,
|
||||
userName,
|
||||
);
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
|
||||
Reference in New Issue
Block a user