Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_SET_PRICING.md
T
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

16 KiB
Raw Blame History

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 (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

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); ▲ / ▼ post set.move −1 / +1 (one step among 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 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.Build is 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 via draftReady/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 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.
  • Fuchs/code/InvoiceSetPricing.cs — the render transformation: SetDisplayMode + Build(items, mode) → InvoiceSetLines 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". $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 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 — 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# 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 (type text/title) now render a blank price/total (InvoiceSetPricing.IsNoPriceLine), instead of 0,00 €.
  • VAT rate detection no longer reads line items (the old items is List<object> test failed on Newtonsoft JArray and pinned @InvoiceVAT_1 to 19); it now comes from sms.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.