Key the set-price menu modes on the set instead of the service-request block (ADR 0019)

"Set mit Preis" / "Nur Set mit Preis" collapsed every block into an inserted
"Gesamtumfang pauschal" row, nulled/removed all lines and cleared the user-defined
set structure. Both modes now convert every still-unconverted top-level set:
the set line takes the sum of its subtree, its members render without a price
(setprice) or are removed from itm + items (setonly); lines outside a set stay
individually priced and several sets stay separate lines. Nested sets are priced
once via their top-level set. One conversion primitive (ConvertSetHeader) now
backs all three triggers; item.setprice is a no-op on an already-converted set
(a repeated delta used to reset the set price to 0). The header's price_net is
mirrored too (flat PDF path unit price), and Build treats a null total_net as
"no price" like the flat path. The editor checks for a set before offering the
modes and the menu entry stays available for sets added later.

ADR 0009 is superseded by 0019; INVOICE_SET_PRICING.md rewritten accordingly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 22:19:01 +02:00
co-authored by Claude Fable 5.1
parent 22d2ef9c1a
commit 5fa7a2b50f
16 changed files with 678 additions and 447 deletions
+5 -3
View File
@@ -778,9 +778,11 @@ public static class FuchsPdf
// Blocks without any set items are unaffected and always render flat.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
// Blocks that carry a set (a line pointing at a set header via setId) render through Build,
// which blanks/drops the members of a converted set; blocks without any set render flat —
// nulled prices map to blank cells either way (see MapItemToLine). Since ADR 0019 the menu
// modes convert per set (not per block) and leave non-set lines individually priced, so both
// paths print exactly what the editor shows.
var blockLines = inv.InvoiceBlocks
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
+9 -5
View File
@@ -97,10 +97,10 @@ public static class InvoiceSetPricing
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
/// True if the block carries a set (a line points at a set header via <c>setId</c>) — the only
/// case that routes through <see cref="Build"/>. Since ADR 0019 every set-price operation — the
/// inline set-item switch and both menu modes — preserves that membership, so this is what
/// renders their result too; a block of lines that belong to no set renders flat.
/// </summary>
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
@@ -227,6 +227,10 @@ public static class InvoiceSetPricing
{
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
// A JSON-null total_net is "no price" (ADR 0009/0019) — an empty cell, never 0,00 € — exactly
// as the flat path (FuchsPdf.MapItemToLine) treats it. Matters for a price-nulled member whose
// set was later hand-edited back to 0 (and so reads as unconverted again).
bool hasPrice = !i.TryGetValue("total_net", out var tv) || tv != null;
return new InvoiceSetLine
{
Id = i.nz("id", ""),
@@ -235,7 +239,7 @@ public static class InvoiceSetPricing
Qty = i.nz("qty", "1"),
PriceNet = price,
TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
ShowPrice = showPrice && hasPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false,
Level = level