Add user-defined invoice set structure dialog (ADR 0016)
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:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+2
View File
@@ -177,6 +177,8 @@ disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access
+22 -1
View File
@@ -1,9 +1,10 @@
---
status: Active
lastUpdated: 2026-07-10
lastUpdated: 2026-09-09
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/Services/ReminderDraft*"
- "Fuchs/Services/IReminderDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
@@ -18,6 +19,8 @@ applyTo:
relatedDecisions:
- "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md"
---
# Live draft editing (backend-authoritative invoice previews)
@@ -83,6 +86,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
path), renders previews from a synthesised registration, and discards by reloading.
- **Set structure** (ADR 0016). Every cached line carries a parent link (`SetItmId` → the
owning `typ == "set"` line, null/self = top level); sets may nest. `fds__prepInvoice`'s
implicit tagging is the initial structure; the user edits it in the "Set-Struktur
bearbeiten" dialog (`$inv.sets`, `fis.inv_sets.js`) which posts one delta per action —
`set.assign` { parent } (drop onto a set / pull out), `set.level` ±1 (indent under the
preceding sibling set / outdent to the grandparent), `set.create` { typ: set|text, title,
parent }, `set.move` ±1 | { anchor, where } (reorder among siblings / drag before or after
a row; the subtree travels along). `InvoiceDraftSetStructureDeltas` delegates to the pure
`InvoiceSetStructure` (assign / change level / create / place, then **linearise** the block depth-first and mirror
`setId`/`level` onto the `items` contract). `Refresh` publishes `lvl` on every line
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -97,12 +113,15 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
## Key files
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
- `Fuchs/code/InvoiceSetStructure.cs` — pure set forest: parent links, levels, linearise, assign/level/create (ADR 0016).
- `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs` — the `set.*` patch targets.
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
- `Fuchs/js/intranet/modules/fis.inv_sets.js` — the set-structure dialog (`$inv.sets`), a view of `dstate.req`.
## Reminders (Zahlungserinnerung)
@@ -151,3 +170,5 @@ amount pair.
## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
@@ -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.
+48 -9
View File
@@ -1,8 +1,10 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor) and ADR
> [0016](Decisions/0016-user-defined-set-structure.md) (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.
@@ -40,6 +42,37 @@ for the full breakdown.
> 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
@@ -51,9 +84,14 @@ for the full breakdown.
> migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **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
@@ -82,11 +120,12 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
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 item's
`setId` is taken directly from the server-computed `SetItmId` field on the row
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
**not** re-derived from row order in the browser. `mfr__items` itself still has no
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
+14
View File
@@ -50,6 +50,20 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
umstellen“** (Waage-Symbol an der Set-Zeile) erhält das Set die Summe seiner Positionen als
Preis, die Positionen selbst werden ohne Preis gedruckt (nicht rückgängig zu machen).
- **„Set-Struktur bearbeiten“** (Menü) zeigt alle Positionen je Auftrag. Positionen per
**Drag & Drop auf ein Set** ziehen, um sie zuzuordnen; auf die Auftragszeile oder den Bereich
„oberste Ebene“ ziehen, um sie aus dem Set zu lösen. **Reihenfolge**: Zeile an den oberen
bzw. unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲ / ▼ nutzen. Mit ◀ / ▶ eine
Ebene höher/tiefer. Sets lassen sich über das Dreieck zuklappen und auch zugeklappt verschieben.
Sets können Sets enthalten; ein verschobenes Set nimmt seine Positionen mit. Über die Leiste
„Neu“ lassen sich neue **Set**- oder **Text**-Zeilen anlegen (auch innerhalb eines Sets).
- Jede Änderung wird sofort übernommen und im Editor sowie in der Vorschau abgebildet
(eingerückt je Ebene); die Rechnungssumme ändert sich dadurch nicht.
### Finalisieren & Versenden
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.