Add backend-authoritative invoice draft editing (ADR 0006/0007)
Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -0,0 +1,193 @@
|
||||
using System.Globalization;
|
||||
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"/>.
|
||||
///
|
||||
/// 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.
|
||||
/// </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).
|
||||
/// </summary>
|
||||
public static void RecomputeTotals(InvoiceDraftSession session)
|
||||
{
|
||||
var sums = new InvoiceDraftSums();
|
||||
bool p13b = Flag(session.Admin, "p13b");
|
||||
|
||||
foreach (var blockTok in session.Req)
|
||||
{
|
||||
if (blockTok is not JObject block) continue;
|
||||
decimal blockNet = 0;
|
||||
string blockId = Str(block["Id"]);
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
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"]);
|
||||
|
||||
sums.ServiceNet += svcNet;
|
||||
sums.ServiceVat += svcVat;
|
||||
sums.TotalNet += netVal;
|
||||
sums.TotalVat += vatVal;
|
||||
sums.TotalGross += netVal + vatVal;
|
||||
blockNet += netVal;
|
||||
|
||||
string rate = NormalizeRate(Str(item["vat"]));
|
||||
if (rate.Length > 0)
|
||||
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(blockId))
|
||||
sums.NetByBlock[blockId] = sums.NetByBlock.GetValueOrDefault(blockId) + blockNet;
|
||||
}
|
||||
|
||||
if (p13b)
|
||||
{
|
||||
// Reverse-charge: no VAT lines, gross equals net (mirrors invSumUpdate's else-branch).
|
||||
sums.TotalGross = sums.TotalNet;
|
||||
sums.TotalVat = 0;
|
||||
sums.VatByRate.Clear();
|
||||
}
|
||||
|
||||
session.Sums = sums;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks
|
||||
/// issues that should block a clean finalise; "warning" is advisory. Kept in German,
|
||||
/// user-readable, so the frontend can render them directly.
|
||||
/// </summary>
|
||||
public static void Validate(InvoiceDraftSession session)
|
||||
{
|
||||
session.ValidationMessages.Clear();
|
||||
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.");
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
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)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50") and invariant ("12.50") strings and "%".</summary>
|
||||
internal static decimal Dec(JToken? token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return 0;
|
||||
if (token.Type is JTokenType.Float or JTokenType.Integer) return token.Value<decimal>();
|
||||
return FuchsPdf.ParseDec(Str(token), out decimal d) ? d : 0;
|
||||
}
|
||||
|
||||
private static string Str(JToken? token) =>
|
||||
token == null || token.Type == JTokenType.Null ? "" : token.Value<string>() ?? "";
|
||||
|
||||
private static bool Flag(JObject obj, string key)
|
||||
{
|
||||
var t = obj[key];
|
||||
if (t == null || t.Type == JTokenType.Null) return false;
|
||||
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
|
||||
string s = Str(t).Trim().ToLowerInvariant();
|
||||
return s is "1" or "true" or "yes" or "ja" or "on";
|
||||
}
|
||||
|
||||
/// <summary>Normalises a VAT rate string ("19,0%", "7%", "19") to a canonical numeric string ("19", "7").</summary>
|
||||
internal static string NormalizeRate(string? raw)
|
||||
{
|
||||
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
|
||||
if (s.Length == 0) return "";
|
||||
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double d) || d == 0) return "";
|
||||
return d == Math.Floor(d)
|
||||
? ((long)d).ToString(CultureInfo.InvariantCulture)
|
||||
: d.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
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('@');
|
||||
if (at <= 0 || at != email.LastIndexOf('@')) return false;
|
||||
int dot = email.IndexOf('.', at);
|
||||
return dot > at + 1 && dot < email.Length - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side, in-memory editing state for a single invoice draft — the
|
||||
/// authoritative source of truth while a back-office user is editing a draft in
|
||||
/// the browser (see ADR 0006). The browser is a pure view/input layer: it posts
|
||||
/// single changes (<see cref="Fuchs.Services.InvoiceDraftDelta"/>), the server
|
||||
/// mutates this session, recomputes totals/VAT (replacing the former client-side
|
||||
/// <c>invSumUpdate</c>) and validates, then signals the browser to re-fetch.
|
||||
///
|
||||
/// This is a <b>data holder</b> only — all calculation, validation, persistence
|
||||
/// and rendering live in <see cref="Fuchs.Services.IInvoiceDraftService"/>
|
||||
/// (mirroring the <see cref="FdsInvoiceData"/> / <see cref="Fuchs.Services.IInvoiceService"/>
|
||||
/// split). The editable payload is kept as the exact JSON shape the editor already
|
||||
/// speaks (<c>admin</c> / <c>new</c> / <c>req</c>), so flushing to the DB can reuse
|
||||
/// <see cref="Fuchs.Services.IInvoiceService.RegisterInvoiceAsync"/> unchanged.
|
||||
/// </summary>
|
||||
public sealed class InvoiceDraftSession
|
||||
{
|
||||
/// <summary>Opaque per-editor token; also the SignalR group name for targeted signals.</summary>
|
||||
public string Token { get; init; } = "";
|
||||
|
||||
/// <summary>Owning user account id (drafts are single-user; used for auth + events).</summary>
|
||||
public string UserAccountId { get; init; } = "";
|
||||
|
||||
/// <summary>DB invoice id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
|
||||
public string InvId { get; set; } = "";
|
||||
|
||||
/// <summary>Always true here — sessions only ever hold unfinalised drafts.</summary>
|
||||
public bool IsDraft { get; set; } = true;
|
||||
|
||||
/// <summary>Bumped on every applied mutation; the browser refetches when the signalled version changes.</summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>UTC of the last read/write; drives the idle sliding-TTL and expiry warnings.</summary>
|
||||
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Guards against sending more than one expiry warning per idle window.</summary>
|
||||
public bool ExpiryWarningSent { get; set; }
|
||||
|
||||
// ── Editable payload (exact editor JSON shape) ───────────────────────────
|
||||
/// <summary>Header/admin flags: type, customerid, p13b, setmode, paymentterms…</summary>
|
||||
public JObject Admin { get; set; } = new();
|
||||
|
||||
/// <summary>Recipient/new fields: title/invoicetitle, invoiceaddress, invoiceemail, provisionlocation/-period, CustomValues…</summary>
|
||||
public JObject New { get; set; } = new();
|
||||
|
||||
/// <summary>Service-request blocks; each block is a JObject with an <c>items</c> JArray (the line items).</summary>
|
||||
public JArray Req { get; set; } = new();
|
||||
|
||||
// ── Computed (by the draft service; never trusted from the client) ───────
|
||||
/// <summary>Server-computed totals/VAT — the values the client used to compute in <c>invSumUpdate</c>.</summary>
|
||||
public InvoiceDraftSums Sums { get; set; } = new();
|
||||
|
||||
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
|
||||
public List<InvoiceDraftValidationMessage> ValidationMessages { get; } = new();
|
||||
|
||||
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||
public List<ChangeHistoryEntry> History { get; } = new();
|
||||
|
||||
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Server-computed invoice totals — the authoritative replacement for the browser's <c>sms</c> object.</summary>
|
||||
public sealed class InvoiceDraftSums
|
||||
{
|
||||
/// <summary>Total net (<c>ttn</c>).</summary>
|
||||
public decimal TotalNet { get; set; }
|
||||
/// <summary>Total gross (<c>ttb</c>); equals net when §13b reverse-charge is active.</summary>
|
||||
public decimal TotalGross { get; set; }
|
||||
/// <summary>Total VAT (<c>ttvat</c>).</summary>
|
||||
public decimal TotalVat { get; set; }
|
||||
/// <summary>Service net (<c>tscn</c>) — the service-refund base.</summary>
|
||||
public decimal ServiceNet { get; set; }
|
||||
/// <summary>Service VAT (<c>tscvat</c>).</summary>
|
||||
public decimal ServiceVat { get; set; }
|
||||
/// <summary>VAT amount per rate string (e.g. "19" → 123.45), matching the editor's <c>sms.vat</c> map.</summary>
|
||||
public Dictionary<string, decimal> VatByRate { get; } = new();
|
||||
/// <summary>Net per block, keyed by block id — feeds the per-block sub-sum row.</summary>
|
||||
public Dictionary<string, decimal> NetByBlock { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A single plausibility/consistency finding for the draft.</summary>
|
||||
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "items").</param>
|
||||
/// <param name="Severity">"error" blocks a clean finalise; "warning"/"info" are advisory.</param>
|
||||
/// <param name="Message">German, user-readable text.</param>
|
||||
public readonly record struct InvoiceDraftValidationMessage(string Field, string Severity, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// One automatically-recorded change in the draft's history (shown in the
|
||||
/// "Änderungshistorie" dialog). Captured on every applied patch; lives only for
|
||||
/// the cache lifetime of the session and is never persisted to the database.
|
||||
/// </summary>
|
||||
public sealed class ChangeHistoryEntry
|
||||
{
|
||||
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
||||
/// <summary>User account id that made the change.</summary>
|
||||
public string UserAccountId { get; init; } = "";
|
||||
/// <summary>The change target/op as sent by the editor (e.g. "item.qty", "email", "p13b").</summary>
|
||||
public string Target { get; init; } = "";
|
||||
/// <summary>Optional item/block id the change applied to.</summary>
|
||||
public string Ref { get; init; } = "";
|
||||
/// <summary>Previous value, stringified for display (may be empty).</summary>
|
||||
public string OldValue { get; init; } = "";
|
||||
/// <summary>New value, stringified for display (may be empty).</summary>
|
||||
public string NewValue { get; init; } = "";
|
||||
/// <summary>Version the session reached after applying this change.</summary>
|
||||
public int Version { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user