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