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>
This commit is contained in:
2026-09-10 22:19:01 +02:00
co-authored by Claude Fable 5.1
parent 22d2ef9c1a
commit 5fa7a2b50f
16 changed files with 678 additions and 447 deletions
@@ -1,5 +1,5 @@
---
status: Accepted
status: Superseded
date: 2026-07-14
applyTo:
- "Fuchs/code/InvoiceSetPricing.cs"
@@ -10,7 +10,7 @@ applyTo:
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
supersededBy: "0019-set-price-modes-are-keyed-on-the-set.md"
---
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
@@ -0,0 +1,111 @@
---
status: Accepted
date: 2026-09-10
applyTo:
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
---
# 0019 — The two set-price menu modes are keyed on the set, not on the service-request block
## Context
ADR [0009](0009-block-setprice-modes-are-irreversible-mutations.md) defined the two menu modes
("Set mit Preis" / "Nur Set mit Preis") as per-**service-request-block** conversions: every block
was collapsed into one inserted "Gesamtumfang pauschal" set row carrying the block's whole sum,
every original line of the block was price-nulled or removed, and any existing set membership was
neutralised (`SetItmId`/`setId` cleared, existing set rows demoted). Grouping by `SetItmId` was
explicitly rejected there.
ADR [0016](0016-user-defined-set-structure.md) then made set membership an explicit, user-editable
parent link with a dialog to define exactly which line belongs to which set, and let sets nest. With
that in place, the block-wide collapse actively destroys the structure the user just defined: it
discards their sets, prices lines that were never meant to be in a set, and can never show two sets
of one service request as two lines.
The product owner has therefore redefined the two modes again, this time onto the set structure:
- Choosing **"Set mit Preis – Positionen ohne Preis"** must give **each set** one price, show that
set's positions without a price, and leave every item that is in **no** set listed individually
with its own price. Sets and single items together make up the invoice total.
- Choosing **"Nur Set mit Preis – Positionen ausgeblendet"** must hide the positions belonging to
the sets, while the sets themselves and the items outside any set stay visible.
- **Several sets stay several separate lines** — they are never merged into one.
This decision records that redefinition and **supersedes ADR 0009**. Function 1 (the inline
set-item switch) is carried over from 0009 unchanged, as is ADR 0008's rule that every calculation
happens server-side and the PDF renders exactly what the editor shows.
## Decision
There are still three set-price operations, but they now share one grouping key — the set — and one
conversion primitive:
1. **Inline set-item switch (`item.setprice`) — unchanged from ADR 0009**, except that it is now a
thin wrapper over the shared primitive and is a **no-op on an already-converted set** (own price
non-zero). Without that guard a repeated delta would re-sum the members the first conversion had
already nulled and reset the set price to 0.
2. **"Set mit Preis" (`block.setprice`) — per set, irreversible.** For **every still-unconverted
top-level set of every block**: sum the set's transitive descendants onto the set line and set
each descendant's price fields to `null` (empty cell, excluded from the sum — never `0`, which
would print a genuine `0,00 €`). The descendant rows are kept.
3. **"Nur Set mit Preis" (`block.setonly`) — per set, irreversible.** As above, and additionally
**remove** the whole subtree of every top-level set (its members and any nested set line) from
both line shapes (`itm` and `items`). A set that was already converted keeps its price and still
has its members removed.
Properties common to modes 2 and 3:
- **The set is the grouping key.** No row is inserted, no row is demoted, no `SetItmId`/`setId` is
cleared. The user-defined structure (ADR 0016) is what the modes act on and what survives them.
- **Lines outside every set are untouched** — they keep their individual price and stay listed as
ordinary positions. Sets and standalone lines together form the total.
- **Only top-level sets are priced.** A nested set is part of its parent's subtree and is
blanked (mode 2) or removed (mode 3) with it, so no value is ever counted twice.
- **Empty sets and set-free invoices are no-ops** — no version bump, no history entry. The editor
therefore checks the authoritative state for a set before opening the mode dialog and otherwise
tells the user to define a set first ("Set-Struktur bearbeiten").
- **Repeatable, still one-way.** Running a mode again converts only sets that are still
unconverted, so the menu entry stays available (the ADR 0009 `admin.setmode` gate on the entry is
gone); no operation converts anything back.
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` server-side; there
is no render-time grouping flag. A residual `setmode:` `InvoiceOptions` token from the pre-0009
display-mode model still degrades safely (ignored) and is not re-persisted.
- **Total conserved, editor and PDF identical** (ADR 0008): a blanked member contributes nothing, a
removed one is gone, and in both cases its value already sits on the set line.
## Consequences
- `InvoiceDraftEditService.ApplyBlockSetPricing` (block aggregation, inserted `bset_*` row,
`DemoteSet`, `setId` neutralisation) is gone; `ApplySetPricing` walks the set forest through
`InvoiceSetStructure` and delegates the per-set work to `ConvertSetHeader`, which
`ApplyItemSetPrice` now uses too. One conversion rule, three triggers.
- Because membership is preserved, `InvoiceSetPricing.Build` (and `BuildSetDisplay`) governs the
rendering of the result again: a converted set's members are price-blanked, non-set lines keep
their own price. The PDF path needs no special case for the menu modes.
- The "Gesamtumfang pauschal" label and the notion of a service request as an implicit set no longer
exist. A user who wants one price for a whole service request creates a set over its lines in the
structure dialog first.
- Tests: `InvoiceDraftServiceTests` covers per-set pricing with a standalone item alongside, several
sets kept separate, nested sets priced once, member removal in both line shapes, the
already-converted set, the repeated `item.setprice` no-op, and the set-free/empty-set no-ops.
## Alternatives considered
- **Keep the block collapse and let the user pick the block** — rejected: it still cannot express
two sets in one service request, and it overwrites the structure ADR 0016 exists to let the user
define.
- **Price nested sets individually as well** — rejected: their value is already contained in the
top-level set's price, so both being priced would double the invoice's printed sum.
- **Fall back to the old block behaviour when an invoice contains no set** — rejected: it would make
the same menu entry do two very different things. Without a set there is nothing to price as a
set, so the modes are a no-op and the editor points the user at the structure dialog.
- **Make the modes reversible display toggles again** — rejected (as in ADR 0009): the set price
must be a real, hand-editable value in the document, and "Nur Set mit Preis" must actually drop
the member lines.