Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0009-block-setprice-modes-are-irreversible-mutations.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.6 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Superseded 2026-07-14
Fuchs/code/InvoiceSetPricing.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-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), never SetItmId. Every block is treated as one set, whether or not it contains any type == "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:

  1. Inline set-item switch — unchanged. The row context button on a single type == "set" item ($inv.toSetPrice → item.setprice delta → InvoiceDraftEditService.ApplyItemSetPrice). It is SetItmId-based, sums the header's members onto the header, sets the members' prices to null (empty cell, excluded from the sum — consistent with modes 2 & 3, not 0), and is one-way. This is the only set-price operation that reads SetItmId. The button is shown — and the operation available — only for items that are type == "set" and carry a SetItmId (and are still unconverted, i.e. own price 0); an item missing either condition never offers it.

  2. "Set mit Preis" (menu) — per-block, irreversible mutation. Applied server-side to the cached InvoiceDraftSession, grouped by ServiceRequestId. 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, not 0) so the row renders with an empty price/total cell. null and 0 are semantically distinct here: null means "no price — render an empty cell and exclude from the block sum", whereas 0 would legitimately print 0,00 €. The rows themselves are retained.
  3. "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 mutating InvoiceDraftDelta, computed on the server (never in the browser). There is no render-time admin.setmode grouping 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.
  • SetItmId is 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, null counts as no contribution) or because they are gone (mode 3). So InvoiceBalance/ InvoiceBalance_net are 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, InvoiceSetPricing stops being a non-mutating render transform over type == "set" groups; the grouping/insert/blank/remove is a real mutation in InvoiceDraftEditService, keyed on the block. The inline item.setprice switch (operation 1) remains the sole SetItmId-based, set-item-scoped operation.
  • The previous admin.setmode display-flag model for these two modes — persisted setmode: InvoiceOptions token, "Set-Preisanzeige menu entry disappears while unset", Build(...) choosing ShowPrice per member at render time — is retired. Because the operation is a one-shot irreversible mutation, there is no persisted display state to toggle. Any residual setmode: 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 — 0 is ambiguous (it prints 0,00 €), so the frontend could not tell an empty cell from a genuine zero price. Blanked members are set to null precisely 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.