diff --git a/Fuchs.Tests/InvoiceSetPricingTests.cs b/Fuchs.Tests/InvoiceSetPricingTests.cs new file mode 100644 index 0000000..50d503d --- /dev/null +++ b/Fuchs.Tests/InvoiceSetPricingTests.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.Linq; +using Fuchs.intranet; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Contract tests for the set-pricing display modes — the agreed interplay +/// between the front-end (which picks the mode and groups items) and the +/// back-end PDF renderer (which honours ShowPrice / IsSetHeader). Set items use +/// type="set"; members carry setId == the set header's id. +/// +public class InvoiceSetPricingTests +{ + private static Dictionary SetHeader(string id, string title, string? total = null) => new() + { + ["type"] = "set", ["id"] = id, ["title"] = title, ["qty"] = "1", + ["total_net"] = total ?? "0", ["price_net"] = total ?? "0" + }; + + private static Dictionary Member(string setId, string title, string price, string total) => new() + { + ["setId"] = setId, ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total + }; + + private static Dictionary Standalone(string title, string price, string total) => new() + { + ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total + }; + + // A set (sum 1000) with two members, plus one standalone item (50). + private static List> Sample() => new() + { + SetHeader("10", "Bad-Komplettset", "1000.00"), + Member("10", "Waschbecken", "600.00", "600.00"), + Member("10", "Armatur", "400.00", "400.00"), + Standalone("Anfahrt", "50.00", "50.00") + }; + + // ── Mode parsing ──────────────────────────────────────────────────────── + [Theory] + [InlineData("itemprices", SetDisplayMode.ItemPrices)] + [InlineData("items", SetDisplayMode.ItemPrices)] + [InlineData("setonly", SetDisplayMode.SetOnly)] + [InlineData("setprice", SetDisplayMode.SetPrice)] + [InlineData("", SetDisplayMode.SetPrice)] + [InlineData("garbage", SetDisplayMode.SetPrice)] + public void ParseMode_Works(string raw, SetDisplayMode expected) + => Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw)); + + [Fact] + public void ModeFromInvoiceOptions_ReadsToken() + { + Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices")); + Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly")); + Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default + Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null)); + } + + [Fact] + public void ContainsSets_DetectsHeaderOrMember() + { + Assert.True(InvoiceSetPricing.ContainsSets(Sample())); + Assert.False(InvoiceSetPricing.ContainsSets(new List> { Standalone("X", "1", "1") })); + } + + // ── SetPrice (default): set priced, members blank ───────────────────────── + [Fact] + public void Build_SetPrice_SetLinePricedMembersBlank() + { + var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice); + + Assert.Equal(4, lines.Count); // header + 2 members + standalone + var header = lines[0]; + Assert.True(header.IsSetHeader); + Assert.True(header.ShowPrice); + Assert.Equal(1000.00m, header.TotalNet); + + Assert.False(lines[1].ShowPrice); // Waschbecken — no price + Assert.False(lines[2].ShowPrice); // Armatur — no price + Assert.Equal("Waschbecken", lines[1].Title); + + Assert.True(lines[3].ShowPrice); // standalone keeps price + Assert.Equal(50.00m, lines[3].TotalNet); + } + + // ── ItemPrices: members priced, set header blank ────────────────────────── + [Fact] + public void Build_ItemPrices_MembersPricedHeaderBlank() + { + var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices); + + Assert.Equal(4, lines.Count); + Assert.True(lines[0].IsSetHeader); + Assert.False(lines[0].ShowPrice); // set header is just a title now + Assert.True(lines[1].ShowPrice); + Assert.Equal(600.00m, lines[1].TotalNet); + Assert.True(lines[2].ShowPrice); + Assert.Equal(400.00m, lines[2].TotalNet); + Assert.True(lines[3].ShowPrice); // standalone + } + + // ── SetOnly: members removed ────────────────────────────────────────────── + [Fact] + public void Build_SetOnly_RemovesMembers() + { + var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetOnly); + + Assert.Equal(2, lines.Count); // set header + standalone only + Assert.True(lines[0].IsSetHeader); + Assert.True(lines[0].ShowPrice); + Assert.Equal(1000.00m, lines[0].TotalNet); + Assert.False(lines[1].IsSetHeader); + Assert.Equal("Anfahrt", lines[1].Title); + } + + // ── Set price falls back to sum of members when header total is 0 ───────── + [Fact] + public void Build_HeaderTotalZero_UsesSumOfMembers() + { + var items = new List> + { + SetHeader("7", "Set ohne Preis"), // total 0 + Member("7", "A", "120.00", "120.00"), + Member("7", "B", "80.00", "80.00") + }; + var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice); + Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80 + } + + // ── No sets: pass-through unchanged ─────────────────────────────────────── + [Fact] + public void Build_NoSets_PassThroughAllPriced() + { + var items = new List> + { + Standalone("A", "10.00", "10.00"), + Standalone("B", "20.00", "20.00") + }; + var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice); + Assert.Equal(2, lines.Count); + Assert.All(lines, l => Assert.True(l.ShowPrice)); + Assert.All(lines, l => Assert.False(l.IsSetHeader)); + } + + // ── Set price equals sum of member prices across modes (no double counting) ─ + [Fact] + public void SetPrice_EqualsSumOfMembers() + { + var setLine = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice).First(l => l.IsSetHeader); + decimal memberSum = Sample() + .Where(i => i.TryGetValue("setId", out var s) && s?.ToString() == "10") + .Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture)); + Assert.Equal(memberSum, setLine.TotalNet); + } +} diff --git a/Fuchs/Docs/INVOICE_SET_PRICING.md b/Fuchs/Docs/INVOICE_SET_PRICING.md new file mode 100644 index 0000000..9d84a77 --- /dev/null +++ b/Fuchs/Docs/INVOICE_SET_PRICING.md @@ -0,0 +1,63 @@ +# Invoice "Set" Pricing — Design & Front-/Back-end Contract + +Customer requirement: items declared as a **set** in `[dbo].[mfr__items]` +(`[Type] = 'set'`) should normally be shown as a single **set price** on the +invoice instead of being broken up into their member items and summed. + +Three display modes (switchable in the invoice editor): + +| Mode | Set line | Member items | Use as | +|---|---|---|---| +| **SetPrice** (default) | shown **with price** | shown **without price** | the new default | +| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour | +| **SetOnly** | shown **with price** | **removed** | compact | + +> **Totals are unaffected.** The invoice total is taken from the registration +> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered +> lines, so switching modes is purely presentational. The set price always +> equals the sum of its members (computed as a fallback when the set header +> carries no own price). + +## Back-end (implemented + unit-tested) + +- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation: + `SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with + `ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode + from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`. +- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with + `ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the + set header line is rendered emphasised. Invoices without sets pass through + unchanged. + +## Front-end contract (to wire in the invoice editor) + +The editor already knows the set structure (from `fds__prepInvoice`) and assembles +the `invc` JSON. To drive the modes it must, when sending the invoice: + +1. **Mode** — add a token to `inv.InvoiceOptions` (CSV, alongside `§13b`): + - `setmode:setprice` (default — may be omitted), `setmode:itemprices`, or `setmode:setonly`. + A 3-way switch in the editor sets this token. + +2. **Item flags** — in each service request's `items[]`: + - the **set header** item: `type: "set"`, `id: ""`, and `total_net` = + the set price (or `0` to let the back-end sum the members); + - each **member** item: `setId: ""` (matching the header's id). + - standalone items need no extra fields. + +That is the entire contract — the back-end does the rest. The editor's running +**total stays the member sum in every mode**, matching the registration balance. + +### Why the switch lives in the editor +Set grouping is only known where the request/item tree is rendered (front-end). +The back-end intentionally stays the single, tested authority for *how* a chosen +mode maps to printed lines, so the editor only needs to pick the mode and tag the +items — it does not re-implement the pricing rules. + +## Persistence note +Draft/preview PDFs render straight from the posted `invc` JSON, so the contract +works end-to-end for previews immediately. For a **finalised** invoice to re-render +in a chosen mode later, the per-item `type`/`setId` flags (and the `setmode` +option) must be persisted with the stored invoice items +(`fds__createInvoice_Details` / `fds__invoice_items`). `setmode` already persists +via `InvoiceOptions`; persisting the per-item set tags is a small SSDT + create-proc +change to make once the editor wiring is confirmed. diff --git a/Fuchs/code/FuchsPdf.cs b/Fuchs/code/FuchsPdf.cs index a02262c..0ddd3ed 100644 --- a/Fuchs/code/FuchsPdf.cs +++ b/Fuchs/code/FuchsPdf.cs @@ -473,25 +473,26 @@ public static class FuchsPdf hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left; } - // Data rows — from InvoiceItems + // Data rows — resolved through the set-display mode (see InvoiceSetPricing). + // For invoices without sets this passes items through unchanged; for sets it + // emits set header + members per the chosen mode, blanking price cells where + // a line should show no price. Totals come from the registration balance, so + // the mode is purely presentational. + var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions")); int pos = 1; - foreach (var itm in inv.InvoiceItems) + foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode)) { - string title = itm.nz("title", ""); - string desc = itm.nz("desc", ""); - string qty = itm.nz("qty", "1"); - ParseDec(itm.no("price_net", 0), out decimal priceNet); - ParseDec(itm.no("total_net", 0), out decimal totalNet); - var row = tbl.AddRow(); row.HeightRule = RowHeightRule.Auto; row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base"; var titleCell = row.Cells[1].AddParagraph(); - titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title); - if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"
{desc}
"); - row.Cells[2].AddParagraph(qty).Style = "TblCell_Base"; - row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base"; - row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum"; + titleCell.Style = "TblCell_RTitle"; + if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold); + else titleCell.AddText(line.Title); + if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"
{line.Desc}
"); + row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base"; + row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base"; + row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum"; row.Cells[2].Format.Alignment = ParagraphAlignment.Right; row.Cells[3].Format.Alignment = ParagraphAlignment.Right; row.Cells[4].Format.Alignment = ParagraphAlignment.Right; diff --git a/Fuchs/code/InvoiceSetPricing.cs b/Fuchs/code/InvoiceSetPricing.cs new file mode 100644 index 0000000..27876b9 --- /dev/null +++ b/Fuchs/code/InvoiceSetPricing.cs @@ -0,0 +1,172 @@ +using static OCORE.commons; +using static OCORE.OCORE_dictionaries; + +namespace Fuchs.intranet; + +/// +/// How a "set" (mfr__items with Type = "set") and its member items are +/// priced/displayed on the invoice. This is the shared contract between the +/// front-end (which lets the user pick the mode and groups the items) and the +/// back-end PDF renderer. Unit-tested in InvoiceSetPricingTests. +/// +public enum SetDisplayMode +{ + /// Default: show the set as one priced line; member items listed without price. + SetPrice, + /// Show each member item with its own price; the set line is a header without price. + ItemPrices, + /// Show only the set as one priced line; member items are removed entirely. + SetOnly +} + +/// A resolved invoice display line (after applying the set display mode). +public sealed class InvoiceSetLine +{ + public string Title { get; init; } = ""; + public string Desc { get; init; } = ""; + public string Qty { get; init; } = "1"; + public decimal PriceNet { get; init; } + public decimal TotalNet { get; init; } + /// When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode). + public bool ShowPrice { get; init; } = true; + /// True for the set header line (rendered emphasised). + public bool IsSetHeader { get; init; } +} + +/// +/// Transforms raw invoice line items into display lines according to a +/// . A "set" item is identified by +/// type == "set"; its members carry setId equal to the set +/// header's id. Items that belong to no set pass through unchanged. +/// +/// The invoice total is taken from the registration balance, not from these +/// lines, so switching modes is purely presentational and never changes the +/// invoice sum — set price always equals the sum of its members. +/// +public static class InvoiceSetPricing +{ + public static SetDisplayMode ParseMode(string? raw) => + (raw ?? "").Trim().ToLowerInvariant() switch + { + "itemprices" or "items" or "item" => SetDisplayMode.ItemPrices, + "setonly" or "set_only" => SetDisplayMode.SetOnly, + _ => SetDisplayMode.SetPrice + }; + + /// Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices". + public static SetDisplayMode ModeFromInvoiceOptions(string? invoiceOptions) + { + foreach (var token in (invoiceOptions ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (token.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase)) + return ParseMode(token["setmode:".Length..]); + } + return SetDisplayMode.SetPrice; + } + + /// True if any item in the list is a set header or set member. + public static bool ContainsSets(IEnumerable> items) => + items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i))); + + /// + /// Produces the ordered display lines for the given items and mode. + /// Standalone items are always shown with their price. + /// + public static List Build(IReadOnlyList> items, SetDisplayMode mode) + { + var result = new List(); + if (items.Count == 0) return result; + + // Group members by their set id. + var membersBySet = items + .Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i))) + .GroupBy(SetIdOf) + .ToDictionary(g => g.Key!, g => g.ToList()); + + foreach (var item in items) + { + if (IsSetHeader(item)) + { + string setId = HeaderIdOf(item); + var members = membersBySet.TryGetValue(setId, out var m) ? m : new List>(); + decimal setTot = HeaderTotal(item, members); + + switch (mode) + { + case SetDisplayMode.SetPrice: + result.Add(HeaderLine(item, setTot, showPrice: true)); + foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false)); + break; + case SetDisplayMode.ItemPrices: + result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count) + foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true)); + break; + case SetDisplayMode.SetOnly: + result.Add(HeaderLine(item, setTot, showPrice: true)); + break; + } + } + else if (!string.IsNullOrEmpty(SetIdOf(item))) + { + // Member — already emitted next to its header above; skip standalone emission. + } + else + { + result.Add(MemberLine(item, showPrice: true)); // standalone item + } + } + return result; + } + + // ── helpers ────────────────────────────────────────────────────────────── + private static bool IsSetHeader(Dictionary i) => + string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase); + + private static string? SetIdOf(Dictionary i) + { + string s = i.nz("setId", ""); + return string.IsNullOrEmpty(s) ? null : s; + } + + private static string HeaderIdOf(Dictionary i) + { + string s = i.nz("setId", ""); + return string.IsNullOrEmpty(s) ? i.nz("id", "") : s; + } + + private static decimal HeaderTotal(Dictionary header, List> members) + { + FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot); + if (headerTot != 0) return headerTot; + decimal sum = 0; + foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; } + return sum; + } + + private static InvoiceSetLine HeaderLine(Dictionary i, decimal setTotal, bool showPrice) => new() + { + Title = i.nz("title", ""), + Desc = i.nz("desc", ""), + Qty = i.nz("qty", "1"), + PriceNet = setTotal, + TotalNet = setTotal, + ShowPrice = showPrice, + IsSetHeader = true + }; + + private static InvoiceSetLine MemberLine(Dictionary i, bool showPrice) + { + FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price); + FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total); + return new InvoiceSetLine + { + Title = i.nz("title", ""), + Desc = i.nz("desc", ""), + Qty = i.nz("qty", "1"), + PriceNet = price, + TotalNet = total, + ShowPrice = showPrice, + IsSetHeader = false + }; + } +}