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:
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
| Project | Type | Purpose |
|
||||
|---|---|---|
|
||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
||||
| **Fuchs_DataService** | Class Library | MFR ERP sync logic (entity polling, invoice/DATEV files). Hosted in-process by **Fuchs** as a `PeriodicHostedService` (see ADR 0010) — no longer a standalone process. |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||
@@ -96,17 +96,18 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Fuchs_DataService (Windows Service / Console) │
|
||||
│ Fuchs_DataService (Class Library, hosted in-process) │
|
||||
│ │
|
||||
│ FdsMain.cs — Topshelf host, job definitions │
|
||||
│ PeriodicHostedService — BackgroundService with PeriodicTimer │
|
||||
│ PeriodicHostedService — in Fuchs/Services; BackgroundService + │
|
||||
│ PeriodicTimer, registered in Program.cs │
|
||||
│ when Fds:SyncEnabled (ADR 0010) │
|
||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
||||
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||
│ FdsDebug.cs — Debug/file logging │
|
||||
│ │
|
||||
│ Jobs: MfrSync (every N min) │
|
||||
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||
│ → UpdateIfNecessary_async (entity table sync) │
|
||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||
│ → GetInvoiceFiles_async (invoice PDF download) │
|
||||
@@ -176,7 +177,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
|
||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||
|
||||
### 4.5 Background Service
|
||||
`Fuchs_DataService` runs as a Windows Service (Topshelf) with a `PeriodicHostedService` that polls the MFR ERP system on a timer, syncing entities and downloading invoice files.
|
||||
The MFR ERP sync runs **in-process inside the web app** as a `PeriodicHostedService` (`Fuchs/Services/PeriodicHostedService.cs`), registered in `Program.cs` when `Fds:SyncEnabled` is true. It polls the MFR ERP on a timer (`Fds:ExecutionFrequency_Minutes`), syncing entities and downloading invoice files via the sync logic in the `Fuchs_DataService` library. See ADR [0010](Decisions/0010-mfr-sync-hosted-in-web-app.md).
|
||||
|
||||
### 4.6 Authentication
|
||||
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
||||
@@ -409,7 +410,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
5. ✅ **Resolved** — **Topshelf** removed; `Fuchs_DataService` is now a class library and the MFR sync is hosted in-process by the web app as a `PeriodicHostedService` (ADR 0010).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-10
|
||||
applyTo:
|
||||
@@ -55,13 +55,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed
|
||||
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
|
||||
`RecomputeLineValues` (net/VAT/service-net/service-VAT per line from raw quantity ×
|
||||
price × VAT rate, the `quantChange`/`setVat` port), `RecomputeTotals`
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
|
||||
(numbers every line except heading/free-text lines continuously across the whole invoice —
|
||||
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
|
||||
including after a reorder),
|
||||
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
|
||||
exhaustively unit-tested.
|
||||
exhaustively unit-tested. **The online editor performs no arithmetic of any kind** — not
|
||||
header, footer, sums, totals, taxes, nor a single line's own net/VAT value: `$inv.quantChange`
|
||||
and `$inv.setVat` only post the raw field the user changed (qty/price/vat rate), and
|
||||
`$inv.invSumUpdate` only reassembles the row-contract array needed to post `req` — it computes
|
||||
no totals, no VAT breakdown, and no service-refund note figures. All of those are rendered
|
||||
exclusively from `dstate.sums` via `$inv.d.footer`.
|
||||
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
|
||||
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
|
||||
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
|
||||
@@ -85,7 +91,8 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
`draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`.
|
||||
- **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins,
|
||||
posts one delta per change, shows a loading state whenever awaiting a signal, and offers
|
||||
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It no longer computes totals.
|
||||
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It computes nothing — no
|
||||
header, footer, sums, totals, taxes, or per-line values.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-10
|
||||
applyTo:
|
||||
@@ -36,9 +36,11 @@ singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftServ
|
||||
(`InvoiceDraftEditService`). The browser is a pure view/input layer.
|
||||
|
||||
- **Truth & calculation on the server.** `InvoiceDraftCalculator` is the pure,
|
||||
unit-tested port of the former client-side math (`quantChange` + `invSumUpdate`),
|
||||
including the §13b reverse-charge rule and VAT-per-rate grouping. The browser never
|
||||
computes totals; it renders the server's `sums`.
|
||||
unit-tested port of the former client-side math (`quantChange` + `setVat` +
|
||||
`invSumUpdate`), including per-line net/VAT/service-value multiplication
|
||||
(`RecomputeLineValues`), the §13b reverse-charge rule and VAT-per-rate grouping. The
|
||||
browser performs **no arithmetic whatsoever** — not even a single line's
|
||||
`net = qty × price` — it only renders the server's `req`/`sums`.
|
||||
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
|
||||
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
|
||||
The server mutates the session, recomputes, validates, bumps a version, and pings the
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/Services/ReminderDraft*"
|
||||
- "Fuchs/Services/IReminderDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/code/ReminderDraftSession.cs"
|
||||
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0008 — Invoices and reminders (all kinds) are fully backend-authoritative; PDF and online editor must render identical content
|
||||
|
||||
## Context
|
||||
ADR [0006](0006-backend-authoritative-draft-editing.md) established the backend-authoritative
|
||||
draft-editing model for invoices and noted reminders were "intended to follow the identical
|
||||
pattern as a second phase". Both are now implemented (`InvoiceDraftEditService` /
|
||||
`ReminderDraftEditService`). In practice, ambiguity kept resurfacing about *which* invoice/
|
||||
reminder kinds this covers and *which* kinds of change qualify as "must be computed server-side":
|
||||
e.g. whether a purely presentational client-side re-render (set-price display toggle, item
|
||||
reordering, position renumbering) was allowed to keep any client-side math, and whether this
|
||||
applies uniformly to every invoice type (regular `r`, partial/Abschlag `i`, final `f`, storno
|
||||
`c`) and every reminder stage, not just the pilot "regular invoice" flow. This decision closes
|
||||
that ambiguity explicitly.
|
||||
|
||||
## Decision
|
||||
**Every invoice (all `InvoiceType` kinds: regular, partial/Abschlagsrechnung, final/
|
||||
Schlussrechnung, Storno/credit) and every reminder (all reminder stages/Mahnstufen) is
|
||||
backend-authoritative while being drafted or previewed.** This generalises and makes explicit
|
||||
what ADR 0006 already implied for the pilot flow:
|
||||
|
||||
- **Any calculation** (net/VAT/gross totals, per-rate VAT grouping, service-refund figures,
|
||||
§13b reverse-charge suppression, set-price sums, open-amount for reminders, position/line
|
||||
numbering) is performed exclusively by the server (`InvoiceDraftCalculator`,
|
||||
`ReminderDraftCalculator`, `InvoiceSetPricing`). The browser never sums, subtracts, or
|
||||
otherwise derives a monetary or positional value — it only displays server-computed values.
|
||||
This includes the single-line arithmetic that used to run in `quantChange`/`setVat`
|
||||
(`net_val = qty × price`, `vat_val = net_val × rate`, service-net/-VAT splits): those
|
||||
handlers now only post the raw, unmultiplied field the user typed (`qn`/`v`/`vat`) and the
|
||||
server (`InvoiceDraftCalculator.RecomputeLineValues`) computes every derived line value.
|
||||
Likewise the invoice footer (net/VAT-by-rate/gross), the per-block "isum" cell, and the
|
||||
service-refund note figures are rendered exclusively from `dstate.sums` (`$inv.d.footer`);
|
||||
`$inv.invSumUpdate` no longer accumulates any of these — it only reassembles the row
|
||||
contract array needed to post `req` to the server and (on first load) seeds the session.
|
||||
- **Any setting** (§13b flag, set-pricing display mode, payment terms, contact, custom values,
|
||||
…) is applied server-side via a named `InvoiceDraftDelta`/`ReminderDraftDelta` target and
|
||||
reflected back through `dstate`. The client never mutates its local model as the source of
|
||||
truth for a setting; it optimistically reflects the *request* but always re-renders from the
|
||||
next `dstate`/`draftReady` refresh.
|
||||
- **Any text change** (recipient email/address, invoice title, provision location/period,
|
||||
section headings, item name/description/notes) is sanitised and stored server-side
|
||||
(`InvoiceDraftEditService.HtmlToPlain` et al.); the server's stored value is the one that
|
||||
reaches the PDF and any reloaded draft.
|
||||
- **Any reordering** (drag-reorder of service-request blocks/sections, drag-reorder of item
|
||||
rows within a block) is committed as a `block.order` (or equivalent) delta; the server
|
||||
performs the actual reorder and renumbers positions (`InvoiceDraftCalculator.RecomputePositions`).
|
||||
The client's drag interaction is input only — the rendered order after a refresh is the
|
||||
server's order, not whatever the browser left in the DOM mid-drag.
|
||||
- **Irreversible one-way conversions** (e.g. "Auf Setpreis umstellen" — switching a set's
|
||||
member items from individual prices to a single set price) are likewise backend-only
|
||||
operations (`item.setprice` delta / `InvoiceDraftEditService.ApplyItemSetPrice`), never
|
||||
computed or applied in the browser.
|
||||
- **The PDF must render 100% the same information and content as the online editor at any
|
||||
given moment.** Both consume the identical authoritative session data:
|
||||
- The online editor renders `dstate`'s `req`/`sums`/`setDisplay`/`notes` — all server-computed.
|
||||
- The PDF preview (`inv/dpreview`, `rem/dpreview`) renders straight from the same cached
|
||||
session via a synthesised registration (`InvoiceDraftEditService.RenderPreview` /
|
||||
`ReminderDraftEditService`'s reminder equivalent) — **not** from a separate client upload
|
||||
or a re-derived model.
|
||||
- `FuchsPdf.BuildInvoiceNotes` (notice paragraphs) is called identically for both the
|
||||
editor's `notes` array and the PDF body, so intro/closing texts can never drift between
|
||||
the two renderings.
|
||||
- Any new editor-visible fact (a new total, a new flag, a new note) must be added to the
|
||||
shared session/service layer once, not duplicated as separate editor-only and PDF-only
|
||||
logic.
|
||||
- This applies for the full lifecycle while a document is a draft (open → edit → preview →
|
||||
Zwischenspeichern) up to finalise; a finalised, persisted invoice/reminder is immutable
|
||||
and is rendered straight from its stored DB data (no draft session involved) — that path
|
||||
already has no client-side math to begin with.
|
||||
|
||||
## Consequences
|
||||
- New invoice/reminder editor features must be modelled as a server-side delta + calculator
|
||||
change, exactly as ADR 0006 already requires; this decision removes any residual excuse to
|
||||
special-case a "just this one is presentational, do it in JS" shortcut for reordering,
|
||||
display-mode toggles, or one-way conversions.
|
||||
- Any PDF-only or editor-only special-casing found in review is a bug against this decision —
|
||||
the shared session/service must be extended so both renderers read the same value/flag.
|
||||
- Reminder "Mahnstufen" and every invoice type share this obligation; there is no partial/
|
||||
Abschlagsrechnung, Schlussrechnung, or Storno exemption while such a document is still a
|
||||
draft going through the same `dopen`/`dpatch`/`dpreview`/`dsave` flow.
|
||||
- Test coverage for the cache/session layer (`InvoiceDraftEditService`, `ReminderDraftEditService`,
|
||||
`InvoiceDraftCalculator`, `InvoiceSetPricing`) must exercise every mutating operation
|
||||
(text edits, reordering, all three set-pricing display modes, the set-price conversion,
|
||||
multi-rate VAT sums, full recompute) against mock datasets, since this is now the single
|
||||
place all of these behaviours are guaranteed correct — see `Fuchs.Tests/InvoiceDraftServiceTests.cs`,
|
||||
`Fuchs.Tests/ReminderDraftServiceTests.cs`, `Fuchs.Tests/InvoiceDraftCalculatorTests.cs`,
|
||||
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
|
||||
## Alternatives considered
|
||||
- **Scope this only to the invoice pilot flow** (leave reminders/other invoice kinds
|
||||
ambiguous): rejected — the ambiguity itself was the problem being fixed; the underlying
|
||||
session/service code already treats all kinds uniformly, so documenting anything narrower
|
||||
would misrepresent the code.
|
||||
- **Allow "purely cosmetic" client-side math for reordering/display toggles**: rejected —
|
||||
history showed exactly this exception is where drift crept in (e.g. the set-price toggle
|
||||
originally computed sums in the browser before being moved server-side); no exception is
|
||||
granted.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-14
|
||||
applyTo:
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
||||
|
||||
## Context
|
||||
ADRs [0006](0006-backend-authoritative-draft-editing.md) and
|
||||
[0008](0008-invoices-and-reminders-fully-backend-authoritative.md) made draft editing
|
||||
backend-authoritative. Under that model the set-price feature had **three** functions, of
|
||||
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
|
||||
whole-invoice **display modes** (`SetDisplayMode.SetPrice`/`SetOnly`): a non-mutating,
|
||||
render-time transform (`InvoiceSetPricing.Build`) over explicit `type == "set"` header items
|
||||
and their `SetItmId` members, persisted only as an `admin.setmode` flag. ADR 0008 calls them
|
||||
"display-mode toggles" and treats them as presentational.
|
||||
|
||||
The product owner has redefined those two menu functions. They are **not** display toggles and
|
||||
they are **not** keyed on set-item membership:
|
||||
|
||||
- Their grouping is the **service request** (`ServiceRequestId` = the editor's tbody block),
|
||||
never `SetItmId`. Every block is treated as one set, whether or not it contains any
|
||||
`type == "set"` item.
|
||||
- Applying a mode is an **irreversible data change** written hard into the cached draft
|
||||
dataset — not a reversible view flag. There is no toggle back; the user adjusts the result
|
||||
by hand afterwards.
|
||||
|
||||
This decision records that redefinition. It **refines** ADR 0008's characterisation of these
|
||||
two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR
|
||||
0008's broader rule — every calculation server-side, and the PDF renders 100% the same content
|
||||
as the editor — remains fully in force and is not superseded.
|
||||
|
||||
## Decision
|
||||
There are three distinct set-price operations, kept clearly separated:
|
||||
|
||||
1. **Inline set-item switch — unchanged.** The row context button on a single `type == "set"`
|
||||
item (`$inv.toSetPrice` → `item.setprice` delta → `InvoiceDraftEditService.ApplyItemSetPrice`).
|
||||
It is `SetItmId`-based, sums the header's members onto the header, sets the members' prices to
|
||||
`null` (empty cell, excluded from the sum — consistent with modes 2 & 3, not `0`), and is
|
||||
one-way. This is the **only** set-price operation that reads `SetItmId`. The button is shown —
|
||||
and the operation available — **only** for items that are `type == "set"` **and** carry a
|
||||
`SetItmId` (and are still unconverted, i.e. own price `0`); an item missing either condition
|
||||
never offers it.
|
||||
|
||||
2. **"Set mit Preis" (menu) — per-block, irreversible mutation.** Applied server-side to the
|
||||
cached `InvoiceDraftSession`, grouped by `ServiceRequestId`. For **every** service-request
|
||||
block:
|
||||
- Insert one dedicated, emphasised **set row** at the top of the block, carrying the block's
|
||||
aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real,
|
||||
editable line item with its own id, so the user can manually change the set value afterwards
|
||||
as an ordinary item edit.
|
||||
- **Null out** the price of every existing item row in the block (set the price fields to
|
||||
`null`, **not** `0`) so the row renders with an **empty** price/total cell. `null` and `0`
|
||||
are semantically distinct here: `null` means "no price — render an empty cell and exclude
|
||||
from the block sum", whereas `0` would legitimately print `0,00 €`. The rows themselves are
|
||||
retained.
|
||||
|
||||
3. **"Nur Set mit Preis" (menu) — per-block, irreversible mutation.** As above, grouped by
|
||||
`ServiceRequestId`. For every block:
|
||||
- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
|
||||
- **Remove** every existing item row in the block from the dataset entirely (the lines are
|
||||
gone, not merely hidden).
|
||||
|
||||
Properties common to the two menu modes (2 and 3):
|
||||
|
||||
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` (the "cache
|
||||
dataset") as a mutating `InvoiceDraftDelta`, computed on the server (never in the browser).
|
||||
There is no render-time `admin.setmode` grouping flag driving how lines are shown, and no
|
||||
reversible toggle.
|
||||
- **Irreversible.** There is no patch to undo it. The only ways back are discarding the draft
|
||||
(reloads the DB state) or hand-editing the resulting rows.
|
||||
- **`SetItmId` is irrelevant.** Membership is the block, full stop.
|
||||
- **Total unchanged.** The inserted set row's value equals the sum of the block's original items,
|
||||
which are then excluded from the sum — either because their price is `null` (mode 2, `null`
|
||||
counts as no contribution) or because they are gone (mode 3). So `InvoiceBalance`/
|
||||
`InvoiceBalance_net` are unaffected.
|
||||
- **Editor and PDF render identically** (ADR 0008): the dedicated set row is emphasised in both,
|
||||
and both read the same mutated session.
|
||||
|
||||
## Consequences
|
||||
- For the two menu modes, `InvoiceSetPricing` stops being a non-mutating render transform over
|
||||
`type == "set"` groups; the grouping/insert/blank/remove is a real mutation in
|
||||
`InvoiceDraftEditService`, keyed on the block. The inline `item.setprice` switch (operation 1)
|
||||
remains the sole `SetItmId`-based, set-item-scoped operation.
|
||||
- The previous `admin.setmode` display-flag model for these two modes — persisted `setmode:`
|
||||
`InvoiceOptions` token, "Set-Preisanzeige menu entry disappears while unset", `Build(...)`
|
||||
choosing `ShowPrice` per member at render time — is retired. Because the operation is a
|
||||
one-shot irreversible mutation, there is no persisted display state to toggle. Any residual
|
||||
`setmode:` token must degrade safely (ignored) and is no longer (re-)persisted.
|
||||
- New/changed behaviour must be modelled as a server-side delta + calculator/service change and
|
||||
covered by tests (`InvoiceDraftServiceTests`, `InvoiceSetPricingTests`): for each menu mode,
|
||||
assert the inserted set row's value equals the block sum, the total is unchanged, mode 2
|
||||
nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op.
|
||||
- Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008
|
||||
reminder mirror).
|
||||
|
||||
## Alternatives considered
|
||||
Each of the following was raised and **explicitly decided against** as part of accepting this
|
||||
decision — they are rejected choices, not open options to revisit without a superseding ADR:
|
||||
|
||||
- **Keep them as non-mutating display toggles** (the prior design): **explicitly rejected** by the
|
||||
product owner — the set price must be a real, hand-editable value baked into the document, and
|
||||
"Nur Set mit Preis" must actually drop the member lines, not just hide them.
|
||||
- **Zero the blanked members' prices instead of nulling them**: **explicitly rejected** — `0` is
|
||||
ambiguous (it prints `0,00 €`), so the frontend could not tell an empty cell from a genuine
|
||||
zero price. Blanked members are set to `null` precisely to make "no price" unambiguous.
|
||||
- **Carry the set price on the existing block heading row** instead of a dedicated row:
|
||||
**explicitly rejected** — a separate, individually-editable set row keeps the section-heading
|
||||
semantics intact and gives the user a concrete line to adjust afterwards.
|
||||
- **Group by `SetItmId`/`type == "set"` headers like the inline switch:** **explicitly rejected**
|
||||
— the menu modes present each *service request* as one set, independent of any mfr set-item;
|
||||
conflating the two groupings is exactly the ambiguity this decision removes.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs_DataService/**"
|
||||
- "Fuchs/Services/PeriodicHostedService.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0010 — MFR ERP sync runs in-process in the web app; Fuchs_DataService is a library
|
||||
|
||||
## Context
|
||||
`Fuchs_DataService` was a standalone console/Windows Service hosted by **Topshelf**.
|
||||
It carried its own `appsettings.json`, its own file-based configuration bootstrap
|
||||
(`FdsConfig.Initialize()` reading the file), its own logging provider
|
||||
(`FdsLoggerProvider`/`AddFdsLogging`), and a machine-name guard in `Main()` that
|
||||
disabled the service on developer PCs. In practice the web app (`Fuchs`) already
|
||||
referenced the project, already called `fds.FdsConfig.Initialize(builder.Configuration)`,
|
||||
already registered `IFdsMfr`, and already created `FdsMfrClient` via
|
||||
`IMfrClientFactory` — so the sync logic and the web app were sharing the same code
|
||||
and the same connection strings while the service kept a second, parallel copy of
|
||||
configuration/logging/hosting.
|
||||
|
||||
Maintaining a separate process, a second `appsettings.json` (duplicating connection
|
||||
strings + MFR credentials), Topshelf, and a machine-name guard added drift risk and
|
||||
operational overhead for no benefit the web host couldn't provide.
|
||||
|
||||
## Decision
|
||||
- **`Fuchs_DataService` is now a class library** (no `OutputType Exe`, no Topshelf,
|
||||
no own `appsettings.json`, no `install.bat`/`un-install.bat`, no
|
||||
`System.Configuration.ConfigurationManager`). It contains only the MFR sync
|
||||
logic (`FdsMfr`/`IFdsMfr`, `FdsMfrClient`), the DATEV/zip helpers, `FdsShared`,
|
||||
`FdsDebug`, and `FdsConfig`.
|
||||
- **The host owns configuration.** `FdsConfig` keeps only
|
||||
`Initialize(IConfiguration)` (the file-based overload is gone). The Fuchs web app
|
||||
injects its `IConfiguration`; connection strings (`fuchs_fds_ConnectionString`)
|
||||
and MFR credentials (`Fds:MFR_*`, Key Vault-managed) come from Fuchs.
|
||||
- **The host owns logging.** `FdsLoggerProvider`/`AddFdsLogging` were removed; the
|
||||
library uses only `ILogger`/`ILoggerFactory` injected from Fuchs's logging
|
||||
(`AddFuchsLogging`). The library depends only on
|
||||
`Microsoft.Extensions.Logging.Abstractions` + `Microsoft.Extensions.Configuration.Binder`.
|
||||
- **The sync runs in-process.** `PeriodicHostedService` (the generic
|
||||
multi-job `BackgroundService`) moved to `Fuchs/Services/` and is registered in
|
||||
`Program.cs` as a hosted service. The single `MfrSync` job calls
|
||||
`UpdateIfNecessary_async` → `UpdateRequested_async` → `GetInvoiceFiles_async`.
|
||||
- **A config flag replaces the machine-name guard.** Registration is gated by
|
||||
`Fds:SyncEnabled` (default `false` when unset): `true` in production
|
||||
`appsettings.json`, `false` in `appsettings.Development.json`, so developer
|
||||
machines never poll the ERP. Interval (`Fds:ExecutionFrequency_Minutes`, default
|
||||
15) and debug verbosity (`Fds:DebugDetails`) also come from the `Fds` section.
|
||||
|
||||
## Consequences
|
||||
- One process, one configuration surface, one logging pipeline. The sync inherits
|
||||
the web app's OpenTelemetry, DI, and lifetime automatically.
|
||||
- **Instance fan-out is a consideration:** the sync now runs in *every* web instance
|
||||
where `Fds:SyncEnabled` is true. The intranet is deployed single-instance, so this
|
||||
is acceptable; if Fuchs is ever scaled out, gate the sync to a single instance
|
||||
(leader election / dedicated instance flag) to avoid concurrent MFR polling.
|
||||
- Enabling/disabling the sync per environment is now a config change, not a
|
||||
redeploy of a separate service.
|
||||
- `Fuchs_DataService` is intentionally kept as a separate project (not folded into
|
||||
`Fuchs`) so the sync logic stays isolated and unit-testable; `Fuchs.Tests` covers
|
||||
it via `InternalsVisibleTo`.
|
||||
- **The `Squid-Box.SevenZipSharp` native dependency (and the bundled `7z.dll`) was
|
||||
removed** from both `Fuchs_DataService` and `Fuchs`. The only live archive use — the
|
||||
DATEV export — is a plain, unencrypted zip, now produced via the native
|
||||
`OCORE.zip.filesToZipArchive` (`System.IO.Compression`). The 7-Zip-only paths
|
||||
(`.7z`/LZMA2, AES-encrypted archives, extraction, `FastAppend`) had no callers.
|
||||
Trade-off accepted: `System.IO.Compression` cannot produce `.7z` or password/AES
|
||||
archives; if that is ever required, a compression library must be reintroduced.
|
||||
|
||||
## Alternatives considered
|
||||
- **Native `dotnet` Worker Service (separate process).** Would modernize off
|
||||
Topshelf but keep the duplicate-config/duplicate-logging/second-process problem.
|
||||
Rejected because the web app already hosts everything the sync needs.
|
||||
- **Fold the code directly into `Fuchs`.** Rejected to preserve a clean, separately
|
||||
testable sync library and avoid enlarging the web project.
|
||||
@@ -94,8 +94,10 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||
(default) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
|
||||
presentational — totals never change.
|
||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||
while the set header itself has no own price; once a set is converted via
|
||||
the item switch (`item.setprice`), the conversion is one-way and totals
|
||||
recompute from that point on.
|
||||
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||
|
||||
All of this recalculates client-side totals live via the `fds.inv` event
|
||||
@@ -122,7 +124,7 @@ On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
|
||||
JSON into SQL parameters and calls, in one batch:
|
||||
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
||||
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
|
||||
e.g. `setmode:itemprices`, `§13b`).
|
||||
e.g. `setmode:setonly`, `§13b`).
|
||||
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||
→ `fds__createInvoice_Details` again.
|
||||
|
||||
|
||||
@@ -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