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.
This commit is contained in:
@@ -1,24 +1,54 @@
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
|
||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||
> (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.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
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:
|
||||
|
||||
| Mode | Set line | Member items | Use as |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
| # | 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** |
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
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.
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
> **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 `InvoiceSetLine`s, each with
|
||||
@@ -34,15 +64,15 @@ Three display modes (switchable 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. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
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`:
|
||||
@@ -51,12 +81,20 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`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); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
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#
|
||||
@@ -80,20 +118,95 @@ no per-rate `vat_*` keys.
|
||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
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 switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
### 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](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. 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 in
|
||||
a different mode later — not done here.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user