feat(documents): per-item Sleva (% discount) on offers + invoices

Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

- DB: discount Decimal(5,2) DEFAULT 0 on quotation_items + invoice_items
  (migration 20260613195833_add_item_discount).
- Totals/VAT: new shared lineNet() (qty × price × (1 − discount/100)); offers'
  NET total and invoices' subtotal AND VAT now compute on the discounted net
  (every getBase/enrichQuotation/stats path). Backward compatible: discount 0
  is identical to before.
- Schemas: discount = numberInRange(0,100), default 0; persisted on
  create/update (+ offer duplicate).
- Detail editors: editable "Sleva %" column — offers via DocumentItemsEditor's
  new opt-in showDiscount prop (issued orders unaffected); invoices in their own
  editor. Spinners hidden + 7rem column so "100" is fully visible. Read-only
  invoice view shows the column when any line has a discount.
- PDFs (offers + invoices): conditional "Sleva"/"Discount" column rendered only
  when an item has a discount; line/total/VAT always use the discounted net.

Tests: +16 (money, totals incl. VAT-on-discounted-net, schema range, PDF
column present/absent). Full suite 665 pass, tsc -b clean, lint 0. Editor
verified live via Playwright. Design spec in docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 20:46:44 +02:00
parent 3787cc4dd0
commit 719d1f2659
21 changed files with 554 additions and 33 deletions

View File

@@ -7,6 +7,7 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money";
import {
formatDate,
formatNum,
@@ -161,6 +162,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_discount: "Sleva",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
@@ -208,6 +210,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_discount: "Discount",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
@@ -312,6 +315,8 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
// Sleva column is rendered only when at least one item has a discount.
const hasDiscount = items.some((it) => Number(it.discount) > 0);
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
@@ -362,7 +367,11 @@ export default async function invoicesPdfRoutes(
let subtotal = 0;
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
const lineSubtotal = lineNet(
Number(item.quantity),
Number(item.unit_price),
Number(item.discount),
);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
@@ -489,11 +498,16 @@ export default async function invoicesPdfRoutes(
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const disc = Number(item.discount) || 0;
const lineSubtotal = lineNet(qty, unitPrice, disc);
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
const discCell = hasDiscount
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)}&nbsp;%` : "—"}</td>`
: "";
const subDesc = item.item_description || "";
return `<tr>
@@ -501,6 +515,7 @@ export default async function invoicesPdfRoutes(
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
${discCell}
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
@@ -1052,9 +1067,10 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th style="width:${hasDiscount ? 29 : 36}%">${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>
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>