"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>
112 lines
7.0 KiB
Markdown
112 lines
7.0 KiB
Markdown
---
|
||
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.
|