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 -1
View File
@@ -178,7 +178,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access
+2 -2
View File
@@ -19,8 +19,8 @@ applyTo:
relatedDecisions:
- "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md"
- "0019-set-price-modes-are-keyed-on-the-set.md"
---
# Live draft editing (backend-authoritative invoice previews)
@@ -170,5 +170,5 @@ amount pair.
## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
- [0019 — Set-price modes are keyed on the set](../Decisions/0019-set-price-modes-are-keyed-on-the-set.md) (supersedes 0009)
@@ -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.
+121 -152
View File
@@ -1,46 +1,49 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor) and ADR
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit,
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the
> reversible, non-mutating display toggles this document previously described into irreversible,
> per-service-request-block mutations — the text below reflects the redefinition.
> Governed by ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)
> (the two menu modes — **supersedes** ADR
> [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md), which had keyed them on
> the service-request block), ADR
> [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) (everything
> server-side, PDF == editor) and ADR [0016](Decisions/0016-user-defined-set-structure.md) (set
> membership is an explicit, user-editable parent link; sets may nest — see "Set structure" below).
There are **three** separate set-price operations. They fall into two families that must not be
confused, because they group items by different keys and differ in whether they mutate the data:
There are **three** set-price operations. All three are **irreversible mutations** of the
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a display toggle — and all
three group by the **same key: the set** (ADR 0019).
| # | Operation | Trigger | Grouped by | Effect |
| # | Operation | Trigger | Scope | Effect |
|---|---|---|---|---|
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** |
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
| **1** | **Set-item switch** | row context button on a single `typ == "set"` row (`$inv.toSetPrice`) | that one set | sums the set's transitive descendants onto the set line and sets their prices to `null` |
| **2** | **"Set mit Preis – Positionen ohne Preis"** | editor menu ("Set-Preisanzeige") | **every still-unconverted top-level set** of every block | same conversion, applied to all of them at once; the members' rows are **kept**, showing an empty price cell |
| **3** | **"Nur Set mit Preis – Positionen ausgeblendet"** | editor menu ("Set-Preisanzeige") | **every top-level set** of every block | same conversion, and the set's whole subtree is **removed** from the dataset |
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
grouping key is the service request (the editor tbody block). See "All set-price functions" below
for the full breakdown.
What the two menu modes explicitly do **not** do (they did, under the superseded ADR 0009): they do
not treat a service request as an implicit set, do not insert a "Gesamtumfang pauschal" row, and do
not clear anyone's set membership.
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
> the set row is a real, editable line item precisely so the user can adjust the set value
> afterwards.
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
> token degrades safely (ignored) and is never (re-)persisted.
- **Lines outside every set stay untouched** — individual price, listed as ordinary positions.
Sets and standalone lines together make up the invoice total.
- **Several sets stay several separate lines**, in the same block or across blocks.
- **Only top-level sets are priced.** A nested set belongs to its parent's subtree, so it is blanked
(mode 2) or removed (mode 3) with it and never priced twice.
- **No sets, or a set without members → no-op** (no version bump, no history). The editor checks the
authoritative state first and, when the invoice has no set at all, points the user at
"Set-Struktur bearbeiten" instead of opening the mode dialog.
- **Repeatable, still one-way.** Running a mode again only converts sets that are still unconverted;
nothing converts back. The only ways back are discarding the draft (reloads the DB state) or
hand-editing the resulting rows — the set line is a real, editable item precisely for that.
> **Totals are unaffected.** The invoice total is taken from the registration balance
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
> header.
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines, and every operation
> conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes.
> `null` (not `0`) marks "no price" — an empty cell excluded from the sum, distinct from a genuine
> `0,00 €`.
> **The `ItemPrices` / `admin.setmode` display-flag model is gone.** The pre-0009 design persisted a
> `setmode:<mode>` token in `InvoiceOptions` and re-grouped at render time. The modes are one-shot
> mutations, so there is no display state to persist or toggle; a stale `setmode:`/`itemprices`
> token degrades safely (ignored) and is never re-persisted.
## Set structure (ADR 0016): which line belongs to which set
@@ -59,11 +62,11 @@ for the full breakdown.
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
triangle (view state only). The per-block bar creates a
new **Set** (zero-priced header, convertible via function 1) or **Text** line inside any set
or at the top level → `set.create { typ, title, parent }`.
new **Set** (zero-priced header, convertible via the operations above) or **Text** line inside any
set or at the top level → `set.create { typ, title, parent }`.
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls
them all; converting inner-then-outer or outer directly conserves the total either way.
deeper). Every conversion sums the header's **transitive** descendants and nulls them all;
converting inner-then-outer or outer directly conserves the total either way.
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
merely greys them out.
@@ -75,65 +78,72 @@ for the full breakdown.
## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
> migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `Fuchs/Services/InvoiceDraftEditService.cs` — the three triggers:
- `ConvertSetHeader(block, lines, header)` is the **single conversion primitive**: sums the
header's transitive descendants (`InvoiceSetStructure.Descendants`) onto the header
(`v`/`vt`/`vv`/`vs`/`vsv`, adopting the members' VAT rate when the header has none), nulls every
descendant's price fields, and mirrors the result onto the `items` contract shape
(`total_net` on the header, `null` on the members) so the editor and the PDF see the conversion
without a reload.
- `ApplyItemSetPrice` (`item.setprice`) resolves one header by `Ref` and delegates. It is a no-op
when the `Ref` is unknown, is not a set line, or the set is **already converted** (own price
non-zero) — that guard keeps a repeated delta from re-summing already-nulled members to 0.
- `ApplySetPricing(removeMembers)` (`block.setprice` / `block.setonly`) walks every block, takes
the **top-level** set lines (`IsSet` && `ParentOf == null`), converts the unconverted ones via
the primitive, and — for `setonly` — removes each such set's whole subtree from `itm` and
`items` (`RemoveById`). Sets with no members, and blocks/invoices without sets, are skipped.
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
set header line is rendered emphasised. Invoices without sets pass through
unchanged.
- `Fuchs/code/InvoiceSetPricing.cs` — the render transformation:
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. Because the menu modes preserve set membership, this is
what renders their result — no special case is needed for them. Fully covered by
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `InvoiceDraftEditService.BuildSetDisplay` runs the same `Build` for the editor and publishes
`setDisplay[id] = { p: showPrice, h: isSetHeader }`, so the rows always match the PDF without any
client-side pricing rule.
- `FuchsPdf.ApplyInvoice` renders blocks that carry a set through `InvoiceSetPricing.Build` and
set-free blocks flat; lines with `ShowPrice == false` (or a `null` `total_net`) render **blank**
price/total cells (not `0,00 €`), and set header lines are emphasised.
## Front-end contract (implemented in the invoice editor)
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
Preis". Selecting one posts a **mutating delta** (grouped by service-request
block) to `inv/dpatch`; the server rewrites the block's items in the cached
session (inserts the set row, blanks or removes members) and pushes the new
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
these two modes.
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, menu entry
`setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit Preis". `$inv.hasSets(state)`
first checks the authoritative `dstate` for a line that points at a set line; if there is none,
the user is told to define a set first ($rct.toSetMn) and nothing is posted. Otherwise selecting a
mode posts a **mutating delta** (`block.setprice` / `block.setonly`) to `inv/dpatch`; the server
rewrites the sets in the cached session and pushes the new state back via `draftReady`/`dstate`.
The browser performs **no** grouping or pricing math (ADR 0008/0019) — it only posts the chosen
mode and re-renders the server's `req`/`sums`. Pending local edits are flushed first
(`$inv.t_fds_inv()`) so a delayed `block.replace` cannot land after the conversion. The menu
entry is always offered (`$inv.emOpt`), because a mode can be run again for a set added later.
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
`items[]` in the back-end contract shape via `$inv.itemToContract`:
2. **Item shape** — `$inv.invSumUpdate` posts each request block's `items[]` in the back-end
contract shape via `$inv.itemToContract`:
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
editor only posted the legacy on-screen `itm`/`co` objects, which
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
C# PDF. This change closes that gap for **all** invoices, not just sets.)
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
an item with `type === 'set'` is a header (`id` = its set id); a member's
`setId` is taken directly from the `SetItmId` field on the row
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
maintained by the server per ADR 0016, including for a **nested set**, which is a member of
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
item list, so only items the server actually attributes to a set are tagged, and
unrelated items following a set in the list are never swept in. The header row's
own `SetItmId` self-references its own id (rather than being `null`); it is
explicitly excluded from being its own member both here (`sid !== citem.id`) and
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
`SetItmId` and group by service-request block.
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with `type === 'set'`
is a header (`id` = its set id); a member's `setId` is taken directly from the `SetItmId` field on
the row (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window
function, anchored on the still-unconverted, zero-priced `'set'` header that owns it, and
thereafter maintained by the server per ADR 0016, including for a **nested set**, which is a
member of its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself
still has no explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
item list, so only items the server actually attributes to a set are tagged, and unrelated items
following a set in the list are never swept in. The header row's own `SetItmId` self-references
its own id (rather than being `null`); it is explicitly excluded from being its own member both
here (`sid !== citem.id`) and in `InvoiceSetStructure.ParentOf`. These flags feed **all three**
set-price operations (ADR 0019).
### Editor → backend field normalization (`$inv.invcPayload`)
The editor's internal model keeps the long-standing key names, but the migrated C#
@@ -158,9 +168,7 @@ no per-rate `vat_*` keys.
the highest rate.
The editor's running **total is unaffected by any set-price operation**, matching
the registration balance — each operation conserves the total (the set row's value
equals the members it blanks/removes; the set-item switch writes exactly the
members' sum onto the header).
the registration balance — each operation conserves the total.
### Why the trigger lives in the editor
The choice of *when* to apply a set-price operation is only known where the invoice
@@ -169,83 +177,44 @@ against the cached draft session — the editor merely names the target (a set-i
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
the server's result. The back-end stays the single, tested authority for how each
operation rewrites the lines; the editor never re-implements the grouping, the
per-block aggregation, or the pricing rules (ADR 0008/0009).
aggregation, or the pricing rules (ADR 0008/0019).
## All set-price functions: before/after comparison
## Before/after per operation
There are **three** distinct functions, and all three are **mutations** of the
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
transient, freely-reversible view flag. They differ in what they group by and
what they touch:
### 1. The set-item switch (`item.setprice`, one set)
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
(one mfr set-item and its members) and is triggered per set row.
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
entirely, and are triggered once from the "Set-Preisanzeige" menu.
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`). The context button is
shown — and the operation available — **only** on a row that is `typ == "set"` and is still
unconverted (own price `0`).
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
is discarding the draft or hand-editing the resulting rows.
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
shown — and the operation available — **only** on a row that is `type == "set"`
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
missing either condition never offers it. It gives a set-item its "own price": the
members' values are summed onto the header and the members' prices are set to
`null` (empty cell, excluded from the sum — not `0`). This
conversion is **one-way** — there is no patch to move a converted set back to
separately-priced members; the user would re-edit the individual line prices by
hand. It is the **only** function keyed on `SetItmId`.
| Aspect | Before the switch | After the switch |
| Aspect | Before | After |
|---|---|---|
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
| Set line (`id == Ref`) price | `0` (as delivered by `fds__prepInvoice`, or as created in the structure dialog) | `v`/`vt`/`vv`/`vs`/`vsv` (and `items[].total_net`) = the sum of its transitive descendants |
| Descendant lines | each shows its own price | every price field set to **`null`** (not `0`) → empty cell, excluded from the sum; the rows stay |
| Membership | fixed by the server (`fds__prepInvoice` + the user's edits, ADR 0016) | **unchanged** — never re-derived or reassigned |
| Lines outside the set | untouched | **still untouched**, regardless of row order |
| Draft version / history | — | version +1, `item.setprice` entry with old/new header value |
| Invoice total | — | **unchanged** |
| No-ops | unknown `Ref`, `Ref` is not a set line, **or the set is already converted** → no version bump, no history | |
### 2 & 3. The two menu modes (per service-request block, mutating)
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
`$inv.setSetmode`) and applied server-side per **service-request block**
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
Both are **irreversible** and rewrite the block's items in the cached session.
For each block, a dedicated, emphasised **set row** is inserted (see "The
dedicated set row" below) carrying the block's aggregated value as its price;
then, depending on the mode, the block's original items are either blanked or
removed:
### 2 & 3. The two menu modes (every set, one action)
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|---|---|---|
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
| `SetItmId` | ignored | ignored |
| Grouping key | the set (top-level sets of every block) | the set (top-level sets of every block) |
| Set line | receives the sum of its subtree | receives the sum of its subtree |
| Its member rows | **kept**, every price field `null` → empty price/total cell | **removed** from `itm` and `items` entirely (nested set lines included) |
| Already-converted set | left as it is | keeps its price, members still removed |
| Lines in no set | **untouched**, individually priced | **untouched**, individually priced |
| Several sets | each stays its own priced line | each stays its own priced line |
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
| Empty block | no-op | no-op |
### The dedicated set row
Both menu modes insert a **real, editable line item** (its own id, rendered
emphasised in the editor and the PDF), not a reused block-heading row and not a
render-only overlay. Because it is a genuine row in the cached dataset, the user
can adjust the set value afterwards with an ordinary item edit — that hand-edit
is the intended and only "undo" for the conversion (ADR
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
| Invoice total | **unchanged** | **unchanged** |
| Invoice without sets / empty set | no-op (editor tells the user to define a set) | no-op (same) |
## Persistence note
Draft/preview PDFs render straight from the cached draft session, so the contract
works end-to-end for previews and creation. The two menu modes bake their result
directly into the session's items (a set row plus blanked/removed members), so no
`setmode:` display token is needed or persisted; the finalised document is
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
invoice must be **re-generated** from stored items later — not done here.
works end-to-end for previews and creation. The menu modes bake their result directly into the
session's items (set line priced, members blanked or removed), so no `setmode:` display token is
needed or persisted; the finalised document is rendered once and stored as a file. Persisting the
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is only required if a
finalised invoice must be **re-generated** from stored items later — not done here.