Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_SET_PRICING.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

221 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
> 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** 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 | Scope | Effect |
|---|---|---|---|---|
| **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 |
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.
- **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, 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
`SetItmId` is no longer only what `fds__prepInvoice` derived from row order — it is the
**explicit parent link** of every cached line, and the user can edit it:
- **Initial structure** = the implicit derivation (every item after a zero-priced Set header
until the next one). Nothing changes for drafts the user never touches.
- **"Set-Struktur bearbeiten"** (editor menu → `$inv.sets`, `fis.inv_sets.js`) shows every
line per service-request block (Pos., Typ, Menge, Bezeichnung, indented by level). Drag a
line onto a set → `set.assign { parent }` (it becomes the set's last member); drag it onto
the block heading / the "oberste Ebene" zone → `set.assign { parent: "" }` (pulled out, placed
after the subtree it came from). Drag a line to the **top/bottom edge** of another row →
`set.move { anchor, where: before|after }` (reorder: it becomes that row's sibling; a set
travels with its members, folded or not); ▲ / ▼ post `set.move` −1 / +1 (one step among
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 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). 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.
- **The server linearises** after each structural delta (depth-first: set, then its members,
recursively; siblings keep their order) and publishes `lvl`/`level`; `InvoiceSetPricing.Build`
is order-preserving, so editor and PDF show the same sequence and the same per-level indent.
Every change is applied immediately to the cached session and pushed back via
`draftReady`/`dstate`; the dialog re-renders from that state.
## Back-end
- `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`.
- `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". `$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` 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** — `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#
`BuildInvoiceParams` reads different ones. At post time `$inv.invcPayload(d)` maps the
working model onto the exact field names the back-end reads (non-destructively):
`sms.ttn → new.total_net`, `sms.ttb → new.total_gross`, each `sms.vat` rate →
`new.vat_<rate>_net`, `new.invoicetitle → new.title`, `new.loc → new.provisionlocation`,
`admin.paymentterms → new.paymentterm`, `admin.CustomerId → admin.customerid`. Both
`req/save` and `req/sprep|sedit` post through it, so titles/balances/VAT now reach the
backend correctly.
VAT (rate **and** amount) is taken by the backend directly from the posted `sms.vat`
map via `FdsInvoiceData.HighestVat` (highest rate wins) — `invcPayload` therefore emits
no per-rate `vat_*` keys.
**Back-end fixes applied alongside the wiring:**
- Heading/free-text lines (`type` `text`/`title`) now render a **blank** price/total
(`InvoiceSetPricing.IsNoPriceLine`), instead of `0,00 €`.
- VAT rate detection no longer reads line items (the old `items is List<object>` test
failed on Newtonsoft `JArray` and pinned `@InvoiceVAT_1` to `19`); it now comes from
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
the highest rate.
The editor's running **total is unaffected by any set-price operation**, matching
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
is being composed (front-end), but the operation itself is executed **server-side**
against the cached draft session — the editor merely names the target (a set-item
`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
aggregation, or the pricing rules (ADR 0008/0019).
## Before/after per operation
### 1. The set-item switch (`item.setprice`, one set)
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`).
| Aspect | Before | After |
|---|---|---|
| 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 (every set, one action)
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|---|---|---|
| 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** | **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 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.