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>
7.2 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Accepted | 2026-09-09 |
|
0016 — Set membership is an explicit, user-editable parent link on the cached lines; sets may nest; the server linearises
Context
Set membership on an invoice draft was purely implicit: fds__prepInvoice tags every item
that follows a zero-priced Type = 'Set' header (until the next such header) with that header's
id (SetItmId), and the set-item switch (ADR 0009,
function 1) sums exactly those tagged lines. Users had no way to correct that grouping — a
trailing item that does not belong, a material line that should sit inside the set, a set that
should contain another set — nor to introduce a set (or a free-text line) that does not exist
in mfr. The product owner asked for a dialog that lets the user define which line belongs to
which set, create new set/text lines, change a line's level, and nest sets, with every change
applied immediately to the cached draft and reflected in the editor.
Decision
-
The implicit derivation stays as the initial structure.
fds__prepInvoice'sSetItmIdwindow function is unchanged; a freshly opened draft shows exactly the grouping it delivered. -
Membership is an explicit parent link on every cached line. Each line in a block's
itmarray carriesSetItmId= the id of thetyp == "set"line that owns it (null, or a self-reference as delivered for headers, = top level). The links form a forest per service-request block; the block is the boundary — a line is never assigned to a set of another block.InvoiceSetStructure.ParentOfnormalises the link (self-reference, unknown id, non-set target → top level). -
Sets may contain sets. A set's own
SetItmIdnames its parent set. Moving a set keeps its members attached — they simply sit one level deeper/shallower. The depth of every line is published aslvl(co shape) /level(items contract) and mirrored ontoitems[].setId, so the PDF and the editor read one structure. -
Four new backend-authoritative deltas (
inv/dpatch, handled byInvoiceDraftSetStructureDeltas→ pureInvoiceSetStructure):set.assign(Ref = item, Value{ parent };""= top level) — drop a line onto a set (it becomes the set's last member) or pull it out (placed right after the subtree it came from). Rejected as a no-op: unknown item, target not a set of the same block, target is the item itself or one of its descendants (cycle), parent unchanged.set.level(Ref = item, Value ±1) — indent = join the nearest preceding sibling set as its last member; outdent = join the grandparent right after the former parent's subtree. No-op when no such move exists.set.create(Ref = block, Value{ typ: "set" | "text", title, parent }) — a new zero-priced set header (convertible later via the item switch) or a free-text line, as the last member ofparent/ end of the block. Both line shapes are written; the title is HTML-encoded for the text line.set.move(Ref = item, Value ±1 or{ anchor, where: "before" | "after" }) — reorder: one step up/down among the line's siblings, or become the anchor's sibling placed before it / after its subtree (drag between rows). The line's own subtree travels with it, so a set can be reordered folded or unfolded alike. Rejected: anchor inside the item's subtree.
-
The server linearises after every structural change: the block's lines are reordered depth-first (each set immediately followed by its members, recursively; siblings keep their relative order), levels are recomputed and the
itemscontract is mirrored (order,setId,level).InvoiceSetPricing.Buildis therefore order-preserving: it no longer regroups members next to their header at render time; a line is governed by its nearest converted ancestor (blank in SetPrice, dropped in SetOnly). Editor and PDF print the same sequence and the same per-level indent (ADR 0008). -
The set-item switch sums transitive descendants.
item.setpriceon a set now sums and nulls the header's whole subtree: an unconverted nested set contributes its members' prices, an already-converted nested set contributes its own set price — so converting inner then outer, or outer directly, both conserve the total and count nothing twice. -
The dialog is a view, not a model.
$inv.sets(fis.inv_sets.js) rendersstate.req[].itm(pos, type, quantity, title, indent, ▲▼ order and ◀▶ level buttons, fold toggles, per-block create bar), posts one delta per action and re-renders from the nextdstate. Folding (collapsedset ids) is browser-only view state. The dialog performs no restructuring, ordering or numbering itself (ADR 0006).
Consequences
RefreshrunsInvoiceSetStructure.RecomputeLevelson every patch, so ablock.replaceposted by the editor (rows carrySetItmIdfromrrw) keeps the user-defined structure; the editor never has to re-derive it. Levels are rendered as an indent class (lvl1…) in the editor and a per-level left indent in the PDF.invSumUpdatenow tags a nested set with itssetIdtoo (previously only non-set members were tagged), andBuildSetDisplayblanks the whole subtree of a converted set — including nested headers, which stay emphasised.- Structural deltas never change a value: totals, VAT splits and
NetByBlockare unaffected; only positions are renumbered along the new order. - Row drag in the editor (the existing
Sortable) reorders rows without touching parent links; a member dragged away from its set keeps its membership and indent. The structure dialog is the place to change membership. - Tests:
InvoiceSetStructureTests(pure forest logic),InvoiceDraftServiceTests(deltas, history, nested set price, state),InvoiceSetPricingTests(nesting, order preservation).
Alternatives considered
- Derive membership from row order (everything after a set until the next set) as the live model: rejected — that is exactly what the user cannot correct, and it cannot express nesting or an item that follows a set but does not belong to it.
- A separate structure table (tree) beside the lines: rejected — a parent link per line
keeps the single
itm/itemsshape the calculator, the PDF, the persistence path and the editor already consume, so nothing else had to be re-shaped. - Render-time regrouping (keep
Buildmoving members next to their header): rejected — with explicit links the server can guarantee contiguity by linearising once, and an order-preservingBuildis the only way the editor and the PDF are guaranteed to show the same sequence. - Client-side restructuring with a full block re-post: rejected per ADR 0006/0008 — one delta per action, the server owns the mutation, the browser mirrors.