Add backend-authoritative invoice draft editing (ADR 0006/0007)
Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -0,0 +1,84 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-10
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||
- "Fuchs/Notifications/*DraftNotifier*"
|
||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
relatedDecisions:
|
||||
- "0006-backend-authoritative-draft-editing.md"
|
||||
- "0007-targeted-draft-signalr-groups.md"
|
||||
---
|
||||
|
||||
# Live draft editing (backend-authoritative invoice previews)
|
||||
|
||||
## Summary
|
||||
While a back-office user edits an invoice draft, the authoritative state is held in
|
||||
server memory, not in the browser. The browser posts single edits, the server mutates
|
||||
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
||||
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
||||
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
||||
explicit discard), reversing the earlier stateless editor. Invoices are the pilot;
|
||||
reminders are intended to mirror the same design.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Open: Browser --POST inv/dopen {id | payload}--> server builds InvoiceDraftSession, caches it
|
||||
Browser --SignalR JoinDraft(token)--> joins the draft's group; spinner while loading
|
||||
Browser --POST inv/dstate {token}--> renders admin/new/req + server sums + validation
|
||||
|
||||
Edit: Browser --POST inv/dpatch {token, delta}--> mutate + recompute + validate + version++
|
||||
Server --SignalR draftReady{token,version}--> Browser re-fetches inv/dstate, re-renders
|
||||
|
||||
Preview: Browser --POST inv/dpreview {token}--> PDF rendered straight from the cache (no upload)
|
||||
Save: Browser --POST inv/dsave {token}--> flush cache->DB (RegisterInvoiceAsync) + EventService toast
|
||||
History: Browser --POST inv/dhistory {token}--> change list -> "Änderungshistorie" dialog
|
||||
Discard: Browser --POST inv/ddiscard {token}--> reload session from DB draft -> draftReady
|
||||
Close: Browser --POST inv/dclose {token}--> session removed (+ LeaveDraft)
|
||||
|
||||
Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bitte zwischenspeichern"
|
||||
Server (evict) --SignalR draftClosed{token,reason}--> close the editor with a reason
|
||||
```
|
||||
|
||||
- **Session** (`InvoiceDraftSession`) is a pure data holder: the editable payload as the
|
||||
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`
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), and `Validate`
|
||||
(email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively
|
||||
unit-tested.
|
||||
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
|
||||
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
|
||||
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
||||
path), renders previews from a synthesised registration, and discards by reloading.
|
||||
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||
- **Signals** (`DraftPreviewHub` at `/draftpreview` + `IDraftNotifier`) are targeted at the
|
||||
editing browser via a group named after the session token: `draftReady`, `draftExpiring`,
|
||||
`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.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
|
||||
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
|
||||
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
||||
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
||||
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
||||
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
||||
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
||||
|
||||
## Related decisions
|
||||
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||
Reference in New Issue
Block a user