- 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.
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 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 |
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 tonull(mode 2) or are deleted (mode 3). There is no reversible toggle and no persistedadmin.setmoderender 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.setmodedisplay-flag model was removed. The earlier design persisted asetmode:<mode>token inInvoiceOptionsand 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 stalesetmode:/itempricestoken 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.Builddisplay-mode implementation for functions 2 & 3. Under ADR 0009 those two functions become per-block mutations inInvoiceDraftEditService(insert set row + blank/remove members); the render-timeBuild/ModeFromInvoiceOptions/setmodedisplay 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)→ orderedInvoiceSetLines, each withShowPriceandIsSetHeader.ModeFromInvoiceOptions(...)reads the mode from the invoice options. Fully covered byFuchs.Tests/InvoiceSetPricingTests.cs.FuchsPdf.ApplyInvoicerenders throughInvoiceSetPricing.Build: lines withShowPrice == falserender blank price/total cells (not0,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):
-
Menu modes — the "Set-Preisanzeige" menu (
$inv.ssetmode→$inv.setSetmode, menu entrysetm, label$ict.setm) offers "Set mit Preis" and "Nur Set mit Preis". Selecting one posts a mutating delta (grouped by service-request block) toinv/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 viadraftReady/dstate. The browser performs no grouping or pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the server'sreq/sums. There is no persistedadmin.setmodedisplay flag for these two modes. -
Item shape —
$inv.invSumUpdatenow posts each request block'sitems[]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-screenitm/coobjects, whichFdsInvoiceData.InvoiceItemsdoes not read — so line items never reached the C# PDF. This change closes that gap for all invoices, not just sets.) -
Set flags (function 1 only) —
invSumUpdatetags items as it buildsitems[]: an item withtype === 'set'is a header (id= its set id); a member item'ssetIdis taken directly from the server-computedSetItmIdfield on the row (rrx.SetItmId, populated byfds__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__itemsitself still has no explicit member link;fds__prepInvoicecomputesSetItmIdper 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 ownSetItmIdself-references its own id (rather than beingnull); it is explicitly excluded from being its own member both here (sid !== citem.id) and inInvoiceDraftEditService.ApplyItemSetPrice. These flags feed only the set-item switch (function 1); the two menu modes (functions 2 & 3) ignoreSetItmIdand 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 (
typetext/title) now render a blank price/total (InvoiceSetPricing.IsNoPriceLine), instead of0,00 €. - VAT rate detection no longer reads line items (the old
items is List<object>test failed on NewtonsoftJArrayand pinned@InvoiceVAT_1to19); it now comes fromsms.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 bySetItmId(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), ignoreSetItmIdentirely, 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.