Invoice set pricing: 3 display modes (backend contract + PDF + tests)

Sets (mfr__items Type='set') can be shown three ways on the invoice:
- SetPrice (default): set line priced, member items shown without price
- ItemPrices: member items priced, set line as a heading without price
- SetOnly: only the set line (priced), members removed

- InvoiceSetPricing (new): the authoritative, unit-tested transformation
  (SetDisplayMode + Build) that both sides agree on; set price always equals the
  sum of members. Mode is read from InvoiceOptions ("setmode:<mode>").
- FuchsPdf.ApplyInvoice renders through it: lines flagged ShowPrice=false print
  blank price/total cells; set headers are emphasised. Invoices without sets are
  unchanged. Totals come from the registration balance, so modes are purely
  presentational and never change the sum.
- InvoiceSetPricingTests (+14): all three modes, set-price = member sum, header
  total fallback, no-set pass-through, option parsing.
- Docs/INVOICE_SET_PRICING.md documents the front-end contract (the editor sets
  the mode token + tags set header/member items); the back-end does the rest.

Front-end editor wiring is specified in the doc but intentionally not shipped
blind (cannot validate the running editor here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:42:44 +02:00
parent 2c17171e77
commit c358fdbdb2
4 changed files with 406 additions and 13 deletions
+14 -13
View File
@@ -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($"<div>{desc}</div>");
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($"<div>{line.Desc}</div>");
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;
+172
View File
@@ -0,0 +1,172 @@
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.intranet;
/// <summary>
/// How a "set" (mfr__items with <c>Type = "set"</c>) 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 <c>InvoiceSetPricingTests</c>.
/// </summary>
public enum SetDisplayMode
{
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
SetPrice,
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
ItemPrices,
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
SetOnly
}
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
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; }
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
public bool ShowPrice { get; init; } = true;
/// <summary>True for the set header line (rendered emphasised).</summary>
public bool IsSetHeader { get; init; }
}
/// <summary>
/// Transforms raw invoice line items into display lines according to a
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
/// header's <c>id</c>. 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.
/// </summary>
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
};
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
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;
}
/// <summary>True if any item in the list is a set header or set member.</summary>
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// Produces the ordered display lines for the given items and mode.
/// Standalone items are always shown with their price.
/// </summary>
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
{
var result = new List<InvoiceSetLine>();
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<Dictionary<string, object?>>();
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<string, object?> i) =>
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
private static string? SetIdOf(Dictionary<string, object?> i)
{
string s = i.nz("setId", "");
return string.IsNullOrEmpty(s) ? null : s;
}
private static string HeaderIdOf(Dictionary<string, object?> i)
{
string s = i.nz("setId", "");
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
}
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> 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<string, object?> 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<string, object?> 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
};
}
}