"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>
7.6 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Superseded | 2026-07-14 |
|
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
Context
ADRs 0006 and
0008 made draft editing
backend-authoritative. Under that model the set-price feature had three functions, of
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
whole-invoice display modes (SetDisplayMode.SetPrice/SetOnly): a non-mutating,
render-time transform (InvoiceSetPricing.Build) over explicit type == "set" header items
and their SetItmId members, persisted only as an admin.setmode flag. ADR 0008 calls them
"display-mode toggles" and treats them as presentational.
The product owner has redefined those two menu functions. They are not display toggles and they are not keyed on set-item membership:
- Their grouping is the service request (
ServiceRequestId= the editor's tbody block), neverSetItmId. Every block is treated as one set, whether or not it contains anytype == "set"item. - Applying a mode is an irreversible data change written hard into the cached draft dataset — not a reversible view flag. There is no toggle back; the user adjusts the result by hand afterwards.
This decision records that redefinition. It refines ADR 0008's characterisation of these two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR 0008's broader rule — every calculation server-side, and the PDF renders 100% the same content as the editor — remains fully in force and is not superseded.
Decision
There are three distinct set-price operations, kept clearly separated:
-
Inline set-item switch — unchanged. The row context button on a single
type == "set"item ($inv.toSetPrice→item.setpricedelta →InvoiceDraftEditService.ApplyItemSetPrice). It isSetItmId-based, sums the header's members onto the header, sets the members' prices tonull(empty cell, excluded from the sum — consistent with modes 2 & 3, not0), and is one-way. This is the only set-price operation that readsSetItmId. The button is shown — and the operation available — only for items that aretype == "set"and carry aSetItmId(and are still unconverted, i.e. own price0); an item missing either condition never offers it. -
"Set mit Preis" (menu) — per-block, irreversible mutation. Applied server-side to the cached
InvoiceDraftSession, grouped byServiceRequestId. For every service-request block:- Insert one dedicated, emphasised set row at the top of the block, carrying the block's aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real, editable line item with its own id, so the user can manually change the set value afterwards as an ordinary item edit.
- Null out the price of every existing item row in the block (set the price fields to
null, not0) so the row renders with an empty price/total cell.nulland0are semantically distinct here:nullmeans "no price — render an empty cell and exclude from the block sum", whereas0would legitimately print0,00 €. The rows themselves are retained.
-
"Nur Set mit Preis" (menu) — per-block, irreversible mutation. As above, grouped by
ServiceRequestId. For every block:- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
- Remove every existing item row in the block from the dataset entirely (the lines are gone, not merely hidden).
Properties common to the two menu modes (2 and 3):
- Mutation, not display. The change is written into
InvoiceDraftSession.Req(the "cache dataset") as a mutatingInvoiceDraftDelta, computed on the server (never in the browser). There is no render-timeadmin.setmodegrouping flag driving how lines are shown, and no reversible toggle. - Irreversible. There is no patch to undo it. The only ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows.
SetItmIdis irrelevant. Membership is the block, full stop.- Total unchanged. The inserted set row's value equals the sum of the block's original items,
which are then excluded from the sum — either because their price is
null(mode 2,nullcounts as no contribution) or because they are gone (mode 3). SoInvoiceBalance/InvoiceBalance_netare unaffected. - Editor and PDF render identically (ADR 0008): the dedicated set row is emphasised in both, and both read the same mutated session.
Consequences
- For the two menu modes,
InvoiceSetPricingstops being a non-mutating render transform overtype == "set"groups; the grouping/insert/blank/remove is a real mutation inInvoiceDraftEditService, keyed on the block. The inlineitem.setpriceswitch (operation 1) remains the soleSetItmId-based, set-item-scoped operation. - The previous
admin.setmodedisplay-flag model for these two modes — persistedsetmode:InvoiceOptionstoken, "Set-Preisanzeige menu entry disappears while unset",Build(...)choosingShowPriceper member at render time — is retired. Because the operation is a one-shot irreversible mutation, there is no persisted display state to toggle. Any residualsetmode:token must degrade safely (ignored) and is no longer (re-)persisted. - New/changed behaviour must be modelled as a server-side delta + calculator/service change and
covered by tests (
InvoiceDraftServiceTests,InvoiceSetPricingTests): for each menu mode, assert the inserted set row's value equals the block sum, the total is unchanged, mode 2 nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op. - Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008 reminder mirror).
Alternatives considered
Each of the following was raised and explicitly decided against as part of accepting this decision — they are rejected choices, not open options to revisit without a superseding ADR:
- Keep them as non-mutating display toggles (the prior design): explicitly rejected by the product owner — the set price must be a real, hand-editable value baked into the document, and "Nur Set mit Preis" must actually drop the member lines, not just hide them.
- Zero the blanked members' prices instead of nulling them: explicitly rejected —
0is ambiguous (it prints0,00 €), so the frontend could not tell an empty cell from a genuine zero price. Blanked members are set tonullprecisely to make "no price" unambiguous. - Carry the set price on the existing block heading row instead of a dedicated row: explicitly rejected — a separate, individually-editable set row keeps the section-heading semantics intact and gives the user a concrete line to adjust afterwards.
- Group by
SetItmId/type == "set"headers like the inline switch: explicitly rejected — the menu modes present each service request as one set, independent of any mfr set-item; conflating the two groupings is exactly the ambiguity this decision removes.