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:
Stefan
2026-07-16 13:34:23 +02:00
parent f724b9b59d
commit 49e3ed2673
102 changed files with 2501 additions and 8438 deletions
@@ -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.