Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_SET_PRICING.md
T
Stefan 49e3ed2673 Add unit tests for Fuchs_DataService and related components
- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction.
- Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability.
- Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction.

Document decisions on backend-authoritative invoice and reminder handling

- Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing.
- Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs.

Define irreversible mutations for set-price modes in invoices

- Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks.
- Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling.

Transition MFR ERP sync to in-process execution within the web app

- Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application.
- Updated configuration and logging management to be handled by the host application, streamlining the sync process.

Add publish profile and periodic hosted service for job scheduling

- Introduced a publish profile for deployment to a specified folder.
- Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals.

Add dotnet-tools.json for EF Core CLI tools

- Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
2026-07-16 13:34:23 +02:00

15 KiB

Invoice "Set" Pricing — Design & Front-/Back-end Contract

Governed by ADR 0009 (the two menu modes) and ADR 0008 (everything server-side, PDF == editor). 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.

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:

# Operation Trigger Grouped by 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 nullone-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

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.

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.

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.

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) → ordered InvoiceSetLines, each with ShowPrice and IsSetHeader. ModeFromInvoiceOptions(...) reads the mode from the invoice options. Fully covered by Fuchs.Tests/InvoiceSetPricingTests.cs.
  • 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.

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.

  2. Item shape$inv.invSumUpdate now 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 item's setId is taken directly from the server-computed SetItmId field on the row (rrx.SetItmId, populated by fds__prepInvoice's [SetItmID] window function, anchored on the still-unconverted, zero-priced 'set' header that owns it) — 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.

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 (the set row's value equals the members it blanks/removes; the set-item switch writes exactly the members' sum onto the header).

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 per-block aggregation, or the pricing rules (ADR 0008/0009).

All set-price functions: before/after comparison

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:

  • 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.

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
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

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:

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
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).

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.