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,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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user