Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0019-set-price-modes-are-keyed-on-the-set.md
T
StefanandClaude Fable 5.1 5fa7a2b50f 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>
2026-09-10 22:19:01 +02:00

7.0 KiB
Raw Blame History

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-09-10
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

0019 — The two set-price menu modes are keyed on the set, not on the service-request block

Context

ADR 0009 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 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.