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:
Stefan
2026-07-10 13:29:35 +02:00
co-authored by Claude Opus 4.8
parent e53d8962ad
commit af445c015e
26 changed files with 2121 additions and 6 deletions
+26
View File
@@ -0,0 +1,26 @@
using Fuchs.intranet;
namespace Fuchs.Services;
/// <summary>
/// In-memory store of live invoice draft editing sessions (see ADR 0006).
/// Singleton, single-instance only — scale-out would need a distributed cache /
/// sticky sessions (documented limitation). Keyed by the session token.
/// </summary>
public interface IInvoiceDraftCache
{
/// <summary>Stores (or replaces) a session under its token.</summary>
void Set(InvoiceDraftSession session);
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
InvoiceDraftSession? Get(string token);
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
InvoiceDraftSession? Remove(string token);
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
IReadOnlyList<InvoiceDraftSession> Snapshot();
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
TimeSpan IdleTtl { get; }
}
+82
View File
@@ -0,0 +1,82 @@
using Fuchs.intranet;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Orchestrates a live, backend-authoritative invoice draft editing session
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
/// open (seed the cache), apply single edits, build the view state, render a PDF
/// preview from the cache, flush to the DB ("Zwischenspeichern"), discard (reload
/// from the DB) and expose the change history. All totals/VAT are computed by
/// <see cref="InvoiceDraftCalculator"/> — the browser never calculates.
/// </summary>
public interface IInvoiceDraftService
{
/// <summary>
/// Seeds a new cache session for a brand-new draft from the editor's initially
/// assembled payload (<c>admin</c> / <c>new</c> / <c>req</c> blocks). Computes
/// totals + validation and returns the session (with its fresh token/version).
/// </summary>
InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId);
/// <summary>
/// Seeds a cache session by loading an existing DB draft (<c>fds__getInvoice</c>)
/// and reshaping it into the editor's block/item structure. Computes + caches.
/// </summary>
Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
InvoiceDraftSession? Get(string token);
/// <summary>
/// Applies one editor change to the cached session: mutates the payload,
/// re-derives affected item math + totals, re-validates, appends a history entry
/// and bumps the version. Returns the mutated session, or null if the token is unknown.
/// </summary>
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
object BuildState(InvoiceDraftSession session);
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
/// <summary>
/// Persists the cached session to the DB via the existing invoice registration
/// path ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
/// <summary>
/// Discards the session's in-memory changes by reloading it from the DB draft
/// (requires a prior flush / an existing <c>InvId</c>). Bumps the version so the
/// client refetches. Returns the reloaded session, or null if the token is unknown.
/// </summary>
Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Removes the session from the cache (explicit close/finalise). Returns true if one was present.</summary>
bool Close(string token);
}
/// <summary>
/// A single editor change posted to <c>inv/dpatch</c>. <see cref="Target"/> names the
/// field/operation (e.g. "email", "p13b", "item.qty"); <see cref="Ref"/> is the item or
/// block id it applies to (when relevant); <see cref="Value"/> is the new value.
/// </summary>
public sealed class InvoiceDraftDelta
{
public string Target { get; set; } = "";
public string Ref { get; set; } = "";
public JToken? Value { get; set; }
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
public string ValueString =>
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using Fuchs.intranet;
using Microsoft.Extensions.Configuration;
namespace Fuchs.Services;
/// <summary>
/// Single-instance, in-memory implementation of <see cref="IInvoiceDraftCache"/>
/// backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session
/// token. A plain dictionary (rather than <c>IMemoryCache</c>) is used on purpose:
/// the <see cref="InvoiceDraftExpiryService"/> needs to enumerate sessions and warn
/// the user <b>before</b> eviction, which opaque cache-entry expiry does not allow.
///
/// Idle TTL and the pre-expiry warning lead time are configurable under
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
/// </summary>
public sealed class InvoiceDraftCache : IInvoiceDraftCache
{
private readonly ConcurrentDictionary<string, InvoiceDraftSession> _sessions = new(StringComparer.Ordinal);
public TimeSpan IdleTtl { get; }
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
public TimeSpan ExpiryWarnLead { get; }
public InvoiceDraftCache(IConfiguration configuration)
{
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
}
public void Set(InvoiceDraftSession session)
{
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
session.Touch();
_sessions[session.Token] = session;
}
public InvoiceDraftSession? Get(string token)
{
if (string.IsNullOrEmpty(token)) return null;
if (_sessions.TryGetValue(token, out var s))
{
s.Touch();
// A touch resets the idle window, so a fresh warning is due next time it lapses.
s.ExpiryWarningSent = false;
return s;
}
return null;
}
public InvoiceDraftSession? Remove(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return _sessions.TryRemove(token, out var s) ? s : null;
}
public IReadOnlyList<InvoiceDraftSession> Snapshot() => _sessions.Values.ToList();
}
+504
View File
@@ -0,0 +1,504 @@
using System.Globalization;
using System.Web;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
/// <see cref="InvoiceDraftSession"/> (via <see cref="IInvoiceDraftCache"/>), applies
/// single edits, computes totals with <see cref="InvoiceDraftCalculator"/>, renders
/// previews and flushes to the DB by reusing the existing <see cref="IInvoiceService"/>
/// registration path — no new persistence. Deliberately I/O-thin so the calculation
/// remains unit-testable.
/// </summary>
public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
private readonly IInvoiceDraftCache _cache;
private readonly IInvoiceService _invoices;
private readonly Fuchs_intranet _intranet;
private readonly ILogger<InvoiceDraftEditService> _logger;
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
Fuchs_intranet intranet, ILogger<InvoiceDraftEditService> logger)
{
_cache = cache;
_invoices = invoices;
_intranet = intranet;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
// ── Open ─────────────────────────────────────────────────────────────────
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
{
var session = new InvoiceDraftSession
{
Token = NewToken(),
UserAccountId = userAccountId,
InvId = payload["invid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
};
session.Admin = payload["admin"] as JObject ?? new JObject();
session.New = payload["new"] as JObject ?? new JObject();
session.Req = payload["req"] as JArray ?? new JArray();
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Draft session {Token} opened from payload (invId={InvId}, user={User})",
session.Token, session.InvId, userAccountId);
return session;
}
public async Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec)
{
var session = new InvoiceDraftSession { Token = NewToken(), UserAccountId = userAccountId, InvId = invId };
await LoadDraftIntoAsync(session, invId, userAccountId, dbSec);
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Draft session {Token} opened from DB draft {InvId} (user={User})",
session.Token, invId, userAccountId);
return session;
}
public InvoiceDraftSession? Get(string token) => _cache.Get(token);
// ── Patch ──────────────────────────────────────────────────────────────────
public InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta)
{
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = delta.ValueString,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>Applies one delta to the payload; returns whether anything changed and captures the prior value.</summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
switch (d.Target)
{
case "email": return SetNew(s, "invoiceemail", d, ref oldValue);
case "address": return SetNew(s, "invoiceaddress", d, ref oldValue);
case "title": return SetNew(s, "invoicetitle", d, ref oldValue);
case "provisionperiod": return SetNew(s, "provisionperiod", d, ref oldValue);
case "provisionlocation":
oldValue = Str(s.New["provisionlocation"]);
s.New["provisionlocation"] = d.ValueString;
s.New["loc"] = d.ValueString; // editor mirrors both
return true;
case "contact": return SetContact(s, d, ref oldValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
case "p13b":
oldValue = Str(s.Admin["p13b"]);
bool next = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value)
: !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = next;
return true;
case "item.qty": return SetItem(s, d, "quantityhours", recompute: true, ref oldValue);
case "item.price": return SetItem(s, d, "net", recompute: true, ref oldValue);
case "item.note": return SetItem(s, d, "Note", recompute: false, ref oldValue);
case "item.remove": return RemoveItem(s, d, ref oldValue);
case "block.combine":
case "item.combine": return SetBlockFlag(s, d, "onesum", ref oldValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue);
default: return false;
}
}
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.New[key]);
s.New[key] = d.ValueString;
return true;
}
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.Admin[key]);
s.Admin[key] = d.ValueString;
return true;
}
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.New["CustomValues"]);
JObject cvo = TryParseObject(oldValue);
if (d.Value is JObject vo)
{
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
}
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
return true;
}
private static bool SetItem(InvoiceDraftSession s, InvoiceDraftDelta d, string key, bool recompute, ref string oldValue)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item[key]);
item[key] = d.Value ?? JValue.CreateString(d.ValueString);
if (recompute) InvoiceDraftCalculator.RecomputeItem(item);
return true;
}
private static bool RemoveItem(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item["NameOrNumber"]).ne(Str(item["htmltext"]));
item.Remove();
return true;
}
private static bool SetBlockFlag(InvoiceDraftSession s, InvoiceDraftDelta d, string key, ref string oldValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block[key]);
block[key] = d.Value != null && d.Value.Type != JTokenType.Null ? AsBool(d.Value) : !AsBool(block[key]);
return true;
}
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block["text"]);
block.Remove();
return true;
}
// ── View state / history ────────────────────────────────────────────────
public object BuildState(InvoiceDraftSession session)
{
session.Touch();
return new
{
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = session.Sums.VatByRate,
block_net = session.Sums.NetByBlock
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count
};
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
// ── Flush / preview / discard ─────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
bool change = !string.IsNullOrEmpty(session.InvId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return reg;
}
public Document? RenderPreview(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _invoices.GenerateInvoicePdf(fds, draft: true);
}
public async Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
if (string.IsNullOrEmpty(session.InvId))
{
_logger.LogInformation("Draft {Token} discard requested but never saved — nothing to reload (user={User})", token, userAccountId);
return session;
}
await LoadDraftIntoAsync(session, session.InvId, userAccountId, dbSec);
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = userAccountId,
Target = "discard",
NewValue = "Änderungen verworfen",
Version = session.Version
});
_cache.Set(session);
_logger.LogInformation("Draft {Token} discarded, reloaded from DB invoice {InvId} (user={User})", token, session.InvId, userAccountId);
return session;
}
public bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(InvoiceDraftSession session)
{
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.Validate(session);
}
private static string NewToken() => Guid.NewGuid().ToString("N");
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
{
foreach (var b in s.Req)
if (b is JObject bo && Str(bo["Id"]) == blockId) return bo;
return null;
}
private static JObject? FindItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && bo["items"] is JArray items)
foreach (var it in items)
if (it is JObject io && Str(io["Id"]) == itemId) return io;
return null;
}
/// <summary>Builds the <see cref="FdsInvoiceData"/> from the session — the server-side port of <c>invcPayload</c>.</summary>
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
{
var adm = (JObject)session.Admin.DeepClone();
var nw = (JObject)session.New.DeepClone();
nw["total_net"] = session.Sums.TotalNet;
nw["total_gross"] = session.Sums.TotalGross;
nw["title"] = nw["invoicetitle"] ?? nw["title"] ?? "";
nw["provisionlocation"] = nw["loc"] ?? nw["provisionlocation"] ?? "";
nw["paymentterm"] = adm["paymentterms"] ?? nw["paymentterm"] ?? "";
adm["customerid"] = adm["customerid"] ?? adm["CustomerId"];
var vat = new JObject();
foreach (var kv in session.Sums.VatByRate) vat[kv.Key] = kv.Value;
var sms = new JObject
{
["ttn"] = session.Sums.TotalNet,
["ttb"] = session.Sums.TotalGross,
["ttvat"] = session.Sums.TotalVat,
["tscn"] = session.Sums.ServiceNet,
["tscvat"] = session.Sums.ServiceVat,
["vat"] = vat
};
var jobj = new JObject
{
["admin"] = adm,
["new"] = nw,
["sms"] = sms,
["req"] = session.Req.DeepClone()
};
return new FdsInvoiceData(jobj);
}
/// <summary>
/// Synthesises the <c>InvoiceRegistration</c> dictionary a draft PDF render needs,
/// straight from the cached session — so a preview requires no DB round-trip and no
/// client upload. Mirrors the columns <c>fds__getInvoice</c> would return for a draft.
/// </summary>
private GenericObjectDictionary SynthesizeRegistration(InvoiceDraftSession session)
{
string title = Str(session.New["invoicetitle"]).ne(Str(session.New["title"]));
string loc = Str(session.New["provisionlocation"]).ne(Str(session.New["loc"]));
var d = new Dictionary<string, object>
{
["Id"] = session.InvId,
["InvoiceType"] = Str(session.Admin["type"]).ne("R"),
["InvoiceId"] = "",
["InvoiceTitle"] = title,
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["ProvisionLocation"] = loc,
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
["InvoiceBalance"] = session.Sums.TotalGross,
["InvoiceBalance_net"] = session.Sums.TotalNet,
["CustomValues"] = Str(session.New["CustomValues"]),
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
int idx = 0;
foreach (var kv in session.Sums.VatByRate)
{
idx++;
if (idx > 2) break;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
}
return new GenericObjectDictionary(d);
}
/// <summary>Builds the InvoiceOptions CSV (§13b + setmode) from the session admin flags — matches <see cref="FdsInvoiceData.BuildInvoiceOptions"/>.</summary>
private static string BuildInvoiceOptions(InvoiceDraftSession session)
{
var tokens = new List<string>();
if (AsBool(session.Admin["p13b"])) tokens.Add("§13b");
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
return string.Join(",", tokens);
}
/// <summary>Loads the DB draft (<c>fds__getInvoice</c>) into the session's payload — the port of HandleInvoiceGet + BuildInvoiceRequestList.</summary>
private async Task LoadDraftIntoAsync(InvoiceDraftSession session, string invId, string userAccountId, DatabaseSecurity dbSec)
{
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invId) };
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("LoadDraftIntoAsync sql exception for {InvId}: {Ex}", invId, dset.Exception);
var adminDic = dset.Table("admin").FirstRow.toObjectDictionary();
var invDic = dset.Table("inv").FirstRow.toObjectDictionary();
string invoiceOptions = invDic.nz("InvoiceOptions", "");
bool p13b = invoiceOptions.Split(',').Contains("§13b");
string setmode = invoiceOptions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))?["setmode:".Length..] ?? "";
var admin = JObject.FromObject(adminDic);
admin["type"] = admin["type"] ?? JValue.CreateString(invDic.nz("InvoiceType").Substr(0, 1));
admin["p13b"] = p13b;
if (!string.IsNullOrEmpty(setmode)) admin["setmode"] = setmode;
var nw = new JObject
{
["invoicetitle"] = invDic.nz("InvoiceTitle"),
["title"] = invDic.nz("InvoiceTitle"),
["invoiceaddress"] = invDic.nz("SendToAddress"),
["invoiceemail"] = invDic.nz("SendToEmail"),
["provisionlocation"] = invDic.nz("ProvisionLocation"),
["loc"] = invDic.nz("ProvisionLocation"),
["provisionperiod"] = invDic.nz("ProvisionPeriod"),
["CustomValues"] = invDic.nz("CustomValues"),
["paymentterm"] = invDic.nz("PaymentTerm")
};
session.Admin = admin;
session.New = nw;
session.Req = BuildDraftBlocks(dset);
session.IsDraft = invDic.getItem("IsFinal", false) is not true;
}
/// <summary>Reshapes the <c>fds__getInvoice</c> req/itm tables into the editor's block/item JSON (port of BuildInvoiceRequestList).</summary>
private static JArray BuildDraftBlocks(SQLDataSet dset)
{
var blocks = new JArray();
foreach (System.Data.DataRow rq in dset.Tables("req").Select("",
dset.Tables("req").Columns.Contains("order") ? "order" : ""))
{
var rdic = rq.toObjectDictionary();
var block = new JObject
{
["Id"] = rdic["mfr__servicerequest"]?.ToString() ?? "",
["InvRqId"] = rdic["Id"]?.ToString() ?? "",
["text"] = HttpUtility.HtmlDecode(rdic["title"]?.ToString() ?? "")
};
var items = new JArray();
if (dset.Contains("itm"))
{
foreach (System.Data.DataRow sitm in dset.Tables("itm").Select(
$"[InvRqId] = '{rdic["Id"]}'",
dset.Tables("itm").Columns.Contains("order") ? "order" : ""))
{
var di = sitm.toObjectDictionary();
double net = Convert.ToDouble(di.no("value_total", 0));
double vat = Convert.ToDouble(di.no("vat", 0));
items.Add(new JObject
{
["Id"] = di["Id"]?.ToString() ?? "",
["net_val"] = net,
["vat_val"] = net * vat * 0.01,
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = Convert.ToDouble(di.no("value_service", 0)),
["svcvat_val"] = 0,
["net"] = Convert.ToDouble(di.no("value", 0)),
["quantity"] = di.nz("Quantity"),
["Type"] = di.nz("Type"),
["Note"] = di.nz("Text"),
["htmltext"] = di.nz("Text"),
["position"] = di.nz("Position"),
["SortOrder"] = di.nz("SortOrder")
});
}
}
block["items"] = items;
blocks.Add(block);
}
return blocks;
}
// ── token helpers ─────────────────────────────────────────────────────────
private static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
private static bool AsBool(JToken? t)
{
if (t == null || t.Type == JTokenType.Null) return false;
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
string s = Str(t).Trim().ToLowerInvariant();
return s is "1" or "true" or "yes" or "ja" or "on";
}
private static JObject TryParseObject(string json)
{
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
{
try { return JObject.Parse(json); } catch { /* fall through */ }
}
return new JObject();
}
}
@@ -0,0 +1,68 @@
using Fuchs.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
/// <summary>
/// Background monitor for the invoice draft cache (ADR 0006). Because a draft's
/// truth lives only in server memory until the user saves, idle sessions must not
/// vanish silently: this service warns the editing browser <b>before</b> a session's
/// idle TTL lapses ("bitte zwischenspeichern"), and when the TTL is finally reached
/// it evicts the session and tells the browser to close the editor with a reason.
/// All hints travel over the <see cref="DraftPreviewHub"/> via <see cref="IDraftNotifier"/>.
/// </summary>
public sealed class InvoiceDraftExpiryService : BackgroundService
{
private readonly IInvoiceDraftCache _cache;
private readonly IDraftNotifier _notifier;
private readonly ILogger<InvoiceDraftExpiryService> _logger;
private readonly TimeSpan _warnLead;
private readonly TimeSpan _interval;
public InvoiceDraftExpiryService(IInvoiceDraftCache cache, IDraftNotifier notifier,
IConfiguration configuration, ILogger<InvoiceDraftExpiryService> logger)
{
_cache = cache;
_notifier = notifier;
_logger = logger;
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
_interval = TimeSpan.FromSeconds(30);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepAsync(stoppingToken);
}
catch (OperationCanceledException) { /* shutting down */ }
}
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
internal async Task SweepAsync(CancellationToken cancellationToken)
{
DateTime now = DateTime.UtcNow;
foreach (var session in _cache.Snapshot())
{
TimeSpan idle = now - session.LastAccessUtc;
if (idle >= _cache.IdleTtl)
{
_cache.Remove(session.Token);
_logger.LogInformation("Draft {Token} evicted after {Idle} idle (user={User})",
session.Token, idle, session.UserAccountId);
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
}
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
{
session.ExpiryWarningSent = true;
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
}
}
}
}