Refactor code structure and remove redundant sections for improved readability and maintainability

This commit is contained in:
Stefan
2026-07-10 21:03:55 +02:00
parent 42997c4f49
commit 83d1c28b29
18 changed files with 696 additions and 86 deletions
+112 -25
View File
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -59,8 +60,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue);
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
@@ -75,71 +76,89 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = delta.ValueString,
NewValue = newValue,
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)
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
/// and new value for the change history. Scalar text fields and the section heading are
/// sanitised from the editor's HTML (TinyMCE wraps inline edits in <c>&lt;p&gt;…&lt;/p&gt;</c>)
/// to plain text here — the backend is the single source of truth (ADR 0006), so no HTML ever
/// reaches the DB, the PDF or a reloaded draft, regardless of which UI path produced it.
/// </summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
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 "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation":
oldValue = Str(s.New["provisionlocation"]);
s.New["provisionlocation"] = d.ValueString;
s.New["loc"] = d.ValueString; // editor mirrors both
newValue = HtmlToPlain(d.ValueString);
s.New["provisionlocation"] = newValue;
s.New["loc"] = newValue; // editor mirrors both
return true;
case "contact": return SetContact(s, d, ref oldValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue, ref newValue);
case "p13b":
oldValue = Str(s.Admin["p13b"]);
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null
bool p13b = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = p13b;
newValue = p13b ? "§13b" : "";
return true;
case "block.replace": return ReplaceBlock(s, d, ref oldValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue);
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
default: return false;
}
}
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
private static bool SetNewText(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
s.New[key] = d.ValueString;
newValue = HtmlToPlain(d.ValueString);
s.New[key] = newValue;
return true;
}
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.Admin[key]);
s.Admin[key] = d.ValueString;
newValue = d.ValueString;
s.Admin[key] = newValue;
return true;
}
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New["CustomValues"]);
JObject cvo = TryParseObject(oldValue);
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
JObject cvo = (JObject)prev.DeepClone();
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);
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
return true;
}
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <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)
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject nb) return false;
SanitizeBlockText(nb);
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
var existing = FindBlock(s, bid);
if (existing != null)
@@ -152,18 +171,58 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
oldValue = "";
s.Req.Add(nb);
}
newValue = Str(nb["text"]); // the section heading — never the whole block JSON
return true;
}
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block["text"]);
newValue = "";
block.Remove();
return true;
}
/// <summary>
/// Reorders the service-request blocks to the id sequence the editor posts after a section
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
/// in their current relative order at the end. Totals are unaffected; item position numbers
/// are renumbered by <see cref="Refresh"/> and pushed back to the browser via the view state.
/// </summary>
private static bool ReorderBlocks(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JArray order) return false;
var current = s.Req.OfType<JObject>().ToList();
oldValue = string.Join(",", current.Select(b => Str(b["Id"])));
var byId = current.ToDictionary(b => Str(b["Id"]), b => b);
var ordered = new List<JObject>();
var seen = new HashSet<string>();
foreach (var idTok in order)
{
string id = Str(idTok);
if (byId.TryGetValue(id, out var blk) && seen.Add(id)) ordered.Add(blk);
}
foreach (var b in current) // append blocks the order list didn't mention, in place
if (seen.Add(Str(b["Id"]))) ordered.Add(b);
newValue = string.Join(",", ordered.Select(b => Str(b["Id"])));
if (oldValue == newValue) return false; // no-op reorder
s.Req.Clear();
foreach (var b in ordered) s.Req.Add(b);
return true;
}
/// <summary>Strips the editor's HTML from a block's heading (<c>text</c>/<c>nme</c>) before it is cached.</summary>
private static void SanitizeBlockText(JObject block)
{
if (block["text"] != null) block["text"] = HtmlToPlain(Str(block["text"]));
if (block["nme"] != null) block["nme"] = HtmlToPlain(Str(block["nme"]));
}
// ── View state / history ────────────────────────────────────────────────
public object BuildState(InvoiceDraftSession session)
{
@@ -230,6 +289,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
private static void Refresh(InvoiceDraftSession session)
{
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session);
}
@@ -349,4 +409,31 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
}
return new JObject();
}
/// <summary>
/// Converts the editor's HTML (TinyMCE-wrapped inline edits, e.g. <c>&lt;p&gt;18.06.2026&lt;/p&gt;</c>)
/// to plain text: line-break-producing tags become newlines, remaining tags are stripped and
/// entities decoded. Multi-line fields (address, Leistungsort) keep their line breaks — the PDF
/// splits those on <c>\n</c>/<c>&lt;br&gt;</c> — while single-line fields collapse to one line.
/// Blank lines are removed so a stray <c>&lt;p&gt;&lt;/p&gt;</c> never becomes an empty row.
/// </summary>
internal static string HtmlToPlain(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "";
if (raw.IndexOf('<') < 0 && raw.IndexOf('&') < 0) return raw.Trim();
// Turn line-break / block-close tags into newlines before stripping the rest.
string s = Regex.Replace(raw, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
s = Regex.Replace(s, @"</\s*(p|div|li|tr|h[1-6])\s*>", "\n", RegexOptions.IgnoreCase);
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(s);
string text = System.Net.WebUtility.HtmlDecode(doc.DocumentNode.InnerText);
var lines = text.Replace("\r\n", "\n").Replace('\r', '\n')
.Split('\n')
.Select(l => l.Trim())
.Where(l => l.Length > 0);
return string.Join("\n", lines).Trim();
}
}
+56 -2
View File
@@ -63,10 +63,64 @@ public class InvoiceService : IInvoiceService
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
// Reconstruct the service-request blocks + line items so the PDF renders the positions.
// Without this the reloaded/finalized invoice showed an empty item table (only the header
// + totals came from InvoiceRegistration), i.e. it did not match the cached preview.
inv.Req = BuildPdfRequestBlocks(dset);
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft} blocks={Blocks}", inv.Id, inv.IsDraft, inv.Req?.Count ?? 0);
return inv;
}
/// <summary>
/// Rebuilds the invoice's service-request groups and line items from the persisted
/// <c>req</c>/<c>itm</c> tables (<c>fds__getInvoice</c>) into the exact block shape the PDF
/// consumes (<see cref="FdsInvoiceData.InvoiceBlocks"/> → item contract
/// <c>type/title/desc/qty/price_net/total_net</c>). Item order follows the persisted
/// <c>SortOrder</c>, so a reordered draft renders in its saved order — making the finalized
/// PDF and the re-downloaded (<c>idoc</c>) document identical to the cached preview.
/// </summary>
private static List<Dictionary<string, object>> BuildPdfRequestBlocks(SQLDataSet dset)
{
var blocks = new List<Dictionary<string, object>>();
if (!dset.Contains("req")) return blocks;
var reqTable = dset.Tables("req");
string reqSort = reqTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow rq in reqTable.Select("", reqSort))
{
var rdic = rq.toObjectDictionary();
var items = new List<Dictionary<string, object?>>();
if (dset.Contains("itm"))
{
var itmTable = dset.Tables("itm");
string itmSort = itmTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow it in itmTable.Select($"[InvRqId] = '{rdic.nz("Id")}'", itmSort))
{
var d = it.toObjectDictionary();
// The persisted "Text" holds the item's rendered HTML (the editor's co.t); it is
// the full title+description, so it maps to the contract's desc (title stays empty).
items.Add(new Dictionary<string, object?>
{
["id"] = d.nz("mfr__item"),
["type"] = d.nz("Type"),
["title"] = "",
["desc"] = d.nz("Text"),
["qty"] = d.nz("Quantity"),
["price_net"] = d.no("value", 0),
["total_net"] = d.no("value_total", 0)
});
}
}
blocks.Add(new Dictionary<string, object>
{
["Id"] = rdic.nz("mfr__servicerequest"),
["text"] = System.Net.WebUtility.HtmlDecode(rdic.nz("title")),
["items"] = items
});
}
return blocks;
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec)
{
@@ -212,7 +266,7 @@ public class InvoiceService : IInvoiceService
var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = reg?.getString("Id") ?? "",
AdminRef = (reg?.getString("InvoiceId") ?? "").ne(reg?.getString("Id") ?? ""),
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
: Array.Empty<string>(),