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);
|
Assert.Equal(125m, s2.Sums.TotalNet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
|
// ── Set-price menu modes (ADR 0019): keyed on the SET, never on the block ──
|
||||||
private static JObject BlocksPayload() => JObject.Parse(@"{
|
// 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'},
|
'admin':{'p13b':false,'type':'r'},
|
||||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
'req':[
|
'req':[
|
||||||
{'Id':'1','text':'Auftrag A','itm':[
|
{'Id':'1','text':'Auftrag A','itm':[
|
||||||
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
|
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
|
||||||
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
|
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
|
||||||
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
|
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%','SetItmId':'10'},
|
||||||
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
|
{'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':'2','text':'Auftrag B','itm':[
|
||||||
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
|
{'id':'20','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'},
|
||||||
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
|
{'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]
|
[Fact]
|
||||||
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
|
public void ApplyPatch_SetPrice_PricesEverySet_BlanksItsMembers_LeavesNonSetItemsPriced()
|
||||||
{
|
{
|
||||||
var (svc, _, _) = NewService();
|
var (svc, _, _) = NewService();
|
||||||
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
|
||||||
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
|
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" });
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
Assert.NotNull(s2);
|
Assert.NotNull(s2);
|
||||||
Assert.Equal(1, s2!.Version);
|
Assert.Equal(1, s2!.Version);
|
||||||
|
|
||||||
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
|
// No row is inserted or dropped — the existing set line carries the price now.
|
||||||
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
|
Assert.Equal(new[] { "10", "11", "12", "13" }, LineOrder(s2));
|
||||||
var setA = (JObject)linesA[0];
|
var setA = LineOf(s2, "10");
|
||||||
Assert.Equal("set", (string)setA["typ"]!);
|
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // its own members only (60 + 40), NOT the block (125)
|
||||||
Assert.Equal("bset_1", (string)setA["id"]!);
|
|
||||||
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
|
|
||||||
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
|
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
|
||||||
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
|
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
|
||||||
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
|
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
|
||||||
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
|
Assert.Equal(JTokenType.Null, LineOf(s2, "11")["vt"]!.Type); // member: null (empty cell), not 0
|
||||||
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
|
Assert.Equal(JTokenType.Null, LineOf(s2, "12")["vt"]!.Type);
|
||||||
Assert.Equal(JTokenType.Null, m11["vv"]!.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"]!;
|
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
|
||||||
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
|
Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["total_net"]!.Value<decimal>());
|
||||||
Assert.Equal(100m, ((JObject)itemsA[0])["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(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.
|
// Sets + loose items make up the unchanged invoice total.
|
||||||
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
|
Assert.Equal(155m, s2.Sums.TotalNet);
|
||||||
Assert.Equal(130m, s2.Sums.TotalNet);
|
Assert.Equal(29.45m, s2.Sums.TotalVat);
|
||||||
Assert.Equal(24.7m, s2.Sums.TotalVat);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
|
public void ApplyPatch_SetOnly_PricesEverySet_RemovesItsMembers_KeepsNonSetItems()
|
||||||
{
|
{
|
||||||
var (svc, _, _) = NewService();
|
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" });
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
|
||||||
|
|
||||||
Assert.NotNull(s2);
|
Assert.NotNull(s2);
|
||||||
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
|
// Set members gone; the set line and the item that belongs to no set remain.
|
||||||
Assert.Single(linesA); // members removed, only the set row remains
|
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
|
||||||
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
|
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>());
|
||||||
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
|
Assert.Equal(25m, LineOf(s2!, "13")["vt"]!.Value<decimal>());
|
||||||
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
|
Assert.Equal(new[] { "10", "13" },
|
||||||
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
|
((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]
|
[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 (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" });
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||||
var setDisplay = (JObject)state["setDisplay"]!;
|
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]
|
[Theory]
|
||||||
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
|
[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 (svc, _, _) = NewService();
|
||||||
var s = svc.OpenFromPayload(JObject.Parse(@"{
|
var s = svc.OpenFromPayload(JObject.Parse(@"{
|
||||||
'admin':{'p13b':false,'type':'r'},
|
'admin':{'p13b':false,'type':'r'},
|
||||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
'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");
|
}"), "user1");
|
||||||
|
|
||||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
Assert.NotNull(s2);
|
Assert.NotNull(s2);
|
||||||
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ public class InvoiceSetPricingTests
|
|||||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
|
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]
|
[Fact]
|
||||||
public void ContainsSets_DetectsHeaderOrMember()
|
public void ContainsSets_DetectsHeaderOrMember()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
|
|||||||
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
||||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||||
|
|
||||||
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
|
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
|
||||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||||
|
|
||||||
### 4.4 SQL-First Data Access
|
### 4.4 SQL-First Data Access
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ applyTo:
|
|||||||
relatedDecisions:
|
relatedDecisions:
|
||||||
- "0006-backend-authoritative-draft-editing.md"
|
- "0006-backend-authoritative-draft-editing.md"
|
||||||
- "0007-targeted-draft-signalr-groups.md"
|
- "0007-targeted-draft-signalr-groups.md"
|
||||||
- "0009-block-setprice-modes-are-irreversible-mutations.md"
|
|
||||||
- "0016-user-defined-set-structure.md"
|
- "0016-user-defined-set-structure.md"
|
||||||
|
- "0019-set-price-modes-are-keyed-on-the-set.md"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Live draft editing (backend-authoritative invoice previews)
|
# Live draft editing (backend-authoritative invoice previews)
|
||||||
@@ -170,5 +170,5 @@ amount pair.
|
|||||||
## Related decisions
|
## Related decisions
|
||||||
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
|
||||||
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
|
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
|
||||||
|
- [0019 — Set-price modes are keyed on the set](../Decisions/0019-set-price-modes-are-keyed-on-the-set.md) (supersedes 0009)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: Accepted
|
status: Superseded
|
||||||
date: 2026-07-14
|
date: 2026-07-14
|
||||||
applyTo:
|
applyTo:
|
||||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||||
@@ -10,7 +10,7 @@ applyTo:
|
|||||||
- "Fuchs/code/FuchsPdf.cs"
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
- "Fuchs/js/intranet/**"
|
- "Fuchs/js/intranet/**"
|
||||||
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||||
supersededBy: ""
|
supersededBy: "0019-set-price-modes-are-keyed-on-the-set.md"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-09-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||||
|
- "Fuchs/code/InvoiceSetStructure.cs"
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0019 — The two set-price menu modes are keyed on the set, not on the service-request block
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADR [0009](0009-block-setprice-modes-are-irreversible-mutations.md) defined the two menu modes
|
||||||
|
("Set mit Preis" / "Nur Set mit Preis") as per-**service-request-block** conversions: every block
|
||||||
|
was collapsed into one inserted "Gesamtumfang pauschal" set row carrying the block's whole sum,
|
||||||
|
every original line of the block was price-nulled or removed, and any existing set membership was
|
||||||
|
neutralised (`SetItmId`/`setId` cleared, existing set rows demoted). Grouping by `SetItmId` was
|
||||||
|
explicitly rejected there.
|
||||||
|
|
||||||
|
ADR [0016](0016-user-defined-set-structure.md) then made set membership an explicit, user-editable
|
||||||
|
parent link with a dialog to define exactly which line belongs to which set, and let sets nest. With
|
||||||
|
that in place, the block-wide collapse actively destroys the structure the user just defined: it
|
||||||
|
discards their sets, prices lines that were never meant to be in a set, and can never show two sets
|
||||||
|
of one service request as two lines.
|
||||||
|
|
||||||
|
The product owner has therefore redefined the two modes again, this time onto the set structure:
|
||||||
|
|
||||||
|
- Choosing **"Set mit Preis – Positionen ohne Preis"** must give **each set** one price, show that
|
||||||
|
set's positions without a price, and leave every item that is in **no** set listed individually
|
||||||
|
with its own price. Sets and single items together make up the invoice total.
|
||||||
|
- Choosing **"Nur Set mit Preis – Positionen ausgeblendet"** must hide the positions belonging to
|
||||||
|
the sets, while the sets themselves and the items outside any set stay visible.
|
||||||
|
- **Several sets stay several separate lines** — they are never merged into one.
|
||||||
|
|
||||||
|
This decision records that redefinition and **supersedes ADR 0009**. Function 1 (the inline
|
||||||
|
set-item switch) is carried over from 0009 unchanged, as is ADR 0008's rule that every calculation
|
||||||
|
happens server-side and the PDF renders exactly what the editor shows.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
There are still three set-price operations, but they now share one grouping key — the set — and one
|
||||||
|
conversion primitive:
|
||||||
|
|
||||||
|
1. **Inline set-item switch (`item.setprice`) — unchanged from ADR 0009**, except that it is now a
|
||||||
|
thin wrapper over the shared primitive and is a **no-op on an already-converted set** (own price
|
||||||
|
non-zero). Without that guard a repeated delta would re-sum the members the first conversion had
|
||||||
|
already nulled and reset the set price to 0.
|
||||||
|
|
||||||
|
2. **"Set mit Preis" (`block.setprice`) — per set, irreversible.** For **every still-unconverted
|
||||||
|
top-level set of every block**: sum the set's transitive descendants onto the set line and set
|
||||||
|
each descendant's price fields to `null` (empty cell, excluded from the sum — never `0`, which
|
||||||
|
would print a genuine `0,00 €`). The descendant rows are kept.
|
||||||
|
|
||||||
|
3. **"Nur Set mit Preis" (`block.setonly`) — per set, irreversible.** As above, and additionally
|
||||||
|
**remove** the whole subtree of every top-level set (its members and any nested set line) from
|
||||||
|
both line shapes (`itm` and `items`). A set that was already converted keeps its price and still
|
||||||
|
has its members removed.
|
||||||
|
|
||||||
|
Properties common to modes 2 and 3:
|
||||||
|
|
||||||
|
- **The set is the grouping key.** No row is inserted, no row is demoted, no `SetItmId`/`setId` is
|
||||||
|
cleared. The user-defined structure (ADR 0016) is what the modes act on and what survives them.
|
||||||
|
- **Lines outside every set are untouched** — they keep their individual price and stay listed as
|
||||||
|
ordinary positions. Sets and standalone lines together form the total.
|
||||||
|
- **Only top-level sets are priced.** A nested set is part of its parent's subtree and is
|
||||||
|
blanked (mode 2) or removed (mode 3) with it, so no value is ever counted twice.
|
||||||
|
- **Empty sets and set-free invoices are no-ops** — no version bump, no history entry. The editor
|
||||||
|
therefore checks the authoritative state for a set before opening the mode dialog and otherwise
|
||||||
|
tells the user to define a set first ("Set-Struktur bearbeiten").
|
||||||
|
- **Repeatable, still one-way.** Running a mode again converts only sets that are still
|
||||||
|
unconverted, so the menu entry stays available (the ADR 0009 `admin.setmode` gate on the entry is
|
||||||
|
gone); no operation converts anything back.
|
||||||
|
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` server-side; there
|
||||||
|
is no render-time grouping flag. A residual `setmode:` `InvoiceOptions` token from the pre-0009
|
||||||
|
display-mode model still degrades safely (ignored) and is not re-persisted.
|
||||||
|
- **Total conserved, editor and PDF identical** (ADR 0008): a blanked member contributes nothing, a
|
||||||
|
removed one is gone, and in both cases its value already sits on the set line.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- `InvoiceDraftEditService.ApplyBlockSetPricing` (block aggregation, inserted `bset_*` row,
|
||||||
|
`DemoteSet`, `setId` neutralisation) is gone; `ApplySetPricing` walks the set forest through
|
||||||
|
`InvoiceSetStructure` and delegates the per-set work to `ConvertSetHeader`, which
|
||||||
|
`ApplyItemSetPrice` now uses too. One conversion rule, three triggers.
|
||||||
|
- Because membership is preserved, `InvoiceSetPricing.Build` (and `BuildSetDisplay`) governs the
|
||||||
|
rendering of the result again: a converted set's members are price-blanked, non-set lines keep
|
||||||
|
their own price. The PDF path needs no special case for the menu modes.
|
||||||
|
- The "Gesamtumfang pauschal" label and the notion of a service request as an implicit set no longer
|
||||||
|
exist. A user who wants one price for a whole service request creates a set over its lines in the
|
||||||
|
structure dialog first.
|
||||||
|
- Tests: `InvoiceDraftServiceTests` covers per-set pricing with a standalone item alongside, several
|
||||||
|
sets kept separate, nested sets priced once, member removal in both line shapes, the
|
||||||
|
already-converted set, the repeated `item.setprice` no-op, and the set-free/empty-set no-ops.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep the block collapse and let the user pick the block** — rejected: it still cannot express
|
||||||
|
two sets in one service request, and it overwrites the structure ADR 0016 exists to let the user
|
||||||
|
define.
|
||||||
|
- **Price nested sets individually as well** — rejected: their value is already contained in the
|
||||||
|
top-level set's price, so both being priced would double the invoice's printed sum.
|
||||||
|
- **Fall back to the old block behaviour when an invoice contains no set** — rejected: it would make
|
||||||
|
the same menu entry do two very different things. Without a set there is nothing to price as a
|
||||||
|
set, so the modes are a no-op and the editor points the user at the structure dialog.
|
||||||
|
- **Make the modes reversible display toggles again** — rejected (as in ADR 0009): the set price
|
||||||
|
must be a real, hand-editable value in the document, and "Nur Set mit Preis" must actually drop
|
||||||
|
the member lines.
|
||||||
+121
-152
@@ -1,46 +1,49 @@
|
|||||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||||
|
|
||||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
> Governed by ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)
|
||||||
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
> (the two menu modes — **supersedes** ADR
|
||||||
> (everything server-side, PDF == editor) and ADR
|
> [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md), which had keyed them on
|
||||||
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit,
|
> the service-request block), ADR
|
||||||
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the
|
> [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) (everything
|
||||||
> reversible, non-mutating display toggles this document previously described into irreversible,
|
> server-side, PDF == editor) and ADR [0016](Decisions/0016-user-defined-set-structure.md) (set
|
||||||
> per-service-request-block mutations — the text below reflects the redefinition.
|
> membership is an explicit, user-editable parent link; sets may nest — see "Set structure" below).
|
||||||
|
|
||||||
There are **three** separate set-price operations. They fall into two families that must not be
|
There are **three** set-price operations. All three are **irreversible mutations** of the
|
||||||
confused, because they group items by different keys and differ in whether they mutate the data:
|
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a display toggle — and all
|
||||||
|
three group by the **same key: the set** (ADR 0019).
|
||||||
|
|
||||||
| # | Operation | Trigger | Grouped by | Effect |
|
| # | Operation | Trigger | Scope | Effect |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** |
|
| **1** | **Set-item switch** | row context button on a single `typ == "set"` row (`$inv.toSetPrice`) | that one set | sums the set's transitive descendants onto the set line and sets their prices to `null` |
|
||||||
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
|
| **2** | **"Set mit Preis – Positionen ohne Preis"** | editor menu ("Set-Preisanzeige") | **every still-unconverted top-level set** of every block | same conversion, applied to all of them at once; the members' rows are **kept**, showing an empty price cell |
|
||||||
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
|
| **3** | **"Nur Set mit Preis – Positionen ausgeblendet"** | editor menu ("Set-Preisanzeige") | **every top-level set** of every block | same conversion, and the set's whole subtree is **removed** from the dataset |
|
||||||
|
|
||||||
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
What the two menu modes explicitly do **not** do (they did, under the superseded ADR 0009): they do
|
||||||
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
not treat a service request as an implicit set, do not insert a "Gesamtumfang pauschal" row, and do
|
||||||
for the full breakdown.
|
not clear anyone's set membership.
|
||||||
|
|
||||||
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
|
- **Lines outside every set stay untouched** — individual price, listed as ordinary positions.
|
||||||
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
|
Sets and standalone lines together make up the invoice total.
|
||||||
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
|
- **Several sets stay several separate lines**, in the same block or across blocks.
|
||||||
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
|
- **Only top-level sets are priced.** A nested set belongs to its parent's subtree, so it is blanked
|
||||||
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
|
(mode 2) or removed (mode 3) with it and never priced twice.
|
||||||
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
|
- **No sets, or a set without members → no-op** (no version bump, no history). The editor checks the
|
||||||
> the set row is a real, editable line item precisely so the user can adjust the set value
|
authoritative state first and, when the invoice has no set at all, points the user at
|
||||||
> afterwards.
|
"Set-Struktur bearbeiten" instead of opening the mode dialog.
|
||||||
|
- **Repeatable, still one-way.** Running a mode again only converts sets that are still unconverted;
|
||||||
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
|
nothing converts back. The only ways back are discarding the draft (reloads the DB state) or
|
||||||
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
|
hand-editing the resulting rows — the set line is a real, editable item precisely for that.
|
||||||
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
|
|
||||||
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
|
|
||||||
> token degrades safely (ignored) and is never (re-)persisted.
|
|
||||||
|
|
||||||
> **Totals are unaffected.** The invoice total is taken from the registration balance
|
> **Totals are unaffected.** The invoice total is taken from the registration balance
|
||||||
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
|
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines, and every operation
|
||||||
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
|
> conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes.
|
||||||
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
|
> `null` (not `0`) marks "no price" — an empty cell excluded from the sum, distinct from a genuine
|
||||||
> header.
|
> `0,00 €`.
|
||||||
|
|
||||||
|
> **The `ItemPrices` / `admin.setmode` display-flag model is gone.** The pre-0009 design persisted a
|
||||||
|
> `setmode:<mode>` token in `InvoiceOptions` and re-grouped at render time. The modes are one-shot
|
||||||
|
> mutations, so there is no display state to persist or toggle; a stale `setmode:`/`itemprices`
|
||||||
|
> token degrades safely (ignored) and is never re-persisted.
|
||||||
|
|
||||||
## Set structure (ADR 0016): which line belongs to which set
|
## Set structure (ADR 0016): which line belongs to which set
|
||||||
|
|
||||||
@@ -59,11 +62,11 @@ for the full breakdown.
|
|||||||
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
|
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
|
||||||
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
|
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
|
||||||
triangle (view state only). The per-block bar creates a
|
triangle (view state only). The per-block bar creates a
|
||||||
new **Set** (zero-priced header, convertible via function 1) or **Text** line inside any set
|
new **Set** (zero-priced header, convertible via the operations above) or **Text** line inside any
|
||||||
or at the top level → `set.create { typ, title, parent }`.
|
set or at the top level → `set.create { typ, title, parent }`.
|
||||||
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
|
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
|
||||||
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls
|
deeper). Every conversion sums the header's **transitive** descendants and nulls them all;
|
||||||
them all; converting inner-then-outer or outer directly conserves the total either way.
|
converting inner-then-outer or outer directly conserves the total either way.
|
||||||
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
|
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
|
||||||
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
|
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
|
||||||
merely greys them out.
|
merely greys them out.
|
||||||
@@ -75,65 +78,72 @@ for the full breakdown.
|
|||||||
|
|
||||||
## Back-end
|
## Back-end
|
||||||
|
|
||||||
> **Migration status (ADR 0009).** The code below still reflects the previous
|
- `Fuchs/Services/InvoiceDraftEditService.cs` — the three triggers:
|
||||||
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
|
- `ConvertSetHeader(block, lines, header)` is the **single conversion primitive**: sums the
|
||||||
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
|
header's transitive descendants (`InvoiceSetStructure.Descendants`) onto the header
|
||||||
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
|
(`v`/`vt`/`vv`/`vs`/`vsv`, adopting the members' VAT rate when the header has none), nulls every
|
||||||
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
|
descendant's price fields, and mirrors the result onto the `items` contract shape
|
||||||
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
|
(`total_net` on the header, `null` on the members) so the editor and the PDF see the conversion
|
||||||
> migration lands so it stays a faithful description of the code.
|
without a reload.
|
||||||
|
- `ApplyItemSetPrice` (`item.setprice`) resolves one header by `Ref` and delegates. It is a no-op
|
||||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
when the `Ref` is unknown, is not a set line, or the set is **already converted** (own price
|
||||||
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
|
non-zero) — that guard keeps a repeated delta from re-summing already-nulled members to 0.
|
||||||
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
|
- `ApplySetPricing(removeMembers)` (`block.setprice` / `block.setonly`) walks every block, takes
|
||||||
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
|
the **top-level** set lines (`IsSet` && `ParentOf == null`), converts the unconverted ones via
|
||||||
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode
|
the primitive, and — for `setonly` — removes each such set's whole subtree from `itm` and
|
||||||
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
`items` (`RemoveById`). Sets with no members, and blocks/invoices without sets, are skipped.
|
||||||
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
|
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
|
||||||
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
|
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
|
||||||
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
|
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
|
||||||
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
|
- `Fuchs/code/InvoiceSetPricing.cs` — the render transformation:
|
||||||
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
|
||||||
set header line is rendered emphasised. Invoices without sets pass through
|
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
|
||||||
unchanged.
|
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
|
||||||
|
blank with individually priced members. Because the menu modes preserve set membership, this is
|
||||||
|
what renders their result — no special case is needed for them. Fully covered by
|
||||||
|
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||||
|
- `InvoiceDraftEditService.BuildSetDisplay` runs the same `Build` for the editor and publishes
|
||||||
|
`setDisplay[id] = { p: showPrice, h: isSetHeader }`, so the rows always match the PDF without any
|
||||||
|
client-side pricing rule.
|
||||||
|
- `FuchsPdf.ApplyInvoice` renders blocks that carry a set through `InvoiceSetPricing.Build` and
|
||||||
|
set-free blocks flat; lines with `ShowPrice == false` (or a `null` `total_net`) render **blank**
|
||||||
|
price/total cells (not `0,00 €`), and set header lines are emphasised.
|
||||||
|
|
||||||
## Front-end contract (implemented in the invoice editor)
|
## Front-end contract (implemented in the invoice editor)
|
||||||
|
|
||||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||||
|
|
||||||
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, menu entry
|
||||||
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
`setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit Preis". `$inv.hasSets(state)`
|
||||||
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
first checks the authoritative `dstate` for a line that points at a set line; if there is none,
|
||||||
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
the user is told to define a set first ($rct.toSetMn) and nothing is posted. Otherwise selecting a
|
||||||
session (inserts the set row, blanks or removes members) and pushes the new
|
mode posts a **mutating delta** (`block.setprice` / `block.setonly`) to `inv/dpatch`; the server
|
||||||
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
rewrites the sets in the cached session and pushes the new state back via `draftReady`/`dstate`.
|
||||||
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
The browser performs **no** grouping or pricing math (ADR 0008/0019) — it only posts the chosen
|
||||||
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
mode and re-renders the server's `req`/`sums`. Pending local edits are flushed first
|
||||||
these two modes.
|
(`$inv.t_fds_inv()`) so a delayed `block.replace` cannot land after the conversion. The menu
|
||||||
|
entry is always offered (`$inv.emOpt`), because a mode can be run again for a set added later.
|
||||||
|
|
||||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
2. **Item shape** — `$inv.invSumUpdate` posts each request block's `items[]` in the back-end
|
||||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
contract shape via `$inv.itemToContract`:
|
||||||
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
|
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
|
||||||
editor only posted the legacy on-screen `itm`/`co` objects, which
|
editor only posted the legacy on-screen `itm`/`co` objects, which
|
||||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||||
|
|
||||||
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with `type === 'set'`
|
||||||
an item with `type === 'set'` is a header (`id` = its set id); a member's
|
is a header (`id` = its set id); a member's `setId` is taken directly from the `SetItmId` field on
|
||||||
`setId` is taken directly from the `SetItmId` field on the row
|
the row (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window
|
||||||
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
function, anchored on the still-unconverted, zero-priced `'set'` header that owns it, and
|
||||||
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
|
thereafter maintained by the server per ADR 0016, including for a **nested set**, which is a
|
||||||
maintained by the server per ADR 0016, including for a **nested set**, which is a member of
|
member of its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself
|
||||||
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no
|
still has no explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
|
||||||
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
|
item list, so only items the server actually attributes to a set are tagged, and unrelated items
|
||||||
item list, so only items the server actually attributes to a set are tagged, and
|
following a set in the list are never swept in. The header row's own `SetItmId` self-references
|
||||||
unrelated items following a set in the list are never swept in. The header row's
|
its own id (rather than being `null`); it is explicitly excluded from being its own member both
|
||||||
own `SetItmId` self-references its own id (rather than being `null`); it is
|
here (`sid !== citem.id`) and in `InvoiceSetStructure.ParentOf`. These flags feed **all three**
|
||||||
explicitly excluded from being its own member both here (`sid !== citem.id`) and
|
set-price operations (ADR 0019).
|
||||||
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
|
|
||||||
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
|
|
||||||
`SetItmId` and group by service-request block.
|
|
||||||
|
|
||||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||||
@@ -158,9 +168,7 @@ no per-rate `vat_*` keys.
|
|||||||
the highest rate.
|
the highest rate.
|
||||||
|
|
||||||
The editor's running **total is unaffected by any set-price operation**, matching
|
The editor's running **total is unaffected by any set-price operation**, matching
|
||||||
the registration balance — each operation conserves the total (the set row's value
|
the registration balance — each operation conserves the total.
|
||||||
equals the members it blanks/removes; the set-item switch writes exactly the
|
|
||||||
members' sum onto the header).
|
|
||||||
|
|
||||||
### Why the trigger lives in the editor
|
### Why the trigger lives in the editor
|
||||||
The choice of *when* to apply a set-price operation is only known where the invoice
|
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||||
@@ -169,83 +177,44 @@ against the cached draft session — the editor merely names the target (a set-i
|
|||||||
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
|
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
|
||||||
the server's result. The back-end stays the single, tested authority for how each
|
the server's result. The back-end stays the single, tested authority for how each
|
||||||
operation rewrites the lines; the editor never re-implements the grouping, the
|
operation rewrites the lines; the editor never re-implements the grouping, the
|
||||||
per-block aggregation, or the pricing rules (ADR 0008/0009).
|
aggregation, or the pricing rules (ADR 0008/0019).
|
||||||
|
|
||||||
## All set-price functions: before/after comparison
|
## Before/after per operation
|
||||||
|
|
||||||
There are **three** distinct functions, and all three are **mutations** of the
|
### 1. The set-item switch (`item.setprice`, one set)
|
||||||
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
|
|
||||||
transient, freely-reversible view flag. They differ in what they group by and
|
|
||||||
what they touch:
|
|
||||||
|
|
||||||
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
|
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`). The context button is
|
||||||
(one mfr set-item and its members) and is triggered per set row.
|
shown — and the operation available — **only** on a row that is `typ == "set"` and is still
|
||||||
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
|
unconverted (own price `0`).
|
||||||
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
|
|
||||||
entirely, and are triggered once from the "Set-Preisanzeige" menu.
|
|
||||||
|
|
||||||
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
|
| Aspect | Before | After |
|
||||||
is discarding the draft or hand-editing the resulting rows.
|
|
||||||
|
|
||||||
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
|
|
||||||
|
|
||||||
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
|
|
||||||
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
|
|
||||||
shown — and the operation available — **only** on a row that is `type == "set"`
|
|
||||||
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
|
|
||||||
missing either condition never offers it. It gives a set-item its "own price": the
|
|
||||||
members' values are summed onto the header and the members' prices are set to
|
|
||||||
`null` (empty cell, excluded from the sum — not `0`). This
|
|
||||||
conversion is **one-way** — there is no patch to move a converted set back to
|
|
||||||
separately-priced members; the user would re-edit the individual line prices by
|
|
||||||
hand. It is the **only** function keyed on `SetItmId`.
|
|
||||||
|
|
||||||
| Aspect | Before the switch | After the switch |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
|
| Set line (`id == Ref`) price | `0` (as delivered by `fds__prepInvoice`, or as created in the structure dialog) | `v`/`vt`/`vv`/`vs`/`vsv` (and `items[].total_net`) = the sum of its transitive descendants |
|
||||||
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
|
| Descendant lines | each shows its own price | every price field set to **`null`** (not `0`) → empty cell, excluded from the sum; the rows stay |
|
||||||
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
|
| Membership | fixed by the server (`fds__prepInvoice` + the user's edits, ADR 0016) | **unchanged** — never re-derived or reassigned |
|
||||||
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
|
| Lines outside the set | untouched | **still untouched**, regardless of row order |
|
||||||
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
|
| Draft version / history | — | version +1, `item.setprice` entry with old/new header value |
|
||||||
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
|
| Invoice total | — | **unchanged** |
|
||||||
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
|
| No-ops | unknown `Ref`, `Ref` is not a set line, **or the set is already converted** → no version bump, no history | |
|
||||||
|
|
||||||
### 2 & 3. The two menu modes (per service-request block, mutating)
|
### 2 & 3. The two menu modes (every set, one action)
|
||||||
|
|
||||||
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
|
|
||||||
`$inv.setSetmode`) and applied server-side per **service-request block**
|
|
||||||
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
|
|
||||||
Both are **irreversible** and rewrite the block's items in the cached session.
|
|
||||||
|
|
||||||
For each block, a dedicated, emphasised **set row** is inserted (see "The
|
|
||||||
dedicated set row" below) carrying the block's aggregated value as its price;
|
|
||||||
then, depending on the mode, the block's original items are either blanked or
|
|
||||||
removed:
|
|
||||||
|
|
||||||
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
|
| Grouping key | the set (top-level sets of every block) | the set (top-level sets of every block) |
|
||||||
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
|
| Set line | receives the sum of its subtree | receives the sum of its subtree |
|
||||||
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
|
| Its member rows | **kept**, every price field `null` → empty price/total cell | **removed** from `itm` and `items` entirely (nested set lines included) |
|
||||||
| `SetItmId` | ignored | ignored |
|
| Already-converted set | left as it is | keeps its price, members still removed |
|
||||||
|
| Lines in no set | **untouched**, individually priced | **untouched**, individually priced |
|
||||||
|
| Several sets | each stays its own priced line | each stays its own priced line |
|
||||||
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
|
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
|
||||||
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
|
| Invoice total | **unchanged** | **unchanged** |
|
||||||
| Empty block | no-op | no-op |
|
| Invoice without sets / empty set | no-op (editor tells the user to define a set) | no-op (same) |
|
||||||
|
|
||||||
### The dedicated set row
|
|
||||||
|
|
||||||
Both menu modes insert a **real, editable line item** (its own id, rendered
|
|
||||||
emphasised in the editor and the PDF), not a reused block-heading row and not a
|
|
||||||
render-only overlay. Because it is a genuine row in the cached dataset, the user
|
|
||||||
can adjust the set value afterwards with an ordinary item edit — that hand-edit
|
|
||||||
is the intended and only "undo" for the conversion (ADR
|
|
||||||
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
|
|
||||||
|
|
||||||
## Persistence note
|
## Persistence note
|
||||||
Draft/preview PDFs render straight from the cached draft session, so the contract
|
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||||
works end-to-end for previews and creation. The two menu modes bake their result
|
works end-to-end for previews and creation. The menu modes bake their result directly into the
|
||||||
directly into the session's items (a set row plus blanked/removed members), so no
|
session's items (set line priced, members blanked or removed), so no `setmode:` display token is
|
||||||
`setmode:` display token is needed or persisted; the finalised document is
|
needed or persisted; the finalised document is rendered once and stored as a file. Persisting the
|
||||||
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is only required if a
|
||||||
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
finalised invoice must be **re-generated** from stored items later — not done here.
|
||||||
invoice must be **re-generated** from stored items later — not done here.
|
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||||
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
|
case "block.setprice": return ApplySetPricing(s, removeMembers: false, ref oldValue, ref newValue); // ADR 0019: keyed on the set, not the block
|
||||||
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
case "block.setonly": return ApplySetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||||
case "set.assign":
|
case "set.assign":
|
||||||
case "set.level":
|
case "set.level":
|
||||||
case "set.create":
|
case "set.create":
|
||||||
@@ -221,23 +221,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
||||||
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
||||||
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
|
/// single set price, by delegating to <see cref="ConvertSetHeader"/> — the one conversion
|
||||||
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
|
/// primitive shared with the two menu modes (<see cref="ApplySetPricing"/>, ADR 0019).
|
||||||
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
|
/// The user can still change the resulting header price manually afterwards (ordinary item
|
||||||
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
|
/// edit), which is why this is one-way.
|
||||||
/// onto the header line and zeroes each member's price fields — matching the visual
|
|
||||||
/// <c>SetPrice</c> display mode (<see cref="InvoiceSetPricing"/>), but as an actual, irreversible
|
|
||||||
/// data change rather than a display toggle. The user can still change the resulting header price
|
|
||||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
|
||||||
/// <para>
|
|
||||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
|
||||||
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
|
|
||||||
/// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
|
|
||||||
/// Because sets may contain sets (ADR 0016), the members are the header's <b>transitive</b>
|
|
||||||
/// descendants: a nested, still-unconverted set contributes its members' prices, a nested
|
|
||||||
/// already-converted set contributes its own set price — either way every descendant line is
|
|
||||||
/// price-nulled afterwards, so the total is conserved and nothing is counted twice.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
{
|
{
|
||||||
@@ -248,10 +235,36 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
var lines = InvoiceSetStructure.Lines(block);
|
var lines = InvoiceSetStructure.Lines(block);
|
||||||
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
|
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
|
||||||
if (header == null) continue;
|
if (header == null) continue;
|
||||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
if (!InvoiceSetStructure.IsSet(header)) return false;
|
||||||
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
|
// Already converted (own price non-zero): one-way, so this is a no-op. Without the
|
||||||
|
// guard a second delta (double click, replayed patch) would re-sum the members that
|
||||||
|
// the first conversion already nulled and reset the set price to 0.
|
||||||
|
if (InvoiceDraftCalculator.Dec(header["vt"]) != 0) return false;
|
||||||
|
|
||||||
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||||
|
decimal sumNet = ConvertSetHeader(block, lines, header);
|
||||||
|
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single set-conversion primitive: sums the <c>vt</c>/<c>vv</c>/<c>vs</c>/<c>vsv</c> of the
|
||||||
|
/// header's <b>transitive</b> descendants (<see cref="InvoiceSetStructure.Descendants"/>, ADR 0016
|
||||||
|
/// — a nested unconverted set contributes its members' prices, a nested already-converted set its
|
||||||
|
/// own set price, so nothing is counted twice), writes that sum onto the header and nulls every
|
||||||
|
/// descendant's price. <c>null</c> (not <c>0</c>) marks "no price": an empty cell excluded from the
|
||||||
|
/// sum, distinct from a genuine 0,00 € (ADR 0009). The conversion is mirrored onto the <c>items</c>
|
||||||
|
/// contract shape so <see cref="InvoiceSetPricing"/>/<c>BuildSetDisplay</c> — which read
|
||||||
|
/// <c>total_net</c> from there — see the set as converted immediately, without a reload.
|
||||||
|
/// Returns the set's net total. The invoice total is conserved.
|
||||||
|
/// </summary>
|
||||||
|
private static decimal ConvertSetHeader(JObject block, List<JObject> lines, JObject header)
|
||||||
|
{
|
||||||
|
string headerId = Str(header["id"]);
|
||||||
|
var members = InvoiceSetStructure.Descendants(lines, headerId);
|
||||||
|
|
||||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||||
foreach (var m in members)
|
foreach (var m in members)
|
||||||
{
|
{
|
||||||
@@ -260,125 +273,103 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||||
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
|
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
|
||||||
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
|
NullLinePrice(m);
|
||||||
}
|
}
|
||||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||||
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
|
|
||||||
// read total_net from here, not from "itm") so the online editor's set-display immediately
|
|
||||||
// reflects that the set is now converted, instead of waiting for a reload.
|
|
||||||
if (block["items"] is JArray items)
|
if (block["items"] is JArray items)
|
||||||
{
|
{
|
||||||
|
var memberIds = members.Select(m => Str(m["id"])).Where(id => id.Length > 0).ToHashSet(StringComparer.Ordinal);
|
||||||
foreach (var itemTok in items)
|
foreach (var itemTok in items)
|
||||||
{
|
{
|
||||||
if (itemTok is not JObject io) continue;
|
if (itemTok is not JObject io) continue;
|
||||||
string id = Str(io["id"]);
|
string id = Str(io["id"]);
|
||||||
if (id == d.Ref) { io["total_net"] = sumNet; }
|
// Both price fields: the flat PDF path (MapItemToLine, taken once the members are
|
||||||
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
|
// gone under "Nur Set mit Preis") prints price_net as the unit price — mirroring only
|
||||||
|
// total_net would print the stale 0 next to the correct total.
|
||||||
|
if (id == headerId) { io["price_net"] = sumNet; io["total_net"] = sumNet; }
|
||||||
|
else if (memberIds.Contains(id)) NullItemPrice(io);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return sumNet;
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
|
/// The two menu set-price modes (ADR 0019, superseding the per-block collapse of ADR 0009):
|
||||||
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
|
/// applied server-side as an <b>irreversible mutation</b> of the cached session, keyed on the
|
||||||
/// cached session — never a display flag. For every block that carries any priced line:
|
/// user-defined <b>set structure</b> (ADR 0016) — never on the service-request block and never a
|
||||||
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
|
/// display flag. Every still-unconverted <b>top-level</b> set of every block is converted via
|
||||||
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
|
/// <see cref="ConvertSetHeader"/>, so each set carries one price and its members render with an
|
||||||
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
|
/// empty price cell ("Set mit Preis", <paramref name="removeMembers"/> = false) or are removed from
|
||||||
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
|
/// the dataset entirely ("Nur Set mit Preis", <paramref name="removeMembers"/> = true). Nested sets
|
||||||
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
|
/// are part of their top-level set's subtree, so they are blanked/removed with it rather than
|
||||||
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
|
/// priced twice.
|
||||||
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
|
/// <para>
|
||||||
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
|
/// Lines that belong to <b>no</b> set are left completely untouched: they keep their individual
|
||||||
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
|
/// price and are listed as ordinary positions. Sets and standalone lines together make up the
|
||||||
/// or removes.
|
/// invoice total, which is conserved either way — a blanked member contributes nothing
|
||||||
|
/// (<c>null</c> = no price) and a removed one is gone, in both cases already accounted for by the
|
||||||
|
/// set price. An invoice without any set is a no-op.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
private static bool ApplySetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||||
{
|
{
|
||||||
bool mutated = false;
|
bool mutated = false;
|
||||||
int converted = 0;
|
int converted = 0, removed = 0;
|
||||||
foreach (var blockTok in s.Req)
|
foreach (var blockTok in s.Req)
|
||||||
{
|
{
|
||||||
if (blockTok is not JObject block || block["itm"] is not JArray lines || lines.Count == 0) continue;
|
if (blockTok is not JObject block || block["itm"] is not JArray lineArr) continue;
|
||||||
|
var lines = InvoiceSetStructure.Lines(block);
|
||||||
|
var byId = InvoiceSetStructure.ById(lines);
|
||||||
|
var dropIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
// Aggregate the block from its current line values (nulled/absent = 0 contribution).
|
// Top-level sets only: a nested set is a member of its parent's subtree and is
|
||||||
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
|
// blanked/removed with it, so converting both would count its value twice.
|
||||||
string rate = "";
|
foreach (var header in lines.Where(co => InvoiceSetStructure.IsSet(co)
|
||||||
foreach (var lineTok in lines)
|
&& InvoiceSetStructure.ParentOf(co, byId) == null).ToList())
|
||||||
{
|
{
|
||||||
if (lineTok is not JObject co) continue;
|
string headerId = Str(header["id"]);
|
||||||
net += InvoiceDraftCalculator.Dec(co["vt"]);
|
if (headerId.Length == 0) continue;
|
||||||
vat += InvoiceDraftCalculator.Dec(co["vv"]);
|
var members = InvoiceSetStructure.Descendants(lines, headerId);
|
||||||
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
|
if (members.Count == 0) continue; // an empty set has nothing to collapse
|
||||||
svcVat += InvoiceDraftCalculator.Dec(co["vsv"]);
|
|
||||||
string r = InvoiceDraftCalculator.NormalizeRate(Str(co["vat"]));
|
|
||||||
if (r.Length > 0) rate = r; // collapsed set line carries the block's (highest/last) rate
|
|
||||||
}
|
|
||||||
if (net == 0 && vat == 0 && svcNet == 0 && svcVat == 0) continue; // nothing priced -> skip block
|
|
||||||
|
|
||||||
string blockId = Str(block["Id"]);
|
// Already converted (own price non-zero, e.g. via the inline item switch): keep its
|
||||||
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
|
// price as it stands — but "Nur Set mit Preis" still drops the members below.
|
||||||
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
|
if (InvoiceDraftCalculator.Dec(header["vt"]) == 0)
|
||||||
|
|
||||||
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
|
|
||||||
var setItm = new JObject
|
|
||||||
{
|
{
|
||||||
["id"] = setId, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
ConvertSetHeader(block, lines, header);
|
||||||
["v"] = JValue.CreateNull(), ["vt"] = net, ["vv"] = vat, ["vs"] = svcNet, ["vsv"] = svcVat,
|
|
||||||
["vat"] = rate, ["det"] = false
|
|
||||||
};
|
|
||||||
var setItem = new JObject
|
|
||||||
{
|
|
||||||
["id"] = setId, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
|
||||||
["price_net"] = net, ["total_net"] = net, ["vat"] = rate, ["setId"] = JValue.CreateNull()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mutate the itm array in place (avoids Newtonsoft re-parenting): drop members (setonly)
|
|
||||||
// or null their prices + neutralise set membership (setprice), then prepend the set row.
|
|
||||||
if (removeMembers) lines.Clear();
|
|
||||||
else
|
|
||||||
foreach (var lineTok in lines)
|
|
||||||
{
|
|
||||||
if (lineTok is not JObject co) continue;
|
|
||||||
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
|
|
||||||
NullLinePrice(co);
|
|
||||||
}
|
|
||||||
lines.Insert(0, setItm);
|
|
||||||
|
|
||||||
if (block["items"] is JArray items)
|
|
||||||
{
|
|
||||||
if (removeMembers) items.Clear();
|
|
||||||
else
|
|
||||||
foreach (var itemTok in items)
|
|
||||||
{
|
|
||||||
if (itemTok is not JObject io) continue;
|
|
||||||
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
|
|
||||||
NullItemPrice(io);
|
|
||||||
}
|
|
||||||
items.Insert(0, setItem);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
block["items"] = new JArray { setItem };
|
|
||||||
}
|
|
||||||
|
|
||||||
mutated = true; converted++;
|
mutated = true; converted++;
|
||||||
}
|
}
|
||||||
|
if (removeMembers)
|
||||||
|
foreach (var m in members)
|
||||||
|
{
|
||||||
|
string mid = Str(m["id"]);
|
||||||
|
if (mid.Length > 0) dropIds.Add(mid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropIds.Count > 0)
|
||||||
|
{
|
||||||
|
int gone = RemoveById(lineArr, dropIds);
|
||||||
|
if (block["items"] is JArray itemsArr) RemoveById(itemsArr, dropIds);
|
||||||
|
if (gone > 0) { mutated = true; removed += gone; }
|
||||||
|
}
|
||||||
|
}
|
||||||
oldValue = "";
|
oldValue = "";
|
||||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
|
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted + (removeMembers ? "/" + removed : "");
|
||||||
return mutated;
|
return mutated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
|
/// <summary>Removes every entry of the array whose <c>id</c> is in <paramref name="ids"/>; returns how many went.</summary>
|
||||||
private static void DemoteSet(JObject line, string typeKey)
|
private static int RemoveById(JArray arr, HashSet<string> ids)
|
||||||
{
|
{
|
||||||
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
|
int n = 0;
|
||||||
line[typeKey] = "other";
|
for (int i = arr.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (arr[i] is not JObject o || !ids.Contains(Str(o["id"]))) continue;
|
||||||
|
arr[i].Remove(); n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
||||||
@@ -478,8 +469,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
{
|
{
|
||||||
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
||||||
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
||||||
// Only function-1 (SetItmId-linked) sets get display flags; block-mode sets (ADR 0009)
|
// Only blocks that actually carry a set need flags; a block of plain, individually
|
||||||
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
|
// priced lines renders flat (and since ADR 0019 keeps non-set lines untouched, that is
|
||||||
|
// exactly what the two menu modes leave behind for them).
|
||||||
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
||||||
|
|
||||||
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
||||||
|
|||||||
@@ -778,9 +778,11 @@ public static class FuchsPdf
|
|||||||
// Blocks without any set items are unaffected and always render flat.
|
// Blocks without any set items are unaffected and always render flat.
|
||||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||||
|
|
||||||
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
|
// Blocks that carry a set (a line pointing at a set header via setId) render through Build,
|
||||||
// and plain blocks render flat — their set row is emphasised on its own and nulled
|
// which blanks/drops the members of a converted set; blocks without any set render flat —
|
||||||
// members map to blank cells (see MapItemToLine).
|
// 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
|
var blockLines = inv.InvoiceBlocks
|
||||||
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
|
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
|
||||||
? InvoiceSetPricing.Build(block.Items, setMode)
|
? InvoiceSetPricing.Build(block.Items, setMode)
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ public static class InvoiceSetPricing
|
|||||||
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
|
/// True if the block carries a set (a line points at a set header via <c>setId</c>) — the only
|
||||||
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
|
/// case that routes through <see cref="Build"/>. Since ADR 0019 every set-price operation — the
|
||||||
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
|
/// inline set-item switch and both menu modes — preserves that membership, so this is what
|
||||||
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
|
/// renders their result too; a block of lines that belong to no set renders flat.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
|
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
|
||||||
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
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("price_net", 0), out decimal price);
|
||||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
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
|
return new InvoiceSetLine
|
||||||
{
|
{
|
||||||
Id = i.nz("id", ""),
|
Id = i.nz("id", ""),
|
||||||
@@ -235,7 +239,7 @@ public static class InvoiceSetPricing
|
|||||||
Qty = i.nz("qty", "1"),
|
Qty = i.nz("qty", "1"),
|
||||||
PriceNet = price,
|
PriceNet = price,
|
||||||
TotalNet = total,
|
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
|
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
|
||||||
IsSetHeader = false,
|
IsSetHeader = false,
|
||||||
Level = level
|
Level = level
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ $inv.iMn = (ix) => {
|
|||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m);
|
return $('#topbar').ocmsmenu(m);
|
||||||
}
|
}
|
||||||
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
|
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
|
||||||
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
|
modes convert every still-unconverted SET (not the whole invoice), so running them again after
|
||||||
changing it again is not supported (the user edits the line items directly instead); it
|
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
|
||||||
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
|
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
|
||||||
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
|
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
|
||||||
$inv.eM = (r, re, opt) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -182,10 +182,7 @@ $inv.d = {
|
|||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||||
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
||||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
|
||||||
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
|
|
||||||
$inv.eM(false, true, $inv.emOpt(state.admin || {}));
|
|
||||||
},
|
},
|
||||||
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
||||||
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
||||||
@@ -202,10 +199,10 @@ $inv.d = {
|
|||||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
|
||||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
|
||||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
|
||||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
patch/insert the matching row, preserving null price fields verbatim (null = empty cell, kept
|
||||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||||
applyItems: function (tbl, req) {
|
applyItems: function (tbl, req) {
|
||||||
@@ -768,7 +765,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
$inv.eM(false, true, $inv.emOpt(admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -913,7 +910,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
|||||||
|
|
||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
$inv.eM(false, true, $inv.emOpt(response.admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1308,7 @@ $inv.rrw = function () {
|
|||||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||||
} else {
|
} else {
|
||||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. a set line) */
|
||||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||||
co.v = dta.net
|
co.v = dta.net
|
||||||
co.vt = dta.net_val
|
co.vt = dta.net_val
|
||||||
@@ -1330,8 +1327,8 @@ $inv.rrw = function () {
|
|||||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||||
} else {
|
} else {
|
||||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
|
||||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
converted to a set price, and a set line's own unit price), distinct from a real 0,00 €. */
|
||||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||||
Array.prototype.push.apply(tda, [
|
Array.prototype.push.apply(tda, [
|
||||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||||
@@ -1340,7 +1337,7 @@ $inv.rrw = function () {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows */
|
||||||
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
||||||
.tC('hidenote', hn).append(tda);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
@@ -1588,7 +1585,7 @@ $inv.itemToContract = function (rrx) {
|
|||||||
let oHtml = (e) => $$.d().append(e).html();
|
let oHtml = (e) => $$.d().append(e).html();
|
||||||
let type = (rrx.Type || '').toString().toLowerCase();
|
let type = (rrx.Type || '').toString().toLowerCase();
|
||||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
nulled set members and the set line round-trip unchanged when the block is re-posted. */
|
||||||
let nz = (x) => (x == null ? null : x);
|
let nz = (x) => (x == null ? null : x);
|
||||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||||
if (rrx.co && rrx.co.typ === 'osum') {
|
if (rrx.co && rrx.co.typ === 'osum') {
|
||||||
@@ -1616,26 +1613,38 @@ $inv.itemToContract = function (rrx) {
|
|||||||
}
|
}
|
||||||
return ci;
|
return ci;
|
||||||
};
|
};
|
||||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
/* Whether the authoritative state carries at least one SET line that owns members — the two
|
||||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
|
||||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
|
||||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
|
||||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
|
||||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
let isSet = (co) => ((co.typ || '').toString().toLowerCase() === 'set');
|
||||||
|
return itms.some((co) => {
|
||||||
|
let p = (co.SetItmId == null ? '' : co.SetItmId).toString();
|
||||||
|
return p !== '' && p !== (co.id || '').toString() && byId[p] && isSet(byId[p]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* 2-way set-pricing switch (SetPrice / SetOnly), ADR 0019: both modes give EVERY set the sum of
|
||||||
|
its positions as one set price; "Set mit Preis" keeps those positions without a price, "Nur Set
|
||||||
|
mit Preis" removes them. Positions outside any set stay individually priced either way, and
|
||||||
|
several sets stay several separate lines. ItemPrices ("Positionen mit Preis – Set als
|
||||||
|
Überschrift") was removed: it is just the pre-conversion default and not a state one can switch
|
||||||
|
back to, since every conversion is one-way (see $inv.toSetPrice /
|
||||||
|
InvoiceDraftEditService.ApplyItemSetPrice). */
|
||||||
$inv.ssetmode = () => {
|
$inv.ssetmode = () => {
|
||||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
|
||||||
d.admin = d.admin || {};
|
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
|
||||||
let cur = (d.admin.setmode || 'setprice'), o;
|
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
|
||||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||||
o = $ocms.dlg(fr, { width: 800 });
|
o = $ocms.dlg(fr, { width: 800 });
|
||||||
};
|
};
|
||||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
|
||||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
|
||||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
sums each top-level set's positions onto the set line and either nulls those positions' prices
|
||||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
|
||||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
|
||||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
pending local edits first so a delayed block.replace can't land after the conversion and
|
||||||
|
overwrite it (same race guard as $inv.toSetPrice). */
|
||||||
$inv.setSetmode = (mode) => {
|
$inv.setSetmode = (mode) => {
|
||||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
combP: 'Positionen zusammenfassen',
|
combP: 'Positionen zusammenfassen',
|
||||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
toSetMc: 'Jedes Set erhält die Summe seiner Positionen als Set-Preis; die Positionen des Sets werden ohne Preis dargestellt bzw. ausgeblendet. Positionen ausserhalb eines Sets bleiben unverändert einzeln aufgeführt. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
|
toSetMn: 'Diese Rechnung enthält kein Set. Legen Sie zuerst über "Set-Struktur bearbeiten" ein Set an bzw. ordnen Sie Positionen einem Set zu.',
|
||||||
iSum: 'Zwischensumme',
|
iSum: 'Zwischensumme',
|
||||||
dtRel: 'Freigegeben am: ',
|
dtRel: 'Freigegeben am: ',
|
||||||
dtCr: 'Erstellt am: ',
|
dtCr: 'Erstellt am: ',
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ let $rct = {
|
|||||||
combP: 'Positionen zusammenfassen',
|
combP: 'Positionen zusammenfassen',
|
||||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
toSetMc: 'Jedes Set erhält die Summe seiner Positionen als Set-Preis; die Positionen des Sets werden ohne Preis dargestellt bzw. ausgeblendet. Positionen ausserhalb eines Sets bleiben unverändert einzeln aufgeführt. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
|
toSetMn: 'Diese Rechnung enthält kein Set. Legen Sie zuerst über "Set-Struktur bearbeiten" ein Set an bzw. ordnen Sie Positionen einem Set zu.',
|
||||||
iSum: 'Zwischensumme',
|
iSum: 'Zwischensumme',
|
||||||
dtRel: 'Freigegeben am: ',
|
dtRel: 'Freigegeben am: ',
|
||||||
dtCr: 'Erstellt am: ',
|
dtCr: 'Erstellt am: ',
|
||||||
@@ -657,11 +658,11 @@ $inv.iMn = (ix) => {
|
|||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m);
|
return $('#topbar').ocmsmenu(m);
|
||||||
}
|
}
|
||||||
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
|
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
|
||||||
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
|
modes convert every still-unconverted SET (not the whole invoice), so running them again after
|
||||||
changing it again is not supported (the user edits the line items directly instead); it
|
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
|
||||||
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
|
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
|
||||||
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
|
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
|
||||||
$inv.eM = (r, re, opt) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -757,10 +758,7 @@ $inv.d = {
|
|||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||||
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
||||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
|
||||||
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
|
|
||||||
$inv.eM(false, true, $inv.emOpt(state.admin || {}));
|
|
||||||
},
|
},
|
||||||
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
||||||
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
||||||
@@ -777,10 +775,10 @@ $inv.d = {
|
|||||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
|
||||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
|
||||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
|
||||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
patch/insert the matching row, preserving null price fields verbatim (null = empty cell, kept
|
||||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||||
applyItems: function (tbl, req) {
|
applyItems: function (tbl, req) {
|
||||||
@@ -1343,7 +1341,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
$inv.eM(false, true, $inv.emOpt(admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -1488,7 +1486,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
|||||||
|
|
||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
$inv.eM(false, true, $inv.emOpt(response.admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -1886,7 +1884,7 @@ $inv.rrw = function () {
|
|||||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||||
} else {
|
} else {
|
||||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. a set line) */
|
||||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||||
co.v = dta.net
|
co.v = dta.net
|
||||||
co.vt = dta.net_val
|
co.vt = dta.net_val
|
||||||
@@ -1905,8 +1903,8 @@ $inv.rrw = function () {
|
|||||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||||
} else {
|
} else {
|
||||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
|
||||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
converted to a set price, and a set line's own unit price), distinct from a real 0,00 €. */
|
||||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||||
Array.prototype.push.apply(tda, [
|
Array.prototype.push.apply(tda, [
|
||||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||||
@@ -1915,7 +1913,7 @@ $inv.rrw = function () {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows */
|
||||||
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
||||||
.tC('hidenote', hn).append(tda);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
@@ -2163,7 +2161,7 @@ $inv.itemToContract = function (rrx) {
|
|||||||
let oHtml = (e) => $$.d().append(e).html();
|
let oHtml = (e) => $$.d().append(e).html();
|
||||||
let type = (rrx.Type || '').toString().toLowerCase();
|
let type = (rrx.Type || '').toString().toLowerCase();
|
||||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
nulled set members and the set line round-trip unchanged when the block is re-posted. */
|
||||||
let nz = (x) => (x == null ? null : x);
|
let nz = (x) => (x == null ? null : x);
|
||||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||||
if (rrx.co && rrx.co.typ === 'osum') {
|
if (rrx.co && rrx.co.typ === 'osum') {
|
||||||
@@ -2191,26 +2189,38 @@ $inv.itemToContract = function (rrx) {
|
|||||||
}
|
}
|
||||||
return ci;
|
return ci;
|
||||||
};
|
};
|
||||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
/* Whether the authoritative state carries at least one SET line that owns members — the two
|
||||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
|
||||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
|
||||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
|
||||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
|
||||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
let isSet = (co) => ((co.typ || '').toString().toLowerCase() === 'set');
|
||||||
|
return itms.some((co) => {
|
||||||
|
let p = (co.SetItmId == null ? '' : co.SetItmId).toString();
|
||||||
|
return p !== '' && p !== (co.id || '').toString() && byId[p] && isSet(byId[p]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* 2-way set-pricing switch (SetPrice / SetOnly), ADR 0019: both modes give EVERY set the sum of
|
||||||
|
its positions as one set price; "Set mit Preis" keeps those positions without a price, "Nur Set
|
||||||
|
mit Preis" removes them. Positions outside any set stay individually priced either way, and
|
||||||
|
several sets stay several separate lines. ItemPrices ("Positionen mit Preis – Set als
|
||||||
|
Überschrift") was removed: it is just the pre-conversion default and not a state one can switch
|
||||||
|
back to, since every conversion is one-way (see $inv.toSetPrice /
|
||||||
|
InvoiceDraftEditService.ApplyItemSetPrice). */
|
||||||
$inv.ssetmode = () => {
|
$inv.ssetmode = () => {
|
||||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
|
||||||
d.admin = d.admin || {};
|
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
|
||||||
let cur = (d.admin.setmode || 'setprice'), o;
|
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
|
||||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||||
o = $ocms.dlg(fr, { width: 800 });
|
o = $ocms.dlg(fr, { width: 800 });
|
||||||
};
|
};
|
||||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
|
||||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
|
||||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
sums each top-level set's positions onto the set line and either nulls those positions' prices
|
||||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
|
||||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
|
||||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
pending local edits first so a delayed block.replace can't land after the conversion and
|
||||||
|
overwrite it (same race guard as $inv.toSetPrice). */
|
||||||
$inv.setSetmode = (mode) => {
|
$inv.setSetmode = (mode) => {
|
||||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -39,7 +39,8 @@ let $rct = {
|
|||||||
combP: 'Positionen zusammenfassen',
|
combP: 'Positionen zusammenfassen',
|
||||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
toSetMc: 'Jedes Set erhält die Summe seiner Positionen als Set-Preis; die Positionen des Sets werden ohne Preis dargestellt bzw. ausgeblendet. Positionen ausserhalb eines Sets bleiben unverändert einzeln aufgeführt. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||||
|
toSetMn: 'Diese Rechnung enthält kein Set. Legen Sie zuerst über "Set-Struktur bearbeiten" ein Set an bzw. ordnen Sie Positionen einem Set zu.',
|
||||||
iSum: 'Zwischensumme',
|
iSum: 'Zwischensumme',
|
||||||
dtRel: 'Freigegeben am: ',
|
dtRel: 'Freigegeben am: ',
|
||||||
dtCr: 'Erstellt am: ',
|
dtCr: 'Erstellt am: ',
|
||||||
@@ -638,11 +639,11 @@ $inv.iMn = (ix) => {
|
|||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m);
|
return $('#topbar').ocmsmenu(m);
|
||||||
}
|
}
|
||||||
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
|
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
|
||||||
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
|
modes convert every still-unconverted SET (not the whole invoice), so running them again after
|
||||||
changing it again is not supported (the user edits the line items directly instead); it
|
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
|
||||||
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
|
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
|
||||||
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
|
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
|
||||||
$inv.eM = (r, re, opt) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -738,10 +739,7 @@ $inv.d = {
|
|||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||||
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
||||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
|
||||||
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
|
|
||||||
$inv.eM(false, true, $inv.emOpt(state.admin || {}));
|
|
||||||
},
|
},
|
||||||
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
|
||||||
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
|
||||||
@@ -758,10 +756,10 @@ $inv.d = {
|
|||||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
|
||||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
|
||||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
|
||||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
patch/insert the matching row, preserving null price fields verbatim (null = empty cell, kept
|
||||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||||
applyItems: function (tbl, req) {
|
applyItems: function (tbl, req) {
|
||||||
@@ -1324,7 +1322,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
$inv.eM(false, true, $inv.emOpt(admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -1469,7 +1467,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
|||||||
|
|
||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
$inv.eM(false, true, $inv.emOpt(response.admin));
|
$inv.eM(false, true, $inv.emOpt());
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
@@ -1867,7 +1865,7 @@ $inv.rrw = function () {
|
|||||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||||
} else {
|
} else {
|
||||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. a set line) */
|
||||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||||
co.v = dta.net
|
co.v = dta.net
|
||||||
co.vt = dta.net_val
|
co.vt = dta.net_val
|
||||||
@@ -1886,8 +1884,8 @@ $inv.rrw = function () {
|
|||||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||||
} else {
|
} else {
|
||||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
|
||||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
converted to a set price, and a set line's own unit price), distinct from a real 0,00 €. */
|
||||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||||
Array.prototype.push.apply(tda, [
|
Array.prototype.push.apply(tda, [
|
||||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||||
@@ -1896,7 +1894,7 @@ $inv.rrw = function () {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows */
|
||||||
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
||||||
.tC('hidenote', hn).append(tda);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
@@ -2144,7 +2142,7 @@ $inv.itemToContract = function (rrx) {
|
|||||||
let oHtml = (e) => $$.d().append(e).html();
|
let oHtml = (e) => $$.d().append(e).html();
|
||||||
let type = (rrx.Type || '').toString().toLowerCase();
|
let type = (rrx.Type || '').toString().toLowerCase();
|
||||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
nulled set members and the set line round-trip unchanged when the block is re-posted. */
|
||||||
let nz = (x) => (x == null ? null : x);
|
let nz = (x) => (x == null ? null : x);
|
||||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||||
if (rrx.co && rrx.co.typ === 'osum') {
|
if (rrx.co && rrx.co.typ === 'osum') {
|
||||||
@@ -2172,26 +2170,38 @@ $inv.itemToContract = function (rrx) {
|
|||||||
}
|
}
|
||||||
return ci;
|
return ci;
|
||||||
};
|
};
|
||||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
/* Whether the authoritative state carries at least one SET line that owns members — the two
|
||||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
|
||||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
|
||||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
|
||||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
|
||||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
let isSet = (co) => ((co.typ || '').toString().toLowerCase() === 'set');
|
||||||
|
return itms.some((co) => {
|
||||||
|
let p = (co.SetItmId == null ? '' : co.SetItmId).toString();
|
||||||
|
return p !== '' && p !== (co.id || '').toString() && byId[p] && isSet(byId[p]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* 2-way set-pricing switch (SetPrice / SetOnly), ADR 0019: both modes give EVERY set the sum of
|
||||||
|
its positions as one set price; "Set mit Preis" keeps those positions without a price, "Nur Set
|
||||||
|
mit Preis" removes them. Positions outside any set stay individually priced either way, and
|
||||||
|
several sets stay several separate lines. ItemPrices ("Positionen mit Preis – Set als
|
||||||
|
Überschrift") was removed: it is just the pre-conversion default and not a state one can switch
|
||||||
|
back to, since every conversion is one-way (see $inv.toSetPrice /
|
||||||
|
InvoiceDraftEditService.ApplyItemSetPrice). */
|
||||||
$inv.ssetmode = () => {
|
$inv.ssetmode = () => {
|
||||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
|
||||||
d.admin = d.admin || {};
|
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
|
||||||
let cur = (d.admin.setmode || 'setprice'), o;
|
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
|
||||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||||
o = $ocms.dlg(fr, { width: 800 });
|
o = $ocms.dlg(fr, { width: 800 });
|
||||||
};
|
};
|
||||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
|
||||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
|
||||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
sums each top-level set's positions onto the set line and either nulls those positions' prices
|
||||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
|
||||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
|
||||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
pending local edits first so a delayed block.replace can't land after the conversion and
|
||||||
|
overwrite it (same race guard as $inv.toSetPrice). */
|
||||||
$inv.setSetmode = (mode) => {
|
$inv.setSetmode = (mode) => {
|
||||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user