Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user