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

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { lineNet } from "../utils/money";
describe("lineNet (per-item discounted net)", () => {
it("returns qty × unit_price when there is no discount", () => {
expect(lineNet(2, 1000, 0)).toBe(2000);
});
it("applies a percentage discount to the line net", () => {
expect(lineNet(2, 1000, 10)).toBe(1800);
});
it("treats a 100% discount as zero", () => {
expect(lineNet(3, 500, 100)).toBe(0);
});
it("treats null/undefined/NaN discount as no discount", () => {
expect(lineNet(2, 1000, null)).toBe(2000);
expect(lineNet(2, 1000, undefined)).toBe(2000);
expect(lineNet(2, 1000, NaN)).toBe(2000);
});
});