Files
Fuchs_Intranet/Fuchs/Docs/Concepts/live-draft-editing.md
T
StefanandClaude Sonnet 5 ecc74cf3c6
Playwright Tests / test (push) Canceled after 0s
Add user-defined invoice set structure dialog (ADR 0016)
Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:55:05 +02:00

12 KiB
Raw Blame History

status, lastUpdated, applyTo, relatedDecisions
status lastUpdated applyTo relatedDecisions
Active 2026-09-09
Fuchs/Services/InvoiceDraft*
Fuchs/Services/IInvoiceDraft*
Fuchs/code/InvoiceSetStructure.cs
Fuchs/Services/ReminderDraft*
Fuchs/Services/IReminderDraft*
Fuchs/code/InvoiceDraftSession.cs
Fuchs/code/InvoiceDraftCalculator.cs
Fuchs/code/ReminderDraftSession.cs
Fuchs/code/ReminderDraftCalculator.cs
Fuchs/Notifications/DraftPreviewHub.cs
Fuchs/Notifications/*DraftNotifier*
Fuchs/Controllers/IntranetController.InvoiceDraft.cs
Fuchs/Controllers/IntranetController.ReminderDraft.cs
Fuchs/js/intranet/**
0006-backend-authoritative-draft-editing.md
0007-targeted-draft-signalr-groups.md
0009-block-setprice-modes-are-irreversible-mutations.md
0016-user-defined-set-structure.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 were the pilot; reminders now mirror the same design (see "Reminders" below).

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: 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. 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 is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section drags post a block.order delta (["id",…]) that reorders Req; positions are then renumbered and pushed back via the view state (applyState/applyPositions). The change history records the changed field (e.g. the new heading text), never the whole block JSON. The PDF (FuchsPdf) renders a heading row per block (FdsInvoiceData.InvoiceBlocks) and shows every position's price (set members are priced like standalone lines; only setonly collapses them), so the PDF preview mirrors the online editor.
  • 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.
  • Set structure (ADR 0016). Every cached line carries a parent link (SetItmId → the owning typ == "set" line, null/self = top level); sets may nest. fds__prepInvoice's implicit tagging is the initial structure; the user edits it in the "Set-Struktur bearbeiten" dialog ($inv.sets, fis.inv_sets.js) which posts one delta per action — set.assign { parent } (drop onto a set / pull out), set.level ±1 (indent under the preceding sibling set / outdent to the grandparent), set.create { typ: set|text, title, parent }, set.move ±1 | { anchor, where } (reorder among siblings / drag before or after a row; the subtree travels along). InvoiceDraftSetStructureDeltas delegates to the pure InvoiceSetStructure (assign / change level / create / place, then linearise the block depth-first and mirror setId/level onto the items contract). Refresh publishes lvl on every line (RecomputeLevels), rendered as an indent class in the editor and a per-level indent in the PDF. item.setprice sums a header's transitive descendants. Structure deltas never change values — only order, membership, levels and therefore position numbers.
  • 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 computes nothing — no header, footer, sums, totals, taxes, or per-line values.

Key files

  • Fuchs/code/InvoiceDraftSession.cs — session + ChangeHistoryEntry + InvoiceDraftSums.
  • Fuchs/code/InvoiceDraftCalculator.cs — pure recompute + validate.
  • Fuchs/code/InvoiceSetStructure.cs — pure set forest: parent links, levels, linearise, assign/level/create (ADR 0016).
  • Fuchs/Services/InvoiceDraftSetStructureDeltas.cs — the set.* patch targets.
  • 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.
  • Fuchs/js/intranet/modules/fis.inv_sets.js — the set-structure dialog ($inv.sets), a view of dstate.req.

Reminders (Zahlungserinnerung)

Reminders mirror the same backend-authoritative model with a reminder-shaped session. A reminder chases a single invoiced amount, so the machinery is simpler than an invoice's: there are no line-item blocks, VAT grouping or reordering — just recipient fields and the amount pair.

  • Endpoints are rem/d* (dopen/dstate/dpatch/dpreview/dsave/dhistory/dclose), dispatched from Do_Process_Reminder. Finalise + email still runs through the existing rem/conf (HandleReminderConf), exactly as invoices finalise through req/sconf.
  • Session (ReminderDraftSession) holds the editor's new (subject / invoiceaddress / invoiceemail / text / amount / amount_payed / CustomValues) and rem (invid / type / invoiceid / invoicedate) blocks, plus server-computed Sums (AmountTotal, AmountPayed, AmountOpen). It reuses the shared ChangeHistoryEntry; validation uses ReminderDraftValidationMessage.
  • Calculation (ReminderDraftCalculator, static/pure): AmountOpen = AmountTotal − AmountPayed, plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested.
  • Deltas (ReminderDraftDelta): scalar email/address/subject/text (HTML-sanitised via the shared InvoiceDraftEditService.HtmlToPlain), the numeric amount/amount_payed (normalised to an invariant decimal string), and contact (→ CustomValues).
  • Orchestration (ReminderDraftEditService, scoped) flushes to the DB by reusing IReminderService.RegisterReminderAsync, and renders previews from a synthesised ReminderRegistration (including the single-invoice invoices row the reminder PDF table renders) so a preview needs no DB round-trip. Note: RegisterReminderAsync is create-only (there is no fds__setReminder update proc), so a re-saved reminder draft does not update the prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
  • Cache/expiry (ReminderDraftCache singleton + ReminderDraftExpiryService) mirror the invoice ones and share the same Fuchs:DraftEditing TTL config.
  • Signals reuse the shared DraftPreviewHub + IDraftNotifier unchanged — the token-keyed groups serve invoice and reminder drafts alike.
  • Frontend ($inv.rd in fis.inv_shared.js) opens/joins on rem/dopen, posts one delta per inline edit and per item-row amount change, renders the open-amount footer + validation from the server state, and previews/finalises through rem/dpreview → rem/dsave → rem/conf. It shares the invoice editor DOM; $inv.d and $inv.rd each key off their own token, so the shared inline editor safely no-ops for whichever mode is inactive.

Reminder key files

  • Fuchs/code/ReminderDraftSession.cs — session + ReminderDraftSums + ReminderDraftValidationMessage.
  • Fuchs/code/ReminderDraftCalculator.cs — pure open-amount recompute + validate.
  • Fuchs/Services/ReminderDraftCache.cs / IReminderDraftCache.cs — in-memory store + TTL.
  • Fuchs/Services/ReminderDraftEditService.cs / IReminderDraftService.cs — orchestration + delta contract.
  • Fuchs/Services/ReminderDraftExpiryService.cs — idle warn/evict monitor.
  • Fuchs/Controllers/IntranetController.ReminderDraft.cs — rem/d* endpoints.