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
+21 -62
View File
@@ -4,49 +4,22 @@ using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, pure port of the invoice totals/VAT math that used to live in the
/// browser (<c>quantChange</c> + <c>invSumUpdate</c> in <c>fis.inv_shared.js</c>).
/// This is the authoritative calculation for a live draft (ADR 0006): given the
/// editable payload of an <see cref="InvoiceDraftSession"/>, it (re)computes each
/// item's line values, aggregates block/rate totals into
/// <see cref="InvoiceDraftSession.Sums"/>, and runs the plausibility/consistency
/// checks into <see cref="InvoiceDraftSession.ValidationMessages"/>.
/// Server-side, pure aggregation of an invoice draft's totals/VAT — the authoritative
/// replacement for the browser's <c>invSumUpdate</c> footer math (ADR 0006). The user's
/// requirement is that the <b>sums</b> live in the backend cache, not the frontend.
///
/// Kept static and free of I/O so it is exhaustively unit-testable — the payoff the
/// old <c>EVAL_live_invoice_editing.md</c> predicted once the truth moved server-side.
/// It reads each block's persisted line contract (<c>block.itm</c> = the editor's <c>co</c>
/// objects: <c>vt</c>=net, <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT,
/// <c>vat</c>=rate) — exactly the shape the editor already posts and the PDF/persistence
/// already consume — so no line data is re-derived or re-shaped. It then applies the §13b
/// reverse-charge rule and validates. Static/pure, hence exhaustively unit-testable.
/// </summary>
public static class InvoiceDraftCalculator
{
/// <summary>
/// Re-derives a single item's line values from quantity × net price × VAT rate —
/// the port of the editor's <c>quantChange</c>. Only applied when an item's
/// quantity/price actually changes (osum/set/text lines keep their stored values,
/// exactly as the client only ran <c>quantChange</c> on edited quantity rows).
/// Mirrors the guard <c>qty &gt; 0 &amp;&amp; price &gt; 0</c>.
/// </summary>
public static void RecomputeItem(JObject item)
{
int qty = (int)Dec(item["quantityhours"]);
decimal net = Dec(item["net"]);
decimal vat = RatePercent(Str(item["vat"])) * 0.01m; // "19%"/"19,0%" → 0.19
if (qty > 0 && net > 0)
{
decimal netVal = decimal.Round(qty * net, 2, MidpointRounding.AwayFromZero);
decimal vatVal = decimal.Round(qty * net * vat, 2, MidpointRounding.AwayFromZero);
item["net_val"] = netVal;
item["vat_val"] = vatVal;
if (string.Equals(Str(item["Type"]), "service", StringComparison.OrdinalIgnoreCase))
{
item["svcnet_val"] = netVal;
item["svcvat_val"] = vatVal;
}
}
}
/// <summary>
/// Aggregates all line items into the draft's totals — the port of <c>invSumUpdate</c>'s
/// <c>csms</c> accumulation plus the §13b reverse-charge rule (VAT suppressed → gross = net).
/// VAT is grouped by the item's rate string (matching the editor's <c>sms.vat</c> map).
/// Aggregates every block's line values into the draft's totals — the port of
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
/// VAT is grouped by the line's rate string (matching the editor's <c>sms.vat</c> map).
/// </summary>
public static void RecomputeTotals(InvoiceDraftSession session)
{
@@ -58,15 +31,15 @@ public static class InvoiceDraftCalculator
if (blockTok is not JObject block) continue;
decimal blockNet = 0;
string blockId = Str(block["Id"]);
if (block["items"] is JArray items)
if (block["itm"] is JArray lines)
{
foreach (var itemTok in items)
foreach (var lineTok in lines)
{
if (itemTok is not JObject item) continue;
decimal netVal = Dec(item["net_val"]);
decimal vatVal = Dec(item["vat_val"]);
decimal svcNet = Dec(item["svcnet_val"]);
decimal svcVat = Dec(item["svcvat_val"]);
if (lineTok is not JObject co) continue;
decimal netVal = Dec(co["vt"]);
decimal vatVal = Dec(co["vv"]);
decimal svcNet = Dec(co["vs"]);
decimal svcVat = Dec(co["vsv"]);
sums.ServiceNet += svcNet;
sums.ServiceVat += svcVat;
@@ -75,7 +48,7 @@ public static class InvoiceDraftCalculator
sums.TotalGross += netVal + vatVal;
blockNet += netVal;
string rate = NormalizeRate(Str(item["vat"]));
string rate = NormalizeRate(Str(co["vat"]));
if (rate.Length > 0)
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
}
@@ -106,30 +79,23 @@ public static class InvoiceDraftCalculator
void Add(string field, string sev, string msg) =>
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
// Recipient email
string email = Str(session.New["invoiceemail"]).Trim();
if (email.Length == 0)
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
// Recipient address
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
// At least one priced line
if (!HasAnyItem(session))
Add("items", "error", "Die Rechnung enthält keine Positionen.");
// VAT rate sanity (only when not reverse-charge)
if (!Flag(session.Admin, "p13b"))
{
foreach (var rate in session.Sums.VatByRate.Keys)
if (!IsKnownVatRate(rate))
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
}
// Negative total
if (session.Sums.TotalGross < 0)
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
}
@@ -138,7 +104,7 @@ public static class InvoiceDraftCalculator
private static bool HasAnyItem(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
if (blockTok is JObject block && block["items"] is JArray items && items.Count > 0)
if (blockTok is JObject block && block["itm"] is JArray lines && lines.Count > 0)
return true;
return false;
}
@@ -152,7 +118,7 @@ public static class InvoiceDraftCalculator
}
private static string Str(JToken? token) =>
token == null || token.Type == JTokenType.Null ? "" : token.Value<string>() ?? "";
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
private static bool Flag(JObject obj, string key)
{
@@ -176,13 +142,6 @@ public static class InvoiceDraftCalculator
private static bool IsKnownVatRate(string rate) => rate is "0" or "7" or "19";
/// <summary>Parses a VAT rate string ("19%", "19,0%", "7") to its numeric percent (German/invariant tolerant).</summary>
internal static decimal RatePercent(string? raw)
{
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
}
private static bool IsValidEmail(string email)
{
int at = email.IndexOf('@');