"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>
221 lines
16 KiB
Markdown
221 lines
16 KiB
Markdown
# 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.
|