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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 22:19:01 +02:00
co-authored by Claude Fable 5.1
parent 22d2ef9c1a
commit 5fa7a2b50f
16 changed files with 678 additions and 447 deletions
+157 -46
View File
@@ -413,105 +413,216 @@ public class InvoiceDraftServiceTests
Assert.Equal(125m, s2.Sums.TotalNet); 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]
+12
View File
@@ -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()
{ {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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.
+115 -123
View File
@@ -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,137 +235,141 @@ 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 = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0; decimal sumNet = ConvertSetHeader(block, lines, header);
foreach (var m in members)
{
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
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
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
}
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
newValue = sumNet.ToString(CultureInfo.InvariantCulture); 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)
{
foreach (var itemTok in items)
{
if (itemTok is not JObject io) continue;
string id = Str(io["id"]);
if (id == d.Ref) { io["total_net"] = sumNet; }
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
}
}
return true; return true;
} }
return false; return false;
} }
/// <summary> /// <summary>
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b> /// The single set-conversion primitive: sums the <c>vt</c>/<c>vv</c>/<c>vs</c>/<c>vsv</c> of the
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the /// header's <b>transitive</b> descendants (<see cref="InvoiceSetStructure.Descendants"/>, ADR 0016
/// cached session — never a display flag. For every block that carries any priced line: /// — a nested unconverted set contributes its members' prices, a nested already-converted set its
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the /// own set price, so nothing is counted twice), writes that sum onto the header and nulls every
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original /// descendant's price. <c>null</c> (not <c>0</c>) marks "no price": an empty cell excluded from the
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit /// sum, distinct from a genuine 0,00 € (ADR 0009). The conversion is mirrored onto the <c>items</c>
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true → /// contract shape so <see cref="InvoiceSetPricing"/>/<c>BuildSetDisplay</c> — which read
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded /// <c>total_net</c> from there — see the set as converted immediately, without a reload.
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised /// Returns the set's net total. The invoice total is conserved.
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
/// or removes.
/// </summary> /// </summary>
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue) 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;
foreach (var m in members)
{
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
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
NullLinePrice(m);
}
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
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)
{
if (itemTok is not JObject io) continue;
string id = Str(io["id"]);
// Both price fields: the flat PDF path (MapItemToLine, taken once the members are
// 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 sumNet;
}
/// <summary>
/// The two menu set-price modes (ADR 0019, superseding the per-block collapse of ADR 0009):
/// applied server-side as an <b>irreversible mutation</b> of the cached session, keyed on the
/// user-defined <b>set structure</b> (ADR 0016) — never on the service-request block and never a
/// display flag. Every still-unconverted <b>top-level</b> set of every block is converted via
/// <see cref="ConvertSetHeader"/>, so each set carries one price and its members render with an
/// empty price cell ("Set mit Preis", <paramref name="removeMembers"/> = false) or are removed from
/// the dataset entirely ("Nur Set mit Preis", <paramref name="removeMembers"/> = true). Nested sets
/// are part of their top-level set's subtree, so they are blanked/removed with it rather than
/// priced twice.
/// <para>
/// Lines that belong to <b>no</b> set are left completely untouched: they keep their individual
/// price and are listed as ordinary positions. Sets and standalone lines together make up the
/// 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>
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"] = "",
["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; ConvertSetHeader(block, lines, header);
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull(); mutated = true; converted++;
NullLinePrice(co);
} }
lines.Insert(0, setItm); if (removeMembers)
foreach (var m in members)
if (block["items"] is JArray items)
{
if (removeMembers) items.Clear();
else
foreach (var itemTok in items)
{ {
if (itemTok is not JObject io) continue; string mid = Str(m["id"]);
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull(); if (mid.Length > 0) dropIds.Add(mid);
NullItemPrice(io);
} }
items.Insert(0, setItem);
}
else
{
block["items"] = new JArray { setItem };
} }
mutated = true; converted++; 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))
+5 -3
View File
@@ -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)
+9 -5
View File
@@ -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
+45 -36
View File
@@ -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';
+2 -1
View File
@@ -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: ',
+47 -37
View File
@@ -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';
File diff suppressed because one or more lines are too long
+47 -37
View File
@@ -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';
File diff suppressed because one or more lines are too long