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,146 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: live, backend-authoritative invoice draft editing (ADR 0006).
|
||||
// The browser posts single edits here; the server mutates the in-memory session
|
||||
// (the source of truth), recomputes/validates, and pings the editing browser over
|
||||
// SignalR (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries
|
||||
// only signals (ADR 0007).
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||
|
||||
// POST inv/dopen — { id? | payload? } → { token, version }
|
||||
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
|
||||
{
|
||||
InvoiceDraftSession session;
|
||||
if (HasForm("id") && !string.IsNullOrEmpty(Form("id")))
|
||||
{
|
||||
_logger.LogInformation("Draft dopen: from DB draft {InvId} user={User}", Form("id"), UserAccountID);
|
||||
session = await _invoiceDrafts.OpenFromDraftAsync(Form("id"), UserAccountID, DbSec);
|
||||
}
|
||||
else if (HasForm("payload"))
|
||||
{
|
||||
_logger.LogInformation("Draft dopen: from payload user={User}", UserAccountID);
|
||||
JObject payload;
|
||||
try { payload = JObject.Parse(Form("payload")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Draft dopen: neither 'id' nor 'payload' supplied user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
// The browser holds the token from this response and fetches dstate directly; there is
|
||||
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
|
||||
// only subsequent server-side changes.
|
||||
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dstate — { token } → full view state
|
||||
private async Task<IActionResult> HandleDraftState(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var session = _invoiceDrafts.Get(Form("token"));
|
||||
if (session == null) return DraftGone();
|
||||
return await JSONAsync(_invoiceDrafts.BuildState(session));
|
||||
}
|
||||
|
||||
// POST inv/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||
private async Task<IActionResult> HandleDraftPatch(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token", "delta")) return BadRequest400();
|
||||
InvoiceDraftDelta? delta;
|
||||
try { delta = JsonConvert.DeserializeObject<InvoiceDraftDelta>(Form("delta")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||
|
||||
var session = _invoiceDrafts.ApplyPatch(Form("token"), delta);
|
||||
if (session == null) return DraftGone();
|
||||
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _invoiceDrafts.RenderPreview(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||
private async Task<IActionResult> HandleDraftSave(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
string token = Form("token");
|
||||
var before = _invoiceDrafts.Get(token);
|
||||
if (before == null) return DraftGone();
|
||||
bool existed = !string.IsNullOrEmpty(before.InvId);
|
||||
|
||||
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (fdInv == null) return DraftGone();
|
||||
if (string.IsNullOrEmpty(fdInv.Id))
|
||||
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
||||
var after = _invoiceDrafts.Get(token);
|
||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||
}
|
||||
|
||||
// POST inv/dhistory — { token } → { history[] }
|
||||
private async Task<IActionResult> HandleDraftHistory(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
if (_invoiceDrafts.Get(Form("token")) == null) return DraftGone();
|
||||
var history = _invoiceDrafts.GetHistory(Form("token"))
|
||||
.Select(h => new
|
||||
{
|
||||
timestamp = h.TimestampUtc,
|
||||
target = h.Target,
|
||||
@ref = h.Ref,
|
||||
oldValue = h.OldValue,
|
||||
newValue = h.NewValue,
|
||||
version = h.Version
|
||||
});
|
||||
return await JSONAsync(new { history });
|
||||
}
|
||||
|
||||
// POST inv/ddiscard — { token } → { ok, version }; reload from DB + draftReady
|
||||
private async Task<IActionResult> HandleDraftDiscard(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var session = await _invoiceDrafts.DiscardAsync(Form("token"), UserAccountID, DbSec);
|
||||
if (session == null) return DraftGone();
|
||||
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dclose — { token } → { ok }
|
||||
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
bool ok = _invoiceDrafts.Close(Form("token"));
|
||||
_logger.LogDebug("Draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||
return await JSONAsync(new { ok });
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,16 @@ public partial class IntranetController
|
||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||
case "dopen": return await HandleDraftOpen(fn, id, code);
|
||||
case "dstate": return await HandleDraftState(fn, id, code);
|
||||
case "dpatch": return await HandleDraftPatch(fn, id, code);
|
||||
case "dpreview": return await HandleDraftPreview(fn, id, code);
|
||||
case "dsave": return await HandleDraftSave(fn, id, code);
|
||||
case "dhistory": return await HandleDraftHistory(fn, id, code);
|
||||
case "ddiscard": return await HandleDraftDiscard(fn, id, code);
|
||||
case "dclose": return await HandleDraftClose(fn, id, code);
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
@@ -35,6 +35,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||
private readonly IDraftNotifier _draftNotifier;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -62,7 +64,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders,
|
||||
IEventService events)
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
IDraftNotifier draftNotifier)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -76,6 +80,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
_draftNotifier = draftNotifier;
|
||||
}
|
||||
|
||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||
|
||||
Reference in New Issue
Block a user