Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
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>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-09
|
||||
applyTo:
|
||||
- "Fuchs/code/InvoiceSetStructure.cs"
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.inv_sets.js"
|
||||
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
|
||||
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 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](0009-block-setprice-modes-are-irreversible-mutations.md),
|
||||
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
|
||||
1. **The implicit derivation stays as the initial structure.** `fds__prepInvoice`'s
|
||||
`SetItmId` window function is unchanged; a freshly opened draft shows exactly the grouping
|
||||
it delivered.
|
||||
|
||||
2. **Membership is an explicit parent link on every cached line.** Each line in a block's
|
||||
`itm` array carries `SetItmId` = the id of the `typ == "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.ParentOf` normalises the link (self-reference, unknown
|
||||
id, non-set target → top level).
|
||||
|
||||
3. **Sets may contain sets.** A set's own `SetItmId` names its parent set. Moving a set keeps
|
||||
its members attached — they simply sit one level deeper/shallower. The depth of every line is
|
||||
published as `lvl` (co shape) / `level` (items contract) and mirrored onto `items[].setId`,
|
||||
so the PDF and the editor read one structure.
|
||||
|
||||
4. **Four new backend-authoritative deltas** (`inv/dpatch`, handled by
|
||||
`InvoiceDraftSetStructureDeltas` → pure `InvoiceSetStructure`):
|
||||
- `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 of `parent` / 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.
|
||||
|
||||
5. **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 `items` contract is mirrored (order,
|
||||
`setId`, `level`). `InvoiceSetPricing.Build` is 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).
|
||||
|
||||
6. **The set-item switch sums transitive descendants.** `item.setprice` on 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.
|
||||
|
||||
7. **The dialog is a view, not a model.** `$inv.sets` (fis.inv_sets.js) renders
|
||||
`state.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 next
|
||||
`dstate`. Folding (`collapsed` set ids) is browser-only view state. The dialog performs no
|
||||
restructuring, ordering or numbering itself (ADR 0006).
|
||||
|
||||
## Consequences
|
||||
- `Refresh` runs `InvoiceSetStructure.RecomputeLevels` on every patch, so a `block.replace`
|
||||
posted by the editor (rows carry `SetItmId` from `rrw`) 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.
|
||||
- `invSumUpdate` now tags a nested **set** with its `setId` too (previously only non-set
|
||||
members were tagged), and `BuildSetDisplay` blanks the whole subtree of a converted set —
|
||||
including nested headers, which stay emphasised.
|
||||
- Structural deltas never change a value: totals, VAT splits and `NetByBlock` are 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`/`items` shape the calculator, the PDF, the persistence path and the
|
||||
editor already consume, so nothing else had to be re-shaped.
|
||||
- **Render-time regrouping (keep `Build` moving members next to their header)**: rejected —
|
||||
with explicit links the server can guarantee contiguity by linearising once, and an
|
||||
order-preserving `Build` is 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.
|
||||
Reference in New Issue
Block a user