"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>
16 KiB
Invoice "Set" Pricing — Design & Front-/Back-end Contract
Governed by ADR 0019 (the two menu modes — supersedes ADR 0009, which had keyed them on the service-request block), ADR 0008 (everything server-side, PDF == editor) and ADR 0016 (set membership is an explicit, user-editable parent link; sets may nest — see "Set structure" below).
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 | Scope | Effect |
|---|---|---|---|---|
| 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 |
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.
- 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, and every operation conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes.null(not0) marks "no price" — an empty cell excluded from the sum, distinct from a genuine0,00 €.
The
ItemPrices/admin.setmodedisplay-flag model is gone. The pre-0009 design persisted asetmode:<mode>token inInvoiceOptionsand re-grouped at render time. The modes are one-shot mutations, so there is no display state to persist or toggle; a stalesetmode:/itempricestoken degrades safely (ignored) and is never re-persisted.
Set structure (ADR 0016): which line belongs to which set
SetItmId is no longer only what fds__prepInvoice derived from row order — it is the
explicit parent link of every cached line, and the user can edit it:
- Initial structure = the implicit derivation (every item after a zero-priced Set header until the next one). Nothing changes for drafts the user never touches.
- "Set-Struktur bearbeiten" (editor menu →
$inv.sets,fis.inv_sets.js) shows every line per service-request block (Pos., Typ, Menge, Bezeichnung, indented by level). Drag a line onto a set →set.assign { parent }(it becomes the set's last member); drag it onto the block heading / the "oberste Ebene" zone →set.assign { parent: "" }(pulled out, placed after the subtree it came from). Drag a line to the top/bottom edge of another row →set.move { anchor, where: before|after }(reorder: it becomes that row's sibling; a set travels with its members, folded or not); ▲ / ▼ postset.move−1 / +1 (one step among siblings). The ◀ / ▶ buttons postset.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 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). 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. - The server linearises after each structural delta (depth-first: set, then its members,
recursively; siblings keep their order) and publishes
lvl/level;InvoiceSetPricing.Buildis order-preserving, so editor and PDF show the same sequence and the same per-level indent. Every change is applied immediately to the cached session and pushed back viadraftReady/dstate; the dialog re-renders from that state.
Back-end
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 theitemscontract shape (total_neton the header,nullon the members) so the editor and the PDF see the conversion without a reload.ApplyItemSetPrice(item.setprice) resolves one header byRefand delegates. It is a no-op when theRefis 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 — forsetonly— removes each such set's whole subtree fromitmanditems(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.csmaps theset.*patch targets onto it. Covered byInvoiceSetStructureTests+InvoiceDraftServiceTests.Fuchs/code/InvoiceSetPricing.cs— the render transformation:SetDisplayMode+Build(items, mode)→InvoiceSetLines in input order, each withShowPrice,IsSetHeaderandLevel(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 byFuchs.Tests/InvoiceSetPricingTests.cs.InvoiceDraftEditService.BuildSetDisplayruns the sameBuildfor the editor and publishessetDisplay[id] = { p: showPrice, h: isSetHeader }, so the rows always match the PDF without any client-side pricing rule.FuchsPdf.ApplyInvoicerenders blocks that carry a set throughInvoiceSetPricing.Buildand set-free blocks flat; lines withShowPrice == false(or anulltotal_net) render blank price/total cells (not0,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):
-
Menu modes — the "Set-Preisanzeige" menu (
$inv.ssetmode→$inv.setSetmode, menu entrysetm, label$ict.setm) offers "Set mit Preis" and "Nur Set mit Preis".$inv.hasSets(state)first checks the authoritativedstatefor 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) toinv/dpatch; the server rewrites the sets in the cached session and pushes the new state back viadraftReady/dstate. The browser performs no grouping or pricing math (ADR 0008/0019) — it only posts the chosen mode and re-renders the server'sreq/sums. Pending local edits are flushed first ($inv.t_fds_inv()) so a delayedblock.replacecannot land after the conversion. The menu entry is always offered ($inv.emOpt), because a mode can be run again for a set added later. -
Item shape —
$inv.invSumUpdateposts each request block'sitems[]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-screenitm/coobjects, whichFdsInvoiceData.InvoiceItemsdoes not read — so line items never reached the C# PDF. This change closes that gap for all invoices, not just sets.) -
Set flags —
invSumUpdatetags items as it buildsitems[]: an item withtype === 'set'is a header (id= its set id); a member'ssetIdis taken directly from theSetItmIdfield on the row (rrx.SetItmId— initially populated byfds__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__itemsitself still has no explicit member link;fds__prepInvoicecomputesSetItmIdper 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 ownSetItmIdself-references its own id (rather than beingnull); it is explicitly excluded from being its own member both here (sid !== citem.id) and inInvoiceSetStructure.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#
BuildInvoiceParams reads different ones. At post time $inv.invcPayload(d) maps the
working model onto the exact field names the back-end reads (non-destructively):
sms.ttn → new.total_net, sms.ttb → new.total_gross, each sms.vat rate →
new.vat_<rate>_net, new.invoicetitle → new.title, new.loc → new.provisionlocation,
admin.paymentterms → new.paymentterm, admin.CustomerId → admin.customerid. Both
req/save and req/sprep|sedit post through it, so titles/balances/VAT now reach the
backend correctly.
VAT (rate and amount) is taken by the backend directly from the posted sms.vat
map via FdsInvoiceData.HighestVat (highest rate wins) — invcPayload therefore emits
no per-rate vat_* keys.
Back-end fixes applied alongside the wiring:
- Heading/free-text lines (
typetext/title) now render a blank price/total (InvoiceSetPricing.IsNoPriceLine), instead of0,00 €. - VAT rate detection no longer reads line items (the old
items is List<object>test failed on NewtonsoftJArrayand pinned@InvoiceVAT_1to19); it now comes fromsms.vat, so non-19 % rates are stored correctly. Single-rate procs still store only the highest rate.
The editor's running total is unaffected by any set-price operation, matching 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
is being composed (front-end), but the operation itself is executed server-side
against the cached draft session — the editor merely names the target (a set-item
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
aggregation, or the pricing rules (ADR 0008/0019).
Before/after per operation
1. The set-item switch (item.setprice, one set)
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).
| Aspect | Before | After |
|---|---|---|
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 (every set, one action)
| Aspect | "Set mit Preis" (mode 2) | "Nur Set mit Preis" (mode 3) |
|---|---|---|
| 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 | 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 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.