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

Document decisions on backend-authoritative invoice and reminder handling

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

Define irreversible mutations for set-price modes in invoices

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

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

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

Add publish profile and periodic hosted service for job scheduling

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

Add dotnet-tools.json for EF Core CLI tools

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

7.6 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-07-14
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

0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row

Context

ADRs 0006 and 0008 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.toSetPriceitem.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 rejected0 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.