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
+157 -46
View File
@@ -413,105 +413,216 @@ public class InvoiceDraftServiceTests
Assert.Equal(125m, s2.Sums.TotalNet);
}
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
private static JObject BlocksPayload() => JObject.Parse(@"{
// ── Set-price menu modes (ADR 0019): keyed on the SET, never on the block ──
// Two service-request blocks, each with one set, plus a standalone item outside any set:
// block 1: set '10' { '11' 60 net, '12' 40 net (service) } + '13' 25 net, no set
// block 2: set '20' { '21' 30 net }
// Expected for both modes: every set gets the sum of its own positions, several sets stay
// several separate lines, and '13' keeps its individual price and stays listed.
private static JObject SetsAndLooseItemsPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[
{'Id':'1','text':'Auftrag A','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%','SetItmId':'10'},
{'id':'13','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'},
{'id':'13','type':'material','total_net':25,'vat':'19%'}]},
{'Id':'2','text':'Auftrag B','itm':[
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
{'id':'20','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'},
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'}],
'items':[{'id':'20','type':'set','total_net':0},
{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
}");
[Fact]
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
public void ApplyPatch_SetPrice_PricesEverySet_BlanksItsMembers_LeavesNonSetItemsPriced()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
Assert.Equal(155m, s.Sums.TotalNet); // 100 (set A) + 25 (loose) + 30 (set B)
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
var setA = (JObject)linesA[0];
Assert.Equal("set", (string)setA["typ"]!);
Assert.Equal("bset_1", (string)setA["id"]!);
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
// No row is inserted or dropped — the existing set line carries the price now.
Assert.Equal(new[] { "10", "11", "12", "13" }, LineOrder(s2));
var setA = LineOf(s2, "10");
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // its own members only (60 + 40), NOT the block (125)
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
Assert.Equal(JTokenType.Null, LineOf(s2, "11")["vt"]!.Type); // member: null (empty cell), not 0
Assert.Equal(JTokenType.Null, LineOf(s2, "12")["vt"]!.Type);
// items contract mirrors it (PDF reads total_net from here).
// The item outside the set keeps its own price and is still listed individually.
Assert.Equal(25m, LineOf(s2, "13")["vt"]!.Value<decimal>());
Assert.Equal(4.75m, LineOf(s2, "13")["vv"]!.Value<decimal>());
// The second set is a separate priced line, not merged with the first.
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "21", block: 1)["vt"]!.Type);
// items contract mirrors it (the PDF reads total_net from here).
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>());
Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["total_net"]!.Value<decimal>());
Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["price_net"]!.Value<decimal>()); // unit price too (flat PDF path)
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
Assert.Equal(25m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "13")["total_net"]!.Value<decimal>());
// second block converted too; total conserved.
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
Assert.Equal(130m, s2.Sums.TotalNet);
Assert.Equal(24.7m, s2.Sums.TotalVat);
// Sets + loose items make up the unchanged invoice total.
Assert.Equal(155m, s2.Sums.TotalNet);
Assert.Equal(29.45m, s2.Sums.TotalVat);
}
[Fact]
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
public void ApplyPatch_SetOnly_PricesEverySet_RemovesItsMembers_KeepsNonSetItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
Assert.Single(linesA); // members removed, only the set row remains
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
// Set members gone; the set line and the item that belongs to no set remain.
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>());
Assert.Equal(25m, LineOf(s2!, "13")["vt"]!.Value<decimal>());
Assert.Equal(new[] { "10", "13" },
((JArray)((JObject)s2!.Req[0])["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray());
// The second set stays a separate line of its own.
Assert.Equal(new[] { "20" }, LineOrder(s2, block: 1));
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(155m, s2.Sums.TotalNet); // total unchanged
}
[Fact]
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
public void ApplyPatch_SetOnly_AlreadyConvertedSet_KeepsItsPrice_AndStillRemovesMembers()
{
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var conv = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" }); // inline switch first
Assert.Equal(100m, LineOf(conv!, "10")["vt"]!.Value<decimal>());
// A repeated conversion is a no-op — it must never re-sum the already-nulled members to 0.
var again = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" });
Assert.Equal(1, again!.Version);
Assert.Equal(100m, LineOf(again, "10")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>()); // not re-summed to 0 from nulled members
Assert.Equal(155m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_NestedSets_OnlyTheTopLevelSetIsPriced_TotalNotDoubled()
{
// A { B { m1 60, m2 40 }, m3 25 } plus loose 'x' 10 — the outer set takes 125, the inner
// set line is part of that subtree and is blanked, so 125 is never counted twice.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(135m, s.Sums.TotalNet);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type); // nested set: blanked with the subtree
Assert.Equal(JTokenType.Null, LineOf(s2!, "m1")["vt"]!.Type);
Assert.Equal(JTokenType.Null, LineOf(s2!, "m3")["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // outside every set: untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetOnly_NestedSets_RemovesTheWholeSubtreeIncludingTheNestedSetLine()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "A", "x" }, LineOrder(s2!));
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_ReflectedInBuildSetDisplay_MembersBlankStandaloneStillPriced()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
var setDisplay = (JObject)state["setDisplay"]!;
Assert.False(setDisplay.ContainsKey("bset_1"));
Assert.Empty(setDisplay);
Assert.True(setDisplay["10"]!["p"]!.Value<bool>()); // the set is priced …
Assert.True(setDisplay["10"]!["h"]!.Value<bool>()); // … and emphasised as a set header
Assert.False(setDisplay["11"]!["p"]!.Value<bool>()); // its members show no price
Assert.False(setDisplay["12"]!["p"]!.Value<bool>());
Assert.True(setDisplay["13"]!["p"]!.Value<bool>()); // the item outside the set keeps its price
}
[Fact]
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
[Theory]
[InlineData("block.setprice")]
[InlineData("block.setonly")]
public void ApplyPatch_SetModes_InvoiceWithoutAnySet_IsNoOp(string target)
{
// Nothing is grouped into a set, so there is nothing to collapse — every line stays as it
// is (ADR 0019: the modes never touch lines outside a set), and no version/history is spent.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'}]},
{'Id':'2','text':'Leer','itm':[],'items':[]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_EmptySet_IsNoOp()
{
// A set line with no members has nothing to sum — converting it would blank the whole
// invoice's only priced line into a 0,00 € set.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'}]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
Assert.Empty(s2.History);
Assert.Equal(0, s2!.Version);
Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
}
[Fact]
+12
View File
@@ -60,6 +60,18 @@ public class InvoiceSetPricingTests
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
}
[Fact]
public void Build_MemberWithNullTotal_UnderUnconvertedHeader_ShowsNoPrice()
{
// A price-nulled member ("no price", ADR 0009/0019) must render an empty cell even when its
// header reads as unconverted again (e.g. hand-edited back to 0) — never 0,00 €.
var header = SetHeader("10", "Set"); // total 0 -> unconverted
var nulled = Member("10", "A", "0", "0"); nulled["total_net"] = null;
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, nulled, Member("10", "B", "5.00", "5.00") }, SetDisplayMode.SetPrice);
Assert.False(lines[1].ShowPrice);
Assert.True(lines[2].ShowPrice); // a real price still shows
}
[Fact]
public void ContainsSets_DetectsHeaderOrMember()
{