Refactor code structure for improved readability and maintainability

This commit is contained in:
Stefan
2026-07-10 14:29:51 +02:00
parent af445c015e
commit 42997c4f49
18 changed files with 1237 additions and 615 deletions
+20 -27
View File
@@ -9,32 +9,31 @@ namespace Fuchs.Services;
/// 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.
/// preview from the cache, flush to the DB ("Zwischenspeichern") and expose the
/// change history. All totals/VAT are aggregated by <see cref="InvoiceDraftCalculator"/>
/// — the browser never sums.
///
/// Reload/discard is handled by the client (re-fetch the DB draft via the existing
/// <c>inv/get</c> render path and re-seed), so there is no server-side DB reshaping here.
/// </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).
/// Seeds a new cache session from the editor's assembled payload
/// (<c>admin</c> / <c>new</c> / <c>req</c> blocks, each block carrying the editor's
/// <c>itm</c>/<c>items</c> line arrays). Computes totals + validation and returns the
/// session (with its fresh token/version). An <c>invid</c> in the payload marks it as
/// an update of an existing DB draft.
/// </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.
/// Applies one editor change to the cached session: mutates the payload, re-aggregates
/// 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);
@@ -45,8 +44,8 @@ public interface IInvoiceDraftService
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.
/// 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);
@@ -54,21 +53,15 @@ public interface IInvoiceDraftService
/// <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>
/// <summary>Removes the session from the cache (explicit close/discard/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.
/// field/operation (e.g. "email", "p13b", "block.replace"); <see cref="Ref"/> is the block
/// id it applies to (when relevant); <see cref="Value"/> is the new value (a scalar for
/// fields, or a full block object for <c>block.replace</c>).
/// </summary>
public sealed class InvoiceDraftDelta
{
+32 -184
View File
@@ -1,44 +1,37 @@
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
/// single edits, aggregates 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.
/// registration path — no new persistence. The session stores the editor's own block
/// shape (<c>itm</c>/<c>items</c> line arrays), which the PDF/persistence already consume,
/// so nothing is re-shaped server-side.
/// </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)
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)
{
@@ -58,17 +51,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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 ──────────────────────────────────────────────────────────────────
@@ -118,17 +100,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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;
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
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.replace": return ReplaceBlock(s, d, ref oldValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue);
default: return false;
}
@@ -161,31 +136,22 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return true;
}
private static bool SetItem(InvoiceDraftSession s, InvoiceDraftDelta d, string key, bool recompute, ref string oldValue)
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, 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]);
if (d.Value is not JObject nb) return false;
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
var existing = FindBlock(s, bid);
if (existing != null)
{
oldValue = Str(existing["text"]);
existing.Replace(nb);
}
else
{
oldValue = "";
s.Req.Add(nb);
}
return true;
}
@@ -229,7 +195,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
// ── Flush / preview / discard ─────────────────────────────────────────────
// ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
@@ -258,30 +224,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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 ──────────────────────────────────────────────────────────────
@@ -300,16 +242,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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>
/// <summary>
/// Builds the <see cref="FdsInvoiceData"/> from the session — the server-side equivalent
/// of <c>invcPayload</c>. The session already holds the editor's <c>req</c> block shape
/// (<c>itm</c>/<c>items</c> line arrays) that registration and the PDF consume, so the
/// blocks pass through unchanged; only the header/total normalisation is applied.
/// </summary>
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
{
var adm = (JObject)session.Admin.DeepClone();
@@ -375,8 +313,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
idx++;
if (idx > 2) break;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
}
return new GenericObjectDictionary(d);
}
@@ -391,96 +329,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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();