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
+ };
+ }
+}