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:
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user