Compare commits

..
2 Commits
Author SHA1 Message Date
StefanandClaude Sonnet 5 52b2811b43 Render invoice-editor <img> elements on their own line
Playwright Tests / test (push) Canceled after 0s
<img> is a nominally inline HTML element, but TinyMCE content places images
between paragraphs of text; without a forced line break before/after, an
image could render packed against adjacent text in the PDF. Insert a line
break ahead of the image (unless one is already there) and always after it.
Also fixes the img handler's early-return path to still apply the inline
size cap (MaxInlineImageWidth/Height) when only one of width/height was
resized, matching the aspect-ratio-locked branch.

Fuchs/wwwroot/web/tools.js: cosmetic gulp rebuild diff (leading blank line),
no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:20:44 +02:00
StefanandClaude Fable 5.1 5fa7a2b50f 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>
2026-09-10 22:19:01 +02:00
18 changed files with 698 additions and 455 deletions
+157 -46
View File
@@ -413,105 +413,216 @@ public class InvoiceDraftServiceTests
Assert.Equal(125m, s2.Sums.TotalNet);
}
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
private static JObject BlocksPayload() => JObject.Parse(@"{
// ── Set-price menu modes (ADR 0019): keyed on the SET, never on the block ──
// Two service-request blocks, each with one set, plus a standalone item outside any set:
// block 1: set '10' { '11' 60 net, '12' 40 net (service) } + '13' 25 net, no set
// block 2: set '20' { '21' 30 net }
// Expected for both modes: every set gets the sum of its own positions, several sets stay
// several separate lines, and '13' keeps its individual price and stays listed.
private static JObject SetsAndLooseItemsPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[
{'Id':'1','text':'Auftrag A','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%','SetItmId':'10'},
{'id':'13','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'},
{'id':'13','type':'material','total_net':25,'vat':'19%'}]},
{'Id':'2','text':'Auftrag B','itm':[
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
{'id':'20','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'},
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'}],
'items':[{'id':'20','type':'set','total_net':0},
{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
}");
[Fact]
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
public void ApplyPatch_SetPrice_PricesEverySet_BlanksItsMembers_LeavesNonSetItemsPriced()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
Assert.Equal(155m, s.Sums.TotalNet); // 100 (set A) + 25 (loose) + 30 (set B)
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
var setA = (JObject)linesA[0];
Assert.Equal("set", (string)setA["typ"]!);
Assert.Equal("bset_1", (string)setA["id"]!);
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
// No row is inserted or dropped — the existing set line carries the price now.
Assert.Equal(new[] { "10", "11", "12", "13" }, LineOrder(s2));
var setA = LineOf(s2, "10");
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // its own members only (60 + 40), NOT the block (125)
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
Assert.Equal(JTokenType.Null, LineOf(s2, "11")["vt"]!.Type); // member: null (empty cell), not 0
Assert.Equal(JTokenType.Null, LineOf(s2, "12")["vt"]!.Type);
// items contract mirrors it (PDF reads total_net from here).
// The item outside the set keeps its own price and is still listed individually.
Assert.Equal(25m, LineOf(s2, "13")["vt"]!.Value<decimal>());
Assert.Equal(4.75m, LineOf(s2, "13")["vv"]!.Value<decimal>());
// The second set is a separate priced line, not merged with the first.
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "21", block: 1)["vt"]!.Type);
// items contract mirrors it (the PDF reads total_net from here).
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>());
Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["total_net"]!.Value<decimal>());
Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["price_net"]!.Value<decimal>()); // unit price too (flat PDF path)
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
Assert.Equal(25m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "13")["total_net"]!.Value<decimal>());
// second block converted too; total conserved.
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
Assert.Equal(130m, s2.Sums.TotalNet);
Assert.Equal(24.7m, s2.Sums.TotalVat);
// Sets + loose items make up the unchanged invoice total.
Assert.Equal(155m, s2.Sums.TotalNet);
Assert.Equal(29.45m, s2.Sums.TotalVat);
}
[Fact]
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
public void ApplyPatch_SetOnly_PricesEverySet_RemovesItsMembers_KeepsNonSetItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
Assert.Single(linesA); // members removed, only the set row remains
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
// Set members gone; the set line and the item that belongs to no set remain.
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>());
Assert.Equal(25m, LineOf(s2!, "13")["vt"]!.Value<decimal>());
Assert.Equal(new[] { "10", "13" },
((JArray)((JObject)s2!.Req[0])["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray());
// The second set stays a separate line of its own.
Assert.Equal(new[] { "20" }, LineOrder(s2, block: 1));
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(155m, s2.Sums.TotalNet); // total unchanged
}
[Fact]
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
public void ApplyPatch_SetOnly_AlreadyConvertedSet_KeepsItsPrice_AndStillRemovesMembers()
{
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var conv = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" }); // inline switch first
Assert.Equal(100m, LineOf(conv!, "10")["vt"]!.Value<decimal>());
// A repeated conversion is a no-op — it must never re-sum the already-nulled members to 0.
var again = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" });
Assert.Equal(1, again!.Version);
Assert.Equal(100m, LineOf(again, "10")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>()); // not re-summed to 0 from nulled members
Assert.Equal(155m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_NestedSets_OnlyTheTopLevelSetIsPriced_TotalNotDoubled()
{
// A { B { m1 60, m2 40 }, m3 25 } plus loose 'x' 10 — the outer set takes 125, the inner
// set line is part of that subtree and is blanked, so 125 is never counted twice.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(135m, s.Sums.TotalNet);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type); // nested set: blanked with the subtree
Assert.Equal(JTokenType.Null, LineOf(s2!, "m1")["vt"]!.Type);
Assert.Equal(JTokenType.Null, LineOf(s2!, "m3")["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // outside every set: untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetOnly_NestedSets_RemovesTheWholeSubtreeIncludingTheNestedSetLine()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "A", "x" }, LineOrder(s2!));
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_ReflectedInBuildSetDisplay_MembersBlankStandaloneStillPriced()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
var setDisplay = (JObject)state["setDisplay"]!;
Assert.False(setDisplay.ContainsKey("bset_1"));
Assert.Empty(setDisplay);
Assert.True(setDisplay["10"]!["p"]!.Value<bool>()); // the set is priced …
Assert.True(setDisplay["10"]!["h"]!.Value<bool>()); // … and emphasised as a set header
Assert.False(setDisplay["11"]!["p"]!.Value<bool>()); // its members show no price
Assert.False(setDisplay["12"]!["p"]!.Value<bool>());
Assert.True(setDisplay["13"]!["p"]!.Value<bool>()); // the item outside the set keeps its price
}
[Fact]
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
[Theory]
[InlineData("block.setprice")]
[InlineData("block.setonly")]
public void ApplyPatch_SetModes_InvoiceWithoutAnySet_IsNoOp(string target)
{
// Nothing is grouped into a set, so there is nothing to collapse — every line stays as it
// is (ADR 0019: the modes never touch lines outside a set), and no version/history is spent.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'}]},
{'Id':'2','text':'Leer','itm':[],'items':[]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_EmptySet_IsNoOp()
{
// A set line with no members has nothing to sum — converting it would blank the whole
// invoice's only priced line into a 0,00 € set.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'}]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
Assert.Empty(s2.History);
Assert.Equal(0, s2!.Version);
Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
}
[Fact]
+12
View File
@@ -60,6 +60,18 @@ public class InvoiceSetPricingTests
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
}
[Fact]
public void Build_MemberWithNullTotal_UnderUnconvertedHeader_ShowsNoPrice()
{
// A price-nulled member ("no price", ADR 0009/0019) must render an empty cell even when its
// header reads as unconverted again (e.g. hand-edited back to 0) — never 0,00 €.
var header = SetHeader("10", "Set"); // total 0 -> unconverted
var nulled = Member("10", "A", "0", "0"); nulled["total_net"] = null;
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, nulled, Member("10", "B", "5.00", "5.00") }, SetDisplayMode.SetPrice);
Assert.False(lines[1].ShowPrice);
Assert.True(lines[2].ShowPrice); // a real price still shows
}
[Fact]
public void ContainsSets_DetectsHeaderOrMember()
{
+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).
`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.
### 4.4 SQL-First Data Access
+2 -2
View File
@@ -19,8 +19,8 @@ applyTo:
relatedDecisions:
- "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md"
- "0019-set-price-modes-are-keyed-on-the-set.md"
---
# Live draft editing (backend-authoritative invoice previews)
@@ -170,5 +170,5 @@ amount pair.
## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.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)
- [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
applyTo:
- "Fuchs/code/InvoiceSetPricing.cs"
@@ -10,7 +10,7 @@ applyTo:
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
- "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
@@ -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
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor) and ADR
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit,
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the
> reversible, non-mutating display toggles this document previously described into irreversible,
> per-service-request-block mutations — the text below reflects the redefinition.
> Governed by ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)
> (the two menu modes — **supersedes** ADR
> [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md), which had keyed them on
> the service-request block), ADR
> [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) (everything
> server-side, PDF == editor) and ADR [0016](Decisions/0016-user-defined-set-structure.md) (set
> 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
confused, because they group items by different keys and differ in whether they mutate the data:
There are **three** set-price operations. All three are **irreversible mutations** of the
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** |
| **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** |
| **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** |
| **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 – 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 – 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
grouping key is the service request (the editor tbody block). See "All set-price functions" below
for the full breakdown.
What the two menu modes explicitly do **not** do (they did, under the superseded ADR 0009): they do
not treat a service request as an implicit set, do not insert a "Gesamtumfang pauschal" row, and do
not clear anyone's set membership.
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
> the set row is a real, editable line item precisely so the user can adjust the set value
> afterwards.
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
> 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.
- **Lines outside every set stay untouched** — individual price, listed as ordinary positions.
Sets and standalone lines together make up the invoice total.
- **Several sets stay several separate lines**, in the same block or across blocks.
- **Only top-level sets are priced.** A nested set belongs to its parent's subtree, so it is blanked
(mode 2) or removed (mode 3) with it and never priced twice.
- **No sets, or a set without members → no-op** (no version bump, no history). The editor checks the
authoritative state first and, when the invoice has no set at all, points the user at
"Set-Struktur bearbeiten" instead of opening the mode dialog.
- **Repeatable, still one-way.** Running a mode again only converts sets that are still unconverted;
nothing converts back. The only ways back are discarding the draft (reloads the DB state) or
hand-editing the resulting rows — the set line is a real, editable item precisely for that.
> **Totals are unaffected.** The invoice total is taken from the registration balance
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
> header.
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines, and every operation
> conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes.
> `null` (not `0`) marks "no price" — an empty cell excluded from the sum, distinct from a genuine
> `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
@@ -59,11 +62,11 @@ for the full breakdown.
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
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
or at the top level → `set.create { typ, title, parent }`.
new **Set** (zero-priced header, convertible via the operations above) or **Text** line inside any
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
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls
them all; converting inner-then-outer or outer directly conserves the total either way.
deeper). Every conversion sums the header's **transitive** descendants and nulls them all;
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
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
merely greys them out.
@@ -75,65 +78,72 @@ for the full breakdown.
## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
> migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `Fuchs/Services/InvoiceDraftEditService.cs` — the three triggers:
- `ConvertSetHeader(block, lines, header)` is the **single conversion primitive**: sums the
header's transitive descendants (`InvoiceSetStructure.Descendants`) onto the header
(`v`/`vt`/`vv`/`vs`/`vsv`, adopting the members' VAT rate when the header has none), nulls every
descendant's price fields, and mirrors the result onto the `items` contract shape
(`total_net` on the header, `null` on the members) so the editor and the PDF see the conversion
without a reload.
- `ApplyItemSetPrice` (`item.setprice`) resolves one header by `Ref` and delegates. It is a no-op
when the `Ref` is unknown, is not a set line, or the set is **already converted** (own price
non-zero) — that guard keeps a repeated delta from re-summing already-nulled members to 0.
- `ApplySetPricing(removeMembers)` (`block.setprice` / `block.setonly`) walks every block, takes
the **top-level** set lines (`IsSet` && `ParentOf == null`), converts the unconverted ones via
the primitive, and — for `setonly` — removes each such set's whole subtree from `itm` and
`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
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
set header line is rendered emphasised. Invoices without sets pass through
unchanged.
- `Fuchs/code/InvoiceSetPricing.cs` — the render transformation:
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*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)
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
Preis". Selecting one posts a **mutating delta** (grouped by service-request
block) to `inv/dpatch`; the server rewrites the block's items in the cached
session (inserts the set row, blanks or removes members) and pushes the new
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
these two modes.
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, menu entry
`setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit Preis". `$inv.hasSets(state)`
first checks the authoritative `dstate` for a line that points at a set line; if there is none,
the user is told to define a set first ($rct.toSetMn) and nothing is posted. Otherwise selecting a
mode posts a **mutating delta** (`block.setprice` / `block.setonly`) to `inv/dpatch`; the server
rewrites the sets in the cached session and pushes the new state back via `draftReady`/`dstate`.
The browser performs **no** grouping or pricing math (ADR 0008/0019) — it only posts the chosen
mode and re-renders the server's `req`/`sums`. Pending local edits are flushed first
(`$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
`items[]` in the back-end contract shape via `$inv.itemToContract`:
2. **Item shape** — `$inv.invSumUpdate` posts each request block's `items[]` in the back-end
contract shape via `$inv.itemToContract`:
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
editor only posted the legacy on-screen `itm`/`co` objects, which
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
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[]`:
an item with `type === 'set'` is a header (`id` = its set id); a member's
`setId` is taken directly from the `SetItmId` field on the row
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
maintained by the server per ADR 0016, including for a **nested set**, which is a member of
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no
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 following a set in the list are never swept in. The header row's
own `SetItmId` self-references its own id (rather than being `null`); it is
explicitly excluded from being its own member both here (`sid !== citem.id`) and
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.
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with `type === 'set'`
is a header (`id` = its set id); a member's `setId` is taken directly from the `SetItmId` field on
the row (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window
function, anchored on the still-unconverted, zero-priced `'set'` header that owns it, and
thereafter maintained by the server per ADR 0016, including for a **nested set**, which is a
member of its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself
still has no 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
following a set in the list are never swept in. The header row's own `SetItmId` self-references
its own id (rather than being `null`); it is explicitly excluded from being its own member both
here (`sid !== citem.id`) and in `InvoiceSetStructure.ParentOf`. These flags feed **all three**
set-price operations (ADR 0019).
### Editor → backend field normalization (`$inv.invcPayload`)
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 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
equals the members it blanks/removes; the set-item switch writes exactly the
members' sum onto the header).
the registration balance — each operation conserves the total.
### Why the trigger lives in the editor
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
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
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
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:
### 1. The set-item switch (`item.setprice`, one set)
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
(one mfr set-item and its members) and is triggered per set row.
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
entirely, and are triggered once from the "Set-Preisanzeige" menu.
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`). The context button is
shown — and the operation available — **only** on a row that is `typ == "set"` and is still
unconverted (own price `0`).
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
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 |
| Aspect | Before | After |
|---|---|---|
| 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 |
| 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 |
| 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` |
| 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 |
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded 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 |
| 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 |
| 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 |
| 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 | fixed by the server (`fds__prepInvoice` + the user's edits, ADR 0016) | **unchanged** — never re-derived or reassigned |
| Lines outside the set | untouched | **still untouched**, regardless of row order |
| Draft version / history | — | version +1, `item.setprice` entry with old/new header value |
| Invoice total | — | **unchanged** |
| 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)
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:
### 2 & 3. The two menu modes (every set, one action)
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|---|---|---|
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
| 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 |
| `SetItmId` | ignored | ignored |
| Grouping key | the set (top-level sets of every block) | the set (top-level sets of every block) |
| Set line | receives the sum of its subtree | receives the sum of its subtree |
| Its member rows | **kept**, every price field `null` → empty price/total cell | **removed** from `itm` and `items` entirely (nested set lines included) |
| 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) |
| 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 |
| Empty block | no-op | no-op |
### 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)).
| Invoice total | **unchanged** | **unchanged** |
| Invoice without sets / empty set | no-op (editor tells the user to define a set) | no-op (same) |
## Persistence note
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
directly into the session's items (a set row plus blanked/removed members), so no
`setmode:` display token is needed or persisted; the finalised document is
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
invoice must be **re-generated** from stored items later — not done here.
works end-to-end for previews and creation. The menu modes bake their result directly into the
session's items (set line priced, members blanked or removed), so no `setmode:` display token is
needed or persisted; the finalised document is rendered once and stored as a file. Persisting the
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is only required if a
finalised invoice must be **re-generated** from stored items later — not done here.
+104 -112
View File
@@ -123,8 +123,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
case "block.remove": return RemoveBlock(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 "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, 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 ApplySetPricing(s, removeMembers: true, ref oldValue, ref newValue);
case "set.assign":
case "set.level":
case "set.create":
@@ -221,23 +221,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
/// <summary>
/// "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
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
/// 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>
/// single set price, by delegating to <see cref="ConvertSetHeader"/> — the one conversion
/// primitive shared with the two menu modes (<see cref="ApplySetPricing"/>, ADR 0019).
/// The user can still change the resulting header price manually afterwards (ordinary item
/// edit), which is why this is one-way.
/// </summary>
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
@@ -248,10 +235,36 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var lines = InvoiceSetStructure.Lines(block);
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
if (header == null) continue;
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
if (!InvoiceSetStructure.IsSet(header)) return false;
// 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);
decimal sumNet = ConvertSetHeader(block, lines, header);
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
return true;
}
return false;
}
/// <summary>
/// The single set-conversion primitive: sums the <c>vt</c>/<c>vv</c>/<c>vs</c>/<c>vsv</c> of the
/// header's <b>transitive</b> descendants (<see cref="InvoiceSetStructure.Descendants"/>, ADR 0016
/// — a nested unconverted set contributes its members' prices, a nested already-converted set its
/// own set price, so nothing is counted twice), writes that sum onto the header and nulls every
/// descendant's price. <c>null</c> (not <c>0</c>) marks "no price": an empty cell excluded from the
/// sum, distinct from a genuine 0,00 € (ADR 0009). The conversion is mirrored onto the <c>items</c>
/// contract shape so <see cref="InvoiceSetPricing"/>/<c>BuildSetDisplay</c> — which read
/// <c>total_net</c> from there — see the set as converted immediately, without a reload.
/// Returns the set's net total. The invoice total is conserved.
/// </summary>
private static decimal ConvertSetHeader(JObject block, List<JObject> lines, JObject header)
{
string headerId = Str(header["id"]);
var members = InvoiceSetStructure.Descendants(lines, headerId);
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
foreach (var m in members)
{
@@ -260,125 +273,103 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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 €
NullLinePrice(m);
}
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
// read total_net from here, not from "itm") so the online editor's set-display immediately
// reflects that the set is now converted, instead of waiting for a reload.
if (block["items"] is JArray items)
{
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"]);
if (id == d.Ref) { io["total_net"] = sumNet; }
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
// 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 true;
}
return false;
return sumNet;
}
/// <summary>
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
/// cached session — never a display flag. For every block that carries any priced line:
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
/// (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.
/// 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 ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
private static bool ApplySetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
{
bool mutated = false;
int converted = 0;
int converted = 0, removed = 0;
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).
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
string rate = "";
foreach (var lineTok in lines)
// Top-level sets only: a nested set is a member of its parent's subtree and is
// blanked/removed with it, so converting both would count its value twice.
foreach (var header in lines.Where(co => InvoiceSetStructure.IsSet(co)
&& InvoiceSetStructure.ParentOf(co, byId) == null).ToList())
{
if (lineTok is not JObject co) continue;
net += InvoiceDraftCalculator.Dec(co["vt"]);
vat += InvoiceDraftCalculator.Dec(co["vv"]);
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
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 headerId = Str(header["id"]);
if (headerId.Length == 0) continue;
var members = InvoiceSetStructure.Descendants(lines, headerId);
if (members.Count == 0) continue; // an empty set has nothing to collapse
string blockId = Str(block["Id"]);
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
var setItm = new JObject
// Already converted (own price non-zero, e.g. via the inline item switch): keep its
// price as it stands — but "Nur Set mit Preis" still drops the members below.
if (InvoiceDraftCalculator.Dec(header["vt"]) == 0)
{
["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;
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
NullLinePrice(co);
}
lines.Insert(0, setItm);
if (block["items"] is JArray items)
{
if (removeMembers) items.Clear();
else
foreach (var itemTok in items)
{
if (itemTok is not JObject io) continue;
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
NullItemPrice(io);
}
items.Insert(0, setItem);
}
else
{
block["items"] = new JArray { setItem };
}
ConvertSetHeader(block, lines, header);
mutated = true; converted++;
}
if (removeMembers)
foreach (var m in members)
{
string mid = Str(m["id"]);
if (mid.Length > 0) dropIds.Add(mid);
}
}
if (dropIds.Count > 0)
{
int gone = RemoveById(lineArr, dropIds);
if (block["items"] is JArray itemsArr) RemoveById(itemsArr, dropIds);
if (gone > 0) { mutated = true; removed += gone; }
}
}
oldValue = "";
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted + (removeMembers ? "/" + removed : "");
return mutated;
}
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
private static void DemoteSet(JObject line, string typeKey)
/// <summary>Removes every entry of the array whose <c>id</c> is in <paramref name="ids"/>; returns how many went.</summary>
private static int RemoveById(JArray arr, HashSet<string> ids)
{
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
line[typeKey] = "other";
int n = 0;
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>
@@ -478,8 +469,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
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)
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
// Only blocks that actually carry a set need flags; a block of plain, individually
// 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;
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.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
// Blocks that carry a set (a line pointing at a set header via setId) render through Build,
// which blanks/drops the members of a converted set; blocks without any set render flat —
// nulled prices map to blank cells either way (see MapItemToLine). Since ADR 0019 the menu
// modes convert per set (not per block) and leave non-set lines individually priced, so both
// paths print exactly what the editor shows.
var blockLines = inv.InvoiceBlocks
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
+9 -5
View File
@@ -97,10 +97,10 @@ public static class InvoiceSetPricing
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
/// True if the block carries a set (a line points at a set header via <c>setId</c>) — the only
/// case that routes through <see cref="Build"/>. Since ADR 0019 every set-price operation — the
/// inline set-item switch and both menu modes — preserves that membership, so this is what
/// renders their result too; a block of lines that belong to no set renders flat.
/// </summary>
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
@@ -227,6 +227,10 @@ public static class InvoiceSetPricing
{
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
// A JSON-null total_net is "no price" (ADR 0009/0019) — an empty cell, never 0,00 € — exactly
// as the flat path (FuchsPdf.MapItemToLine) treats it. Matters for a price-nulled member whose
// set was later hand-edited back to 0 (and so reads as unconverted again).
bool hasPrice = !i.TryGetValue("total_net", out var tv) || tv != null;
return new InvoiceSetLine
{
Id = i.nz("id", ""),
@@ -235,7 +239,7 @@ public static class InvoiceSetPricing
Qty = i.nz("qty", "1"),
PriceNet = price,
TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
ShowPrice = showPrice && hasPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false,
Level = level
+13 -2
View File
@@ -122,7 +122,9 @@ namespace MigraDoc.Extensions.Html
// else (including any remote URL the caller didn't fetch/vet) is silently dropped, same
// as every other unsupported element. Size mirrors what the editor shows (the width/height
// TinyMCE writes onto the <img> from its resize handles), capped so a stray huge size from
// hand-edited HTML can't blow out the invoice table layout.
// hand-edited HTML can't blow out the invoice table layout. Always rendered alone on its
// own line — surrounded by line breaks rather than left inline with adjacent text — even
// though <img> is nominally an inline HTML element.
_handlers["img"] = (n, p) =>
{
string src = n.GetAttributeValue("src", "");
@@ -130,6 +132,11 @@ namespace MigraDoc.Extensions.Html
return p;
var para = GetPara(p);
if (para == null) return p;
var existing = para.Elements.Cast<DocumentObject>().ToList();
bool lastIsLineBreak = existing.Count > 0 && existing[^1] is Character { SymbolName: SymbolName.LineBreak };
if (existing.Count > 0 && !lastIsLineBreak) para.AddLineBreak();
var img = para.AddImage(source);
img.LockAspectRatio = true;
@@ -152,13 +159,17 @@ namespace MigraDoc.Extensions.Html
img.Width = Unit.FromCentimeter(img.Width.Centimeter * scale);
img.Height = Unit.FromCentimeter(img.Height.Centimeter * scale);
}
return para;
}
else
{
if (wPx is > 0) img.Width = PxToUnit(wPx.Value);
else if (hPx is > 0) img.Height = PxToUnit(hPx.Value);
if (img.Width > MaxInlineImageWidth) { img.LockAspectRatio = true; img.Width = MaxInlineImageWidth; }
if (img.Height > MaxInlineImageHeight) { img.LockAspectRatio = true; img.Height = MaxInlineImageHeight; }
}
para.AddLineBreak();
return para;
};
+45 -36
View File
@@ -82,11 +82,11 @@ $inv.iMn = (ix) => {
}
return $('#topbar').ocmsmenu(m);
}
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
modes convert every still-unconverted SET (not the whole invoice), so running them again after
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -182,10 +182,7 @@ $inv.d = {
$inv.d.applyItems(tbl, state.req || []);
$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) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
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 || {}));
$inv.eM(false, true, $inv.emOpt());
},
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
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. */
/* 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
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
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.
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
applyItems: function (tbl, req) {
@@ -768,7 +765,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -913,7 +910,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(response.admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
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 || ''));
} else {
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.v = dta.net
co.vt = dta.net_val
@@ -1330,8 +1327,8 @@ $inv.rrw = function () {
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
} else {
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"
members, and the set row's own unit-price), distinct from a real 0,00 €. */
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
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));
Array.prototype.push.apply(tda, [
$$.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)
.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) */
.tC('hidenote', hn).append(tda);
dta.co = co;
@@ -1588,7 +1585,7 @@ $inv.itemToContract = function (rrx) {
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
/* 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 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') {
@@ -1616,26 +1613,38 @@ $inv.itemToContract = function (rrx) {
}
return ci;
};
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
always just the implicit pre-conversion default and is not a state the user can switch back to
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
/* Whether the authoritative state carries at least one SET line that owns members — the two
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
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 = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
set row per block (block sum) and either nulls each member's price (setprice) or removes the
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
sums each top-level set's positions onto the set line and either nulls those positions' prices
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
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) => {
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
+2 -1
View File
@@ -39,7 +39,8 @@
combP: 'Positionen zusammenfassen',
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?',
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',
dtRel: 'Freigegeben am: ',
dtCr: 'Erstellt am: ',
+47 -37
View File
@@ -39,7 +39,8 @@ let $rct = {
combP: 'Positionen zusammenfassen',
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?',
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',
dtRel: 'Freigegeben am: ',
dtCr: 'Erstellt am: ',
@@ -657,11 +658,11 @@ $inv.iMn = (ix) => {
}
return $('#topbar').ocmsmenu(m);
}
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
modes convert every still-unconverted SET (not the whole invoice), so running them again after
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -757,10 +758,7 @@ $inv.d = {
$inv.d.applyItems(tbl, state.req || []);
$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) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
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 || {}));
$inv.eM(false, true, $inv.emOpt());
},
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
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. */
/* 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
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
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.
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
applyItems: function (tbl, req) {
@@ -1343,7 +1341,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -1488,7 +1486,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(response.admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
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 || ''));
} else {
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.v = dta.net
co.vt = dta.net_val
@@ -1905,8 +1903,8 @@ $inv.rrw = function () {
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
} else {
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"
members, and the set row's own unit-price), distinct from a real 0,00 €. */
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
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));
Array.prototype.push.apply(tda, [
$$.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)
.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) */
.tC('hidenote', hn).append(tda);
dta.co = co;
@@ -2163,7 +2161,7 @@ $inv.itemToContract = function (rrx) {
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
/* 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 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') {
@@ -2191,26 +2189,38 @@ $inv.itemToContract = function (rrx) {
}
return ci;
};
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
always just the implicit pre-conversion default and is not a state the user can switch back to
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
/* Whether the authoritative state carries at least one SET line that owns members — the two
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
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 = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
set row per block (block sum) and either nulls each member's price (setprice) or removes the
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
sums each top-level set's positions onto the set line and either nulls those positions' prices
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
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) => {
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
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',
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?',
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',
dtRel: 'Freigegeben am: ',
dtCr: 'Erstellt am: ',
@@ -638,11 +639,11 @@ $inv.iMn = (ix) => {
}
return $('#topbar').ocmsmenu(m);
}
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
modes convert every still-unconverted SET (not the whole invoice), so running them again after
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -738,10 +739,7 @@ $inv.d = {
$inv.d.applyItems(tbl, state.req || []);
$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) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
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 || {}));
$inv.eM(false, true, $inv.emOpt());
},
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
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. */
/* 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
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
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.
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
applyItems: function (tbl, req) {
@@ -1324,7 +1322,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -1469,7 +1467,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(response.admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
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 || ''));
} else {
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.v = dta.net
co.vt = dta.net_val
@@ -1886,8 +1884,8 @@ $inv.rrw = function () {
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
} else {
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"
members, and the set row's own unit-price), distinct from a real 0,00 €. */
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
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));
Array.prototype.push.apply(tda, [
$$.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)
.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) */
.tC('hidenote', hn).append(tda);
dta.co = co;
@@ -2144,7 +2142,7 @@ $inv.itemToContract = function (rrx) {
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
/* 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 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') {
@@ -2172,26 +2170,38 @@ $inv.itemToContract = function (rrx) {
}
return ci;
};
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
always just the implicit pre-conversion default and is not a state the user can switch back to
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
/* Whether the authoritative state carries at least one SET line that owns members — the two
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
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 = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
set row per block (block sum) and either nulls each member's price (setprice) or removes the
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
sums each top-level set's positions onto the set line and either nulls those positions' prices
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
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) => {
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long