Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_SET_PRICING.md
T
StefanandClaude Sonnet 5 ecc74cf3c6
Playwright Tests / test (push) Canceled after 0s
Add user-defined invoice set structure dialog (ADR 0016)
Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:55:05 +02:00

18 KiB
Raw Blame History

Invoice "Set" Pricing — Design & Front-/Back-end Contract

Governed by ADR 0009 (the two menu modes), 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). 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.

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:

# Operation Trigger Grouped by 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

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.

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.

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.

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 function 1) 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.
  • 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

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) → 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. ModeFromInvoiceOptions(...) reads the mode from the invoice options. Fully covered by Fuchs.Tests/InvoiceSetPricingTests.cs.
  • 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.

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.

  2. Item shape — $inv.invSumUpdate now 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.

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 (the set row's value equals the members it blanks/removes; the set-item switch writes exactly the members' sum onto the header).

Why the trigger lives in the editor

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 per-block aggregation, or the pricing rules (ADR 0008/0009).

All set-price functions: before/after comparison

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:

  • 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.

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

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:

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
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).

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.