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,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
|
||||||
|
/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftCacheTests
|
||||||
|
{
|
||||||
|
private static IConfiguration Config(int idle = 30, int warn = 5) =>
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
|
||||||
|
["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
private sealed class FakeNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
public readonly List<(string token, int version)> Ready = new();
|
||||||
|
public readonly List<(string token, int secondsLeft)> Expiring = new();
|
||||||
|
public readonly List<(string token, string reason)> Closed = new();
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache storage ─────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void SetGet_RoundTrips_AndUnknownTokenIsNull()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "abc" };
|
||||||
|
cache.Set(s);
|
||||||
|
Assert.Same(s, cache.Get("abc"));
|
||||||
|
Assert.Null(cache.Get("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Remove_EvictsSession()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "x" });
|
||||||
|
Assert.NotNull(cache.Remove("x"));
|
||||||
|
Assert.Null(cache.Get("x"));
|
||||||
|
Assert.Null(cache.Remove("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
|
||||||
|
cache.Set(s);
|
||||||
|
cache.Get("x");
|
||||||
|
Assert.False(s.ExpiryWarningSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expiry monitor ────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
|
||||||
|
var s = new InvoiceDraftSession { Token = "a" };
|
||||||
|
cache.Set(s);
|
||||||
|
|
||||||
|
// Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
Assert.True(s.ExpiryWarningSent);
|
||||||
|
|
||||||
|
// Another sweep while still idle must not spam a second warning.
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
|
||||||
|
// Past the TTL → evicted and the editor is told to close with a reason.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Closed);
|
||||||
|
Assert.Equal(("a", "expired"), notifier.Closed[0]);
|
||||||
|
Assert.Null(cache.Get("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_FreshSession_DoesNothing()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "fresh" });
|
||||||
|
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the server-side port of the former client-side invoice math
|
||||||
|
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
|
||||||
|
/// backend (ADR 0006), this logic is finally unit-testable directly.
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftCalculatorTests
|
||||||
|
{
|
||||||
|
private static InvoiceDraftSession SessionWith(string reqJson, bool p13b = false)
|
||||||
|
{
|
||||||
|
var s = new InvoiceDraftSession { Token = "t" };
|
||||||
|
s.Admin = new JObject { ["p13b"] = p13b };
|
||||||
|
s.New = new JObject { ["invoiceemail"] = "kunde@example.de", ["invoiceaddress"] = "Weg 1" };
|
||||||
|
s.Req = JArray.Parse(reqJson);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecomputeTotals ──────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','items':[
|
||||||
|
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
|
||||||
|
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
|
||||||
|
{ 'Id':'11','items':[
|
||||||
|
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
|
||||||
|
Assert.Equal(350m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(42.5m, s.Sums.TotalVat);
|
||||||
|
Assert.Equal(392.5m, s.Sums.TotalGross);
|
||||||
|
Assert.Equal(50m, s.Sums.ServiceNet);
|
||||||
|
Assert.Equal(9.5m, s.Sums.ServiceVat);
|
||||||
|
Assert.Equal(28.5m, s.Sums.VatByRate["19"]);
|
||||||
|
Assert.Equal(14m, s.Sums.VatByRate["7"]);
|
||||||
|
Assert.Equal(150m, s.Sums.NetByBlock["10"]);
|
||||||
|
Assert.Equal(200m, s.Sums.NetByBlock["11"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','items':[
|
||||||
|
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
|
||||||
|
Assert.Equal(100m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(100m, s.Sums.TotalGross);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalVat);
|
||||||
|
Assert.Empty(s.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_EmptyDraft_AllZero()
|
||||||
|
{
|
||||||
|
var s = SessionWith("[]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalGross);
|
||||||
|
Assert.Empty(s.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecomputeItem (quantChange port) ─────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Service", true)]
|
||||||
|
[InlineData("material", false)]
|
||||||
|
public void RecomputeItem_DerivesLineValuesFromQtyPriceVat(string type, bool isService)
|
||||||
|
{
|
||||||
|
var item = new JObject
|
||||||
|
{
|
||||||
|
["quantityhours"] = 5, ["net"] = "10", ["vat"] = "19", ["Type"] = type
|
||||||
|
};
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeItem(item);
|
||||||
|
|
||||||
|
Assert.Equal(50m, item["net_val"]!.Value<decimal>());
|
||||||
|
Assert.Equal(9.5m, item["vat_val"]!.Value<decimal>());
|
||||||
|
if (isService)
|
||||||
|
{
|
||||||
|
Assert.Equal(50m, item["svcnet_val"]!.Value<decimal>());
|
||||||
|
Assert.Equal(9.5m, item["svcvat_val"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Null(item["svcnet_val"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeItem_ZeroQuantity_LeavesValuesUntouched()
|
||||||
|
{
|
||||||
|
var item = new JObject { ["quantityhours"] = 0, ["net"] = "10", ["vat"] = "19", ["Type"] = "material" };
|
||||||
|
InvoiceDraftCalculator.RecomputeItem(item);
|
||||||
|
Assert.Null(item["net_val"]); // guard qty>0 && price>0 not met → no derivation
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NormalizeRate ────────────────────────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("19,0%", "19")]
|
||||||
|
[InlineData("7%", "7")]
|
||||||
|
[InlineData("19", "19")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData("0", "")]
|
||||||
|
[InlineData("7,5", "7.5")]
|
||||||
|
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
|
||||||
|
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
|
||||||
|
|
||||||
|
// ── Validate ─────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ValidDraft_NoErrors()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','items':[
|
||||||
|
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "warning")] // missing email → advisory
|
||||||
|
[InlineData("not-an-email", "error")]
|
||||||
|
public void Validate_EmailProblems_AreFlagged(string email, string severity)
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat':'19%','Type':'material'}] }]");
|
||||||
|
s.New["invoiceemail"] = email;
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_NoItems_IsError()
|
||||||
|
{
|
||||||
|
var s = SessionWith("[]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_UnknownVatRate_IsWarning()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat_val':0.5,'vat':'5%','Type':'material'}] }]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||||
|
/// without any database, proving the backend-authoritative model behaves correctly
|
||||||
|
/// end-to-end at the service seam (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Captures the invoice handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||||
|
private sealed class FakeInvoiceService : IInvoiceService
|
||||||
|
{
|
||||||
|
public FdsInvoiceData? Registered;
|
||||||
|
public bool? LastChange;
|
||||||
|
public Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Registered = invoice;
|
||||||
|
LastChange = change;
|
||||||
|
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
||||||
|
return Task.FromResult(invoice);
|
||||||
|
}
|
||||||
|
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||||
|
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||||
|
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||||
|
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||||
|
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
|
||||||
|
{
|
||||||
|
var cfg = new ConfigurationBuilder().Build();
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var inv = new FakeInvoiceService();
|
||||||
|
var svc = new InvoiceDraftEditService(cache, inv, intranet: null!, NullLogger<InvoiceDraftEditService>.Instance);
|
||||||
|
return (svc, inv, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject Payload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','items':[
|
||||||
|
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenFromPayload_SeedsSessionAndComputesTotals()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||||
|
Assert.Equal(0, s.Version);
|
||||||
|
Assert.Equal(100m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(119m, s.Sums.TotalGross);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("email", h.Target);
|
||||||
|
Assert.Equal("a@b.de", h.OldValue);
|
||||||
|
Assert.Equal("neu@x.de", h.NewValue);
|
||||||
|
Assert.Equal(1, h.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.qty", Ref = "900", Value = JToken.FromObject(5) });
|
||||||
|
|
||||||
|
// qty 5 × price 10 = 50 net, 19% → 9.5 VAT.
|
||||||
|
Assert.Equal(50m, s2!.Sums.TotalNet);
|
||||||
|
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b" }); // no value → toggle
|
||||||
|
|
||||||
|
Assert.Equal(100m, s2!.Sums.TotalGross); // reverse-charge → gross == net
|
||||||
|
Assert.Empty(s2.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
Assert.Null(svc.ApplyPatch("ghost", new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_RegistersWithMappedTotals_AndSetsInvId()
|
||||||
|
{
|
||||||
|
var (svc, inv, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("INV42", result!.Id);
|
||||||
|
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||||
|
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
||||||
|
|
||||||
|
// The FdsInvoiceData handed to registration carries the session's server-computed totals.
|
||||||
|
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
|
||||||
|
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
|
||||||
|
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
var vatRate = prms.First(p => p.ParameterName == "@InvoiceVAT_1");
|
||||||
|
Assert.Equal("19", vatRate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardAsync_NeverSaved_ReturnsSessionUnchanged()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
var back = await svc.DiscardAsync(s.Token, "user1", null!);
|
||||||
|
Assert.Same(s, back); // no InvId → nothing to reload from the DB
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHistory_UnknownToken_IsEmpty()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
Assert.Empty(svc.GetHistory("ghost"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
// Partial class: live, backend-authoritative invoice draft editing (ADR 0006).
|
||||||
|
// The browser posts single edits here; the server mutates the in-memory session
|
||||||
|
// (the source of truth), recomputes/validates, and pings the editing browser over
|
||||||
|
// SignalR (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries
|
||||||
|
// only signals (ADR 0007).
|
||||||
|
public partial class IntranetController
|
||||||
|
{
|
||||||
|
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||||
|
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||||
|
|
||||||
|
// POST inv/dopen — { id? | payload? } → { token, version }
|
||||||
|
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
InvoiceDraftSession session;
|
||||||
|
if (HasForm("id") && !string.IsNullOrEmpty(Form("id")))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Draft dopen: from DB draft {InvId} user={User}", Form("id"), UserAccountID);
|
||||||
|
session = await _invoiceDrafts.OpenFromDraftAsync(Form("id"), UserAccountID, DbSec);
|
||||||
|
}
|
||||||
|
else if (HasForm("payload"))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Draft dopen: from payload user={User}", UserAccountID);
|
||||||
|
JObject payload;
|
||||||
|
try { payload = JObject.Parse(Form("payload")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Draft dopen: neither 'id' nor 'payload' supplied user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
// The browser holds the token from this response and fetches dstate directly; there is
|
||||||
|
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
|
||||||
|
// only subsequent server-side changes.
|
||||||
|
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dstate — { token } → full view state
|
||||||
|
private async Task<IActionResult> HandleDraftState(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var session = _invoiceDrafts.Get(Form("token"));
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
return await JSONAsync(_invoiceDrafts.BuildState(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||||
|
private async Task<IActionResult> HandleDraftPatch(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token", "delta")) return BadRequest400();
|
||||||
|
InvoiceDraftDelta? delta;
|
||||||
|
try { delta = JsonConvert.DeserializeObject<InvoiceDraftDelta>(Form("delta")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||||
|
|
||||||
|
var session = _invoiceDrafts.ApplyPatch(Form("token"), delta);
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||||
|
return await JSONAsync(new { ok = true, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||||
|
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var doc = _invoiceDrafts.RenderPreview(Form("token"));
|
||||||
|
if (doc == null) return DraftGone();
|
||||||
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||||
|
private async Task<IActionResult> HandleDraftSave(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
string token = Form("token");
|
||||||
|
var before = _invoiceDrafts.Get(token);
|
||||||
|
if (before == null) return DraftGone();
|
||||||
|
bool existed = !string.IsNullOrEmpty(before.InvId);
|
||||||
|
|
||||||
|
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (fdInv == null) return DraftGone();
|
||||||
|
if (string.IsNullOrEmpty(fdInv.Id))
|
||||||
|
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
|
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
||||||
|
var after = _invoiceDrafts.Get(token);
|
||||||
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
|
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dhistory — { token } → { history[] }
|
||||||
|
private async Task<IActionResult> HandleDraftHistory(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
if (_invoiceDrafts.Get(Form("token")) == null) return DraftGone();
|
||||||
|
var history = _invoiceDrafts.GetHistory(Form("token"))
|
||||||
|
.Select(h => new
|
||||||
|
{
|
||||||
|
timestamp = h.TimestampUtc,
|
||||||
|
target = h.Target,
|
||||||
|
@ref = h.Ref,
|
||||||
|
oldValue = h.OldValue,
|
||||||
|
newValue = h.NewValue,
|
||||||
|
version = h.Version
|
||||||
|
});
|
||||||
|
return await JSONAsync(new { history });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/ddiscard — { token } → { ok, version }; reload from DB + draftReady
|
||||||
|
private async Task<IActionResult> HandleDraftDiscard(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var session = await _invoiceDrafts.DiscardAsync(Form("token"), UserAccountID, DbSec);
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||||
|
return await JSONAsync(new { ok = true, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dclose — { token } → { ok }
|
||||||
|
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
bool ok = _invoiceDrafts.Close(Form("token"));
|
||||||
|
_logger.LogDebug("Draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,16 @@ public partial class IntranetController
|
|||||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||||
return await JSONAsync(new { ok = true });
|
return await JSONAsync(new { ok = true });
|
||||||
|
|
||||||
|
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||||
|
case "dopen": return await HandleDraftOpen(fn, id, code);
|
||||||
|
case "dstate": return await HandleDraftState(fn, id, code);
|
||||||
|
case "dpatch": return await HandleDraftPatch(fn, id, code);
|
||||||
|
case "dpreview": return await HandleDraftPreview(fn, id, code);
|
||||||
|
case "dsave": return await HandleDraftSave(fn, id, code);
|
||||||
|
case "dhistory": return await HandleDraftHistory(fn, id, code);
|
||||||
|
case "ddiscard": return await HandleDraftDiscard(fn, id, code);
|
||||||
|
case "dclose": return await HandleDraftClose(fn, id, code);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||||
return await JSONAsync(new { ok = true });
|
return await JSONAsync(new { ok = true });
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
private readonly IInvoiceService _invoices;
|
private readonly IInvoiceService _invoices;
|
||||||
private readonly IReminderService _reminders;
|
private readonly IReminderService _reminders;
|
||||||
private readonly IEventService _events;
|
private readonly IEventService _events;
|
||||||
|
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||||
|
private readonly IDraftNotifier _draftNotifier;
|
||||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||||
private readonly List<string> _allowedGet = new()
|
private readonly List<string> _allowedGet = new()
|
||||||
{
|
{
|
||||||
@@ -62,7 +64,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
IReportService reports,
|
IReportService reports,
|
||||||
IInvoiceService invoices,
|
IInvoiceService invoices,
|
||||||
IReminderService reminders,
|
IReminderService reminders,
|
||||||
IEventService events)
|
IEventService events,
|
||||||
|
IInvoiceDraftService invoiceDrafts,
|
||||||
|
IDraftNotifier draftNotifier)
|
||||||
{
|
{
|
||||||
_intranet = intranet;
|
_intranet = intranet;
|
||||||
_mfr = mfr;
|
_mfr = mfr;
|
||||||
@@ -76,6 +80,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_reminders = reminders;
|
_reminders = reminders;
|
||||||
_events = events;
|
_events = events;
|
||||||
|
_invoiceDrafts = invoiceDrafts;
|
||||||
|
_draftNotifier = draftNotifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
status: Active
|
||||||
|
lastUpdated: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/*DraftNotifier*"
|
||||||
|
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
relatedDecisions:
|
||||||
|
- "0006-backend-authoritative-draft-editing.md"
|
||||||
|
- "0007-targeted-draft-signalr-groups.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Live draft editing (backend-authoritative invoice previews)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
While a back-office user edits an invoice draft, the authoritative state is held in
|
||||||
|
server memory, not in the browser. The browser posts single edits, the server mutates
|
||||||
|
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
||||||
|
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
||||||
|
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
||||||
|
explicit discard), reversing the earlier stateless editor. Invoices are the pilot;
|
||||||
|
reminders are intended to mirror the same design.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Open: Browser --POST inv/dopen {id | payload}--> server builds InvoiceDraftSession, caches it
|
||||||
|
Browser --SignalR JoinDraft(token)--> joins the draft's group; spinner while loading
|
||||||
|
Browser --POST inv/dstate {token}--> renders admin/new/req + server sums + validation
|
||||||
|
|
||||||
|
Edit: Browser --POST inv/dpatch {token, delta}--> mutate + recompute + validate + version++
|
||||||
|
Server --SignalR draftReady{token,version}--> Browser re-fetches inv/dstate, re-renders
|
||||||
|
|
||||||
|
Preview: Browser --POST inv/dpreview {token}--> PDF rendered straight from the cache (no upload)
|
||||||
|
Save: Browser --POST inv/dsave {token}--> flush cache->DB (RegisterInvoiceAsync) + EventService toast
|
||||||
|
History: Browser --POST inv/dhistory {token}--> change list -> "Änderungshistorie" dialog
|
||||||
|
Discard: Browser --POST inv/ddiscard {token}--> reload session from DB draft -> draftReady
|
||||||
|
Close: Browser --POST inv/dclose {token}--> session removed (+ LeaveDraft)
|
||||||
|
|
||||||
|
Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bitte zwischenspeichern"
|
||||||
|
Server (evict) --SignalR draftClosed{token,reason}--> close the editor with a reason
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Session** (`InvoiceDraftSession`) is a pure data holder: the editable payload as the
|
||||||
|
exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed
|
||||||
|
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||||
|
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||||
|
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
|
||||||
|
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), and `Validate`
|
||||||
|
(email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively
|
||||||
|
unit-tested.
|
||||||
|
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
|
||||||
|
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
|
||||||
|
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||||
|
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
||||||
|
path), renders previews from a synthesised registration, and discards by reloading.
|
||||||
|
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||||
|
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||||
|
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||||
|
- **Signals** (`DraftPreviewHub` at `/draftpreview` + `IDraftNotifier`) are targeted at the
|
||||||
|
editing browser via a group named after the session token: `draftReady`, `draftExpiring`,
|
||||||
|
`draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`.
|
||||||
|
- **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins,
|
||||||
|
posts one delta per change, shows a loading state whenever awaiting a signal, and offers
|
||||||
|
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It no longer computes totals.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||||
|
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
|
||||||
|
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
|
||||||
|
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
||||||
|
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
||||||
|
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
||||||
|
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
||||||
|
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
|
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/*DraftNotifier*"
|
||||||
|
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0006 — Invoice draft editing is backend-authoritative over an in-memory cache
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The invoice editor was deliberately **stateless**: the browser held the working
|
||||||
|
model, computed totals/VAT client-side (`invSumUpdate` in `fis.inv_shared.js`) and
|
||||||
|
re-posted the whole `invc` JSON on every preview/save. `EVAL_live_invoice_editing.md`
|
||||||
|
(2026) recommended keeping it that way and **against** a server-cached, SignalR-driven
|
||||||
|
model, because the real-time/co-editing benefits were weak for a single back-office
|
||||||
|
editor.
|
||||||
|
|
||||||
|
The product owner has since decided the trade-off differently and prioritised a
|
||||||
|
**single source of truth in the backend** with server-computed sums, server-side
|
||||||
|
plausibility/consistency checks, in-place PDF preview without re-upload, an automatic
|
||||||
|
change history, and an explicit discard. This decision records that reversal and the
|
||||||
|
architecture chosen to implement it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
While a user edits an invoice draft, the authoritative state lives **server-side** in
|
||||||
|
an in-memory `InvoiceDraftSession` (`Fuchs/code/InvoiceDraftSession.cs`), held by the
|
||||||
|
singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftService`
|
||||||
|
(`InvoiceDraftEditService`). The browser is a pure view/input layer.
|
||||||
|
|
||||||
|
- **Truth & calculation on the server.** `InvoiceDraftCalculator` is the pure,
|
||||||
|
unit-tested port of the former client-side math (`quantChange` + `invSumUpdate`),
|
||||||
|
including the §13b reverse-charge rule and VAT-per-rate grouping. The browser never
|
||||||
|
computes totals; it renders the server's `sums`.
|
||||||
|
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
|
||||||
|
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
|
||||||
|
The server mutates the session, recomputes, validates, bumps a version, and pings the
|
||||||
|
editing browser (`draftReady`) to re-fetch `inv/dstate`. See
|
||||||
|
[0007](0007-targeted-draft-signalr-groups.md) for the targeted-signal transport.
|
||||||
|
- **Cache-only until Zwischenspeichern/Finalise.** Opening builds the session (from a
|
||||||
|
brand-new payload or by reloading a DB draft); edits touch only the cache. `dsave`
|
||||||
|
flushes the session to the DB by reusing the existing
|
||||||
|
`IInvoiceService.RegisterInvoiceAsync` — **no new persistence path** — and reports
|
||||||
|
success/failure through the existing `IEventService` (ADR 0001). Finalise continues
|
||||||
|
through `req/sconf`.
|
||||||
|
- **Preview from cache.** `inv/dpreview` renders the draft PDF straight from the session
|
||||||
|
(synthesised registration), with no client upload.
|
||||||
|
- **Automatic change history.** Every applied patch appends a `ChangeHistoryEntry`
|
||||||
|
(cache-only, never persisted); `inv/dhistory` exposes it for the "Änderungshistorie"
|
||||||
|
dialog.
|
||||||
|
- **Idle lifecycle with user warning.** `InvoiceDraftExpiryService` warns the editing
|
||||||
|
browser before a session's idle TTL lapses (`draftExpiring`) and, on eviction, tells
|
||||||
|
it to close the editor with a reason (`draftClosed`). TTL and warning lead are under
|
||||||
|
`Fuchs:DraftEditing`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The server is now **stateful for in-progress drafts**. This is acceptable for a
|
||||||
|
single-instance deployment; **scale-out requires sticky sessions or a distributed
|
||||||
|
cache/SignalR backplane** — none exist today, so this is a documented limitation, not
|
||||||
|
a silent assumption.
|
||||||
|
- New editor interactions must be modelled as a **delta** applied server-side (add a
|
||||||
|
case in `InvoiceDraftEditService.ApplyDelta` + calculator handling), never as a new
|
||||||
|
client-side calculation. Do not reintroduce client-side totals.
|
||||||
|
- `FdsInvoiceData` stays a pure data holder; `InvoiceDraftSession` is likewise a data
|
||||||
|
holder, with all logic in the service/calculator (mirrors the existing service split).
|
||||||
|
- Reminders (Mahnungen) are intended to follow the identical pattern as a second phase;
|
||||||
|
this decision covers invoices first (the pilot) and applies to the reminder mirror
|
||||||
|
when built.
|
||||||
|
- `EVAL_live_invoice_editing.md` and `INVOICE_LIFECYCLE.md` §4/§10 (the "stateless
|
||||||
|
editor" invariant) are superseded by this decision for the draft-editing flow and have
|
||||||
|
been annotated accordingly.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep the stateless editor** (the prior recommendation): rejected by the product
|
||||||
|
owner in favour of a backend single source of truth.
|
||||||
|
- **Full bidirectional SignalR hub for commands too**: rejected — edits as POSTs reuse
|
||||||
|
the existing controller/auth pattern and avoid a command reconnect/replay protocol; the
|
||||||
|
hub carries only coordination signals.
|
||||||
|
- **Write-through to the DB on every edit**: rejected — conflicts with the
|
||||||
|
"Zwischenspeichern = persist the cache" semantics and adds DB load; the cache is the
|
||||||
|
truth until an explicit save/finalise.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/IDraftNotifier.cs"
|
||||||
|
- "Fuchs/Notifications/DraftNotifier.cs"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0007 — Draft-editing signals are targeted via a dedicated hub with per-draft groups
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Backend-authoritative draft editing (ADR 0006) needs to notify **exactly the one
|
||||||
|
browser** editing a given draft that its cached state changed, is about to expire, or
|
||||||
|
was closed. The existing `NotificationHub` (ADR 0002) deliberately **broadcasts** every
|
||||||
|
business toast to all logged-in sessions and explicitly deferred per-user/targeted
|
||||||
|
delivery as "a new decision". Draft coordination pings are high-frequency, per-editor,
|
||||||
|
and must not spray to every session.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Draft signals use a **dedicated** SignalR hub, `DraftPreviewHub`, mapped at
|
||||||
|
`/draftpreview` (separate from `NotificationHub` at `/notifications`). Targeting is by
|
||||||
|
**SignalR group named after the draft's session token**:
|
||||||
|
|
||||||
|
- The client calls the hub methods `JoinDraft(token)` / `LeaveDraft(token)` to
|
||||||
|
subscribe/unsubscribe its connection to a draft's group. The hub carries **no
|
||||||
|
commands** — only group membership (edits are POSTs; see ADR 0006).
|
||||||
|
- The server sends via `IDraftNotifier` (`DraftNotifier`) to `Clients.Group(token)`:
|
||||||
|
`draftReady{token,version}` (re-fetch), `draftExpiring{token,secondsLeft}` (idle
|
||||||
|
warning), `draftClosed{token,reason}` (session evicted/discarded → close the editor).
|
||||||
|
- Like `EventService`, delivery failures are logged and swallowed — a missed
|
||||||
|
coordination ping must never fail the underlying operation; the client also re-syncs on
|
||||||
|
reconnect and on its next POST.
|
||||||
|
|
||||||
|
Business success/failure messages for draft operations (e.g. "Zwischenstand
|
||||||
|
gespeichert") continue to flow through `IEventService`/`NotificationHub`, **not** this
|
||||||
|
hub — the two channels stay separate.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The session **token doubles as the group name**; it is an opaque GUID and must not
|
||||||
|
encode sensitive data. Any browser that knows a token can join its group, so tokens
|
||||||
|
must be treated as capabilities and only handed to the authenticated editor that opened
|
||||||
|
the draft.
|
||||||
|
- Adding a new draft signal means adding a method to `IDraftNotifier` + `DraftNotifier`
|
||||||
|
and a client handler in `$fis.draft` — not overloading the business notification path.
|
||||||
|
- ADR 0002 is unchanged: `NotificationHub` stays broadcast-only for toasts. This hub is
|
||||||
|
the answer to its "if per-user targeting becomes necessary, that is a new decision".
|
||||||
|
- Multi-instance scale-out needs a SignalR backplane for group delivery — same limitation
|
||||||
|
as ADR 0006.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Reuse `NotificationHub` with groups**: rejected — it would entangle broadcast toasts
|
||||||
|
with targeted, high-frequency editing pings and force ADR 0002's broadcast contract to
|
||||||
|
change. A separate hub keeps the concerns and their decisions independent.
|
||||||
|
- **Per-user groups (by account id)**: rejected — a user may open two drafts/tabs;
|
||||||
|
per-draft-token groups target the precise editor and naturally support that.
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Evaluation — Backend-cached invoice editing over SignalR
|
# Evaluation — Backend-cached invoice editing over SignalR
|
||||||
|
|
||||||
|
> **⚠️ Superseded (2026-07-10).** This note's recommendation (keep the editor
|
||||||
|
> stateless; do **not** build the SignalR/server-cached model) was reversed by the
|
||||||
|
> product owner. Invoice draft editing is now backend-authoritative over an in-memory
|
||||||
|
> cache — see **ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)**,
|
||||||
|
> [`Decisions/0007-targeted-draft-signalr-groups.md`](Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
|
> and the concept doc [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md).
|
||||||
|
> The analysis below is retained for the historical rationale and the risks it flagged
|
||||||
|
> (server-held state, scaling/backplane, reconnect) — which the new design addresses or
|
||||||
|
> accepts explicitly as documented limitations.
|
||||||
|
|
||||||
|
|
||||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||||
|
|||||||
@@ -337,9 +337,18 @@ flowchart TD
|
|||||||
|
|
||||||
## 10. Key invariants worth remembering
|
## 10. Key invariants worth remembering
|
||||||
|
|
||||||
- **Stateless editor**: every preview/save/finalise call re-posts the full
|
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
|
||||||
`invc` JSON; the server never holds a partial invoice in memory or session
|
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
|
||||||
between requests (see `EVAL_live_invoice_editing.md`).
|
> **backend-authoritative** model where the server holds the draft in an in-memory
|
||||||
|
> cache (the single source of truth), the browser posts single edits and re-fetches on
|
||||||
|
> a SignalR signal, and totals are computed server-side. See ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
|
> and [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md). Finalise/email
|
||||||
|
> (§5–§6) are unchanged. The remaining invariants below still hold.
|
||||||
|
|
||||||
|
- **Stateless editor** *(legacy — see the note above; superseded by ADR 0006)*: every
|
||||||
|
preview/save/finalise call re-posts the full `invc` JSON; the server never holds a
|
||||||
|
partial invoice in memory or session between requests (see `EVAL_live_invoice_editing.md`).
|
||||||
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||||
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||||
mode (set pricing) never changes what the customer owes.
|
mode (set pricing) never changes what the customer owes.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
|
||||||
|
/// SignalR group named after the draft token so only the editing browser is notified.
|
||||||
|
/// Like <see cref="EventService.PublishAsync"/>, delivery failures are logged and
|
||||||
|
/// swallowed — a missed coordination ping must never fail the underlying operation
|
||||||
|
/// (the client also re-syncs on reconnect and on its next POST).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DraftNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
private readonly IHubContext<DraftPreviewHub> _hub;
|
||||||
|
private readonly ILogger<DraftNotifier> _logger;
|
||||||
|
|
||||||
|
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
|
||||||
|
|
||||||
|
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
|
||||||
|
///
|
||||||
|
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
|
||||||
|
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
|
||||||
|
/// signals must be <b>targeted</b> at the one browser editing a given draft.
|
||||||
|
/// Targeting is done with a SignalR group named after the draft's session token —
|
||||||
|
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
|
||||||
|
///
|
||||||
|
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
|
||||||
|
/// (see ADR 0006). The hub only manages group membership and delivers the server's
|
||||||
|
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public sealed class DraftPreviewHub : Hub
|
||||||
|
{
|
||||||
|
/// <summary>Subscribes this connection to a draft's signal group.</summary>
|
||||||
|
public Task JoinDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.AddToGroupAsync(Context.ConnectionId, token);
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
|
||||||
|
public Task LeaveDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends <b>system-internal</b> draft-editing signals to the one browser editing a
|
||||||
|
/// given draft, over the <see cref="DraftPreviewHub"/> group keyed by session token
|
||||||
|
/// (see ADR 0006 / 0007). These are coordination pings, not business notifications:
|
||||||
|
/// user-facing success/failure messages (e.g. "Zwischenstand gespeichert") still go
|
||||||
|
/// through <see cref="IEventService"/> / <see cref="NotificationHub"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDraftNotifier
|
||||||
|
{
|
||||||
|
/// <summary>The cached draft reached a new <paramref name="version"/> — the client should re-fetch its state.</summary>
|
||||||
|
Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>The draft is about to expire in <paramref name="secondsLeft"/>s unless saved — warn the user.</summary>
|
||||||
|
Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>The draft session was removed (evicted/expired/discarded) — the client must close the editor and show why.</summary>
|
||||||
|
Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -110,6 +110,14 @@ public class Program
|
|||||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||||
builder.Services.AddScoped<IEventService, EventService>();
|
builder.Services.AddScoped<IEventService, EventService>();
|
||||||
|
|
||||||
|
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
|
||||||
|
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
|
||||||
|
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
|
||||||
|
builder.Services.AddSingleton<IInvoiceDraftCache, InvoiceDraftCache>();
|
||||||
|
builder.Services.AddSingleton<IDraftNotifier, DraftNotifier>();
|
||||||
|
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
|
||||||
|
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
|
||||||
|
|
||||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||||
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
||||||
@@ -184,6 +192,7 @@ public class Program
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapHub<NotificationHub>("/notifications");
|
app.MapHub<NotificationHub>("/notifications");
|
||||||
|
app.MapHub<DraftPreviewHub>("/draftpreview");
|
||||||
|
|
||||||
// Intranet routes (root-level — this IS the website)
|
// Intranet routes (root-level — this IS the website)
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory store of live invoice draft editing sessions (see ADR 0006).
|
||||||
|
/// Singleton, single-instance only — scale-out would need a distributed cache /
|
||||||
|
/// sticky sessions (documented limitation). Keyed by the session token.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInvoiceDraftCache
|
||||||
|
{
|
||||||
|
/// <summary>Stores (or replaces) a session under its token.</summary>
|
||||||
|
void Set(InvoiceDraftSession session);
|
||||||
|
|
||||||
|
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
|
||||||
|
InvoiceDraftSession? Get(string token);
|
||||||
|
|
||||||
|
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
|
||||||
|
InvoiceDraftSession? Remove(string token);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
|
||||||
|
IReadOnlyList<InvoiceDraftSession> Snapshot();
|
||||||
|
|
||||||
|
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
|
||||||
|
TimeSpan IdleTtl { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </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).
|
||||||
|
/// </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.
|
||||||
|
/// </summary>
|
||||||
|
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
|
||||||
|
|
||||||
|
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
|
||||||
|
object BuildState(InvoiceDraftSession session);
|
||||||
|
|
||||||
|
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
|
||||||
|
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.
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvoiceDraftDelta
|
||||||
|
{
|
||||||
|
public string Target { get; set; } = "";
|
||||||
|
public string Ref { get; set; } = "";
|
||||||
|
public JToken? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
|
||||||
|
public string ValueString =>
|
||||||
|
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-instance, in-memory implementation of <see cref="IInvoiceDraftCache"/>
|
||||||
|
/// backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session
|
||||||
|
/// token. A plain dictionary (rather than <c>IMemoryCache</c>) is used on purpose:
|
||||||
|
/// the <see cref="InvoiceDraftExpiryService"/> needs to enumerate sessions and warn
|
||||||
|
/// the user <b>before</b> eviction, which opaque cache-entry expiry does not allow.
|
||||||
|
///
|
||||||
|
/// Idle TTL and the pre-expiry warning lead time are configurable under
|
||||||
|
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvoiceDraftCache : IInvoiceDraftCache
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, InvoiceDraftSession> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public TimeSpan IdleTtl { get; }
|
||||||
|
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
|
||||||
|
public TimeSpan ExpiryWarnLead { get; }
|
||||||
|
|
||||||
|
public InvoiceDraftCache(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
|
||||||
|
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||||
|
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
|
||||||
|
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
|
||||||
|
session.Touch();
|
||||||
|
_sessions[session.Token] = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceDraftSession? Get(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
if (_sessions.TryGetValue(token, out var s))
|
||||||
|
{
|
||||||
|
s.Touch();
|
||||||
|
// A touch resets the idle window, so a fresh warning is due next time it lapses.
|
||||||
|
s.ExpiryWarningSent = false;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceDraftSession? Remove(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return _sessions.TryRemove(token, out var s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<InvoiceDraftSession> Snapshot() => _sessions.Values.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
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
|
||||||
|
/// 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.
|
||||||
|
/// </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)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_invoices = invoices;
|
||||||
|
_intranet = intranet;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||||
|
|
||||||
|
// ── Open ─────────────────────────────────────────────────────────────────
|
||||||
|
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
||||||
|
{
|
||||||
|
var session = new InvoiceDraftSession
|
||||||
|
{
|
||||||
|
Token = NewToken(),
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
InvId = payload["invid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
|
||||||
|
};
|
||||||
|
session.Admin = payload["admin"] as JObject ?? new JObject();
|
||||||
|
session.New = payload["new"] as JObject ?? new JObject();
|
||||||
|
session.Req = payload["req"] as JArray ?? new JArray();
|
||||||
|
Refresh(session);
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Draft session {Token} opened from payload (invId={InvId}, user={User})",
|
||||||
|
session.Token, session.InvId, userAccountId);
|
||||||
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
|
public InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
|
||||||
|
string oldValue = "";
|
||||||
|
bool mutated = ApplyDelta(session, delta, ref oldValue);
|
||||||
|
if (!mutated)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh(session);
|
||||||
|
session.Version++;
|
||||||
|
session.History.Add(new ChangeHistoryEntry
|
||||||
|
{
|
||||||
|
UserAccountId = session.UserAccountId,
|
||||||
|
Target = delta.Target,
|
||||||
|
Ref = delta.Ref,
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = delta.ValueString,
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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 "provisionlocation":
|
||||||
|
oldValue = Str(s.New["provisionlocation"]);
|
||||||
|
s.New["provisionlocation"] = d.ValueString;
|
||||||
|
s.New["loc"] = d.ValueString; // editor mirrors both
|
||||||
|
return true;
|
||||||
|
case "contact": return SetContact(s, d, ref oldValue);
|
||||||
|
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;
|
||||||
|
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.remove": return RemoveBlock(s, d, ref oldValue);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
|
||||||
|
{
|
||||||
|
oldValue = Str(s.New[key]);
|
||||||
|
s.New[key] = d.ValueString;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
|
||||||
|
{
|
||||||
|
oldValue = Str(s.Admin[key]);
|
||||||
|
s.Admin[key] = d.ValueString;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||||
|
{
|
||||||
|
oldValue = Str(s.New["CustomValues"]);
|
||||||
|
JObject cvo = TryParseObject(oldValue);
|
||||||
|
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);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetItem(InvoiceDraftSession s, InvoiceDraftDelta d, string key, bool recompute, 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]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||||
|
{
|
||||||
|
var block = FindBlock(s, d.Ref);
|
||||||
|
if (block == null) return false;
|
||||||
|
oldValue = Str(block["text"]);
|
||||||
|
block.Remove();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View state / history ────────────────────────────────────────────────
|
||||||
|
public object BuildState(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
session.Touch();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
token = session.Token,
|
||||||
|
version = session.Version,
|
||||||
|
invid = session.InvId,
|
||||||
|
isDraft = session.IsDraft,
|
||||||
|
admin = session.Admin,
|
||||||
|
@new = session.New,
|
||||||
|
req = session.Req,
|
||||||
|
sums = new
|
||||||
|
{
|
||||||
|
total_net = session.Sums.TotalNet,
|
||||||
|
total_gross = session.Sums.TotalGross,
|
||||||
|
total_vat = session.Sums.TotalVat,
|
||||||
|
service_net = session.Sums.ServiceNet,
|
||||||
|
service_vat = session.Sums.ServiceVat,
|
||||||
|
vat = session.Sums.VatByRate,
|
||||||
|
block_net = session.Sums.NetByBlock
|
||||||
|
},
|
||||||
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||||
|
historyCount = session.History.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||||
|
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||||
|
|
||||||
|
// ── Flush / preview / discard ─────────────────────────────────────────────
|
||||||
|
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
|
||||||
|
var fds = BuildFdsData(session);
|
||||||
|
bool change = !string.IsNullOrEmpty(session.InvId);
|
||||||
|
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
||||||
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
|
{
|
||||||
|
session.InvId = reg.Id;
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||||
|
token, reg.Id, change, userAccountId);
|
||||||
|
}
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Document? RenderPreview(string token)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
var fds = BuildFdsData(session);
|
||||||
|
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||||
|
fds.IsDraft = true;
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
||||||
|
private static void Refresh(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(session);
|
||||||
|
InvoiceDraftCalculator.Validate(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
|
||||||
|
{
|
||||||
|
foreach (var b in s.Req)
|
||||||
|
if (b is JObject bo && Str(bo["Id"]) == blockId) return bo;
|
||||||
|
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>
|
||||||
|
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
var adm = (JObject)session.Admin.DeepClone();
|
||||||
|
var nw = (JObject)session.New.DeepClone();
|
||||||
|
nw["total_net"] = session.Sums.TotalNet;
|
||||||
|
nw["total_gross"] = session.Sums.TotalGross;
|
||||||
|
nw["title"] = nw["invoicetitle"] ?? nw["title"] ?? "";
|
||||||
|
nw["provisionlocation"] = nw["loc"] ?? nw["provisionlocation"] ?? "";
|
||||||
|
nw["paymentterm"] = adm["paymentterms"] ?? nw["paymentterm"] ?? "";
|
||||||
|
adm["customerid"] = adm["customerid"] ?? adm["CustomerId"];
|
||||||
|
|
||||||
|
var vat = new JObject();
|
||||||
|
foreach (var kv in session.Sums.VatByRate) vat[kv.Key] = kv.Value;
|
||||||
|
var sms = new JObject
|
||||||
|
{
|
||||||
|
["ttn"] = session.Sums.TotalNet,
|
||||||
|
["ttb"] = session.Sums.TotalGross,
|
||||||
|
["ttvat"] = session.Sums.TotalVat,
|
||||||
|
["tscn"] = session.Sums.ServiceNet,
|
||||||
|
["tscvat"] = session.Sums.ServiceVat,
|
||||||
|
["vat"] = vat
|
||||||
|
};
|
||||||
|
|
||||||
|
var jobj = new JObject
|
||||||
|
{
|
||||||
|
["admin"] = adm,
|
||||||
|
["new"] = nw,
|
||||||
|
["sms"] = sms,
|
||||||
|
["req"] = session.Req.DeepClone()
|
||||||
|
};
|
||||||
|
return new FdsInvoiceData(jobj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synthesises the <c>InvoiceRegistration</c> dictionary a draft PDF render needs,
|
||||||
|
/// straight from the cached session — so a preview requires no DB round-trip and no
|
||||||
|
/// client upload. Mirrors the columns <c>fds__getInvoice</c> would return for a draft.
|
||||||
|
/// </summary>
|
||||||
|
private GenericObjectDictionary SynthesizeRegistration(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
string title = Str(session.New["invoicetitle"]).ne(Str(session.New["title"]));
|
||||||
|
string loc = Str(session.New["provisionlocation"]).ne(Str(session.New["loc"]));
|
||||||
|
var d = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Id"] = session.InvId,
|
||||||
|
["InvoiceType"] = Str(session.Admin["type"]).ne("R"),
|
||||||
|
["InvoiceId"] = "",
|
||||||
|
["InvoiceTitle"] = title,
|
||||||
|
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
||||||
|
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
||||||
|
["ProvisionLocation"] = loc,
|
||||||
|
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
|
||||||
|
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
|
||||||
|
["InvoiceBalance"] = session.Sums.TotalGross,
|
||||||
|
["InvoiceBalance_net"] = session.Sums.TotalNet,
|
||||||
|
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||||
|
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
||||||
|
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
int idx = 0;
|
||||||
|
foreach (var kv in session.Sums.VatByRate)
|
||||||
|
{
|
||||||
|
idx++;
|
||||||
|
if (idx > 2) break;
|
||||||
|
d[$"InvoiceVAT_{idx}"] = kv.Key;
|
||||||
|
d[$"InvoiceVAT_net{idx}"] = kv.Value;
|
||||||
|
}
|
||||||
|
return new GenericObjectDictionary(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the InvoiceOptions CSV (§13b + setmode) from the session admin flags — matches <see cref="FdsInvoiceData.BuildInvoiceOptions"/>.</summary>
|
||||||
|
private static string BuildInvoiceOptions(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
var tokens = new List<string>();
|
||||||
|
if (AsBool(session.Admin["p13b"])) tokens.Add("§13b");
|
||||||
|
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
|
||||||
|
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||||
|
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();
|
||||||
|
|
||||||
|
private static bool AsBool(JToken? t)
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject TryParseObject(string json)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
|
||||||
|
{
|
||||||
|
try { return JObject.Parse(json); } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return new JObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Fuchs.Notifications;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background monitor for the invoice draft cache (ADR 0006). Because a draft's
|
||||||
|
/// truth lives only in server memory until the user saves, idle sessions must not
|
||||||
|
/// vanish silently: this service warns the editing browser <b>before</b> a session's
|
||||||
|
/// idle TTL lapses ("bitte zwischenspeichern"), and when the TTL is finally reached
|
||||||
|
/// it evicts the session and tells the browser to close the editor with a reason.
|
||||||
|
/// All hints travel over the <see cref="DraftPreviewHub"/> via <see cref="IDraftNotifier"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvoiceDraftExpiryService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IInvoiceDraftCache _cache;
|
||||||
|
private readonly IDraftNotifier _notifier;
|
||||||
|
private readonly ILogger<InvoiceDraftExpiryService> _logger;
|
||||||
|
private readonly TimeSpan _warnLead;
|
||||||
|
private readonly TimeSpan _interval;
|
||||||
|
|
||||||
|
public InvoiceDraftExpiryService(IInvoiceDraftCache cache, IDraftNotifier notifier,
|
||||||
|
IConfiguration configuration, ILogger<InvoiceDraftExpiryService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_notifier = notifier;
|
||||||
|
_logger = logger;
|
||||||
|
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||||
|
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
|
||||||
|
_interval = TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(_interval);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
await SweepAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* shutting down */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
|
||||||
|
internal async Task SweepAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
foreach (var session in _cache.Snapshot())
|
||||||
|
{
|
||||||
|
TimeSpan idle = now - session.LastAccessUtc;
|
||||||
|
if (idle >= _cache.IdleTtl)
|
||||||
|
{
|
||||||
|
_cache.Remove(session.Token);
|
||||||
|
_logger.LogInformation("Draft {Token} evicted after {Idle} idle (user={User})",
|
||||||
|
session.Token, idle, session.UserAccountId);
|
||||||
|
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
|
||||||
|
}
|
||||||
|
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
|
||||||
|
{
|
||||||
|
session.ExpiryWarningSent = true;
|
||||||
|
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
|
||||||
|
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -284,3 +284,65 @@ $fis.notifications = {
|
|||||||
}, 9000);
|
}, 9000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
|
||||||
|
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
|
||||||
|
(group = session token) to re-fetch (draftReady), warns before idle expiry
|
||||||
|
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
|
||||||
|
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
|
||||||
|
$fis.draft = {
|
||||||
|
connection: null,
|
||||||
|
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
|
||||||
|
init: function () {
|
||||||
|
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl('/draftpreview')
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
|
||||||
|
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
|
||||||
|
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
|
||||||
|
/* Re-join the active draft's group after a (re)connect — group membership is
|
||||||
|
per-connection and is lost when the socket drops. */
|
||||||
|
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
|
||||||
|
this.connection.onclose(() => {
|
||||||
|
console.warn('Draft connection closed; retrying in 5s.');
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
|
||||||
|
});
|
||||||
|
this.start();
|
||||||
|
},
|
||||||
|
start: function () {
|
||||||
|
this.connection.start()
|
||||||
|
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Draft connection failed to start; retrying in 5s.', err);
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => this.init(), 5000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Registers the currently open draft and joins its signal group. handlers:
|
||||||
|
{ onReady, onExpiring, onClosed }. */
|
||||||
|
bind: function (token, handlers) {
|
||||||
|
if (!token) { return; }
|
||||||
|
this.active = $.extend({ token: token }, handlers || {});
|
||||||
|
if (this.connection === null) { this.init(); }
|
||||||
|
this._invoke('JoinDraft', token);
|
||||||
|
},
|
||||||
|
/* Unregisters + leaves the group (editor closed). */
|
||||||
|
release: function (token) {
|
||||||
|
if (this.active && (!token || this.active.token === token)) { this.active = null; }
|
||||||
|
this._invoke('LeaveDraft', token);
|
||||||
|
},
|
||||||
|
_invoke: function (method, token) {
|
||||||
|
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
|
||||||
|
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
|
||||||
|
},
|
||||||
|
_dispatch: function (handler, payload, argOf) {
|
||||||
|
payload = payload || {};
|
||||||
|
if (!this.active || this.active.token !== payload.token) { return; }
|
||||||
|
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$fis.notifications.init();
|
$fis.notifications.init();
|
||||||
|
$fis.draft.init();
|
||||||
$fis.ov();
|
$fis.ov();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3129,6 +3129,68 @@ $fis.notifications = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
|
||||||
|
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
|
||||||
|
(group = session token) to re-fetch (draftReady), warns before idle expiry
|
||||||
|
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
|
||||||
|
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
|
||||||
|
$fis.draft = {
|
||||||
|
connection: null,
|
||||||
|
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
|
||||||
|
init: function () {
|
||||||
|
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl('/draftpreview')
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
|
||||||
|
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
|
||||||
|
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
|
||||||
|
/* Re-join the active draft's group after a (re)connect — group membership is
|
||||||
|
per-connection and is lost when the socket drops. */
|
||||||
|
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
|
||||||
|
this.connection.onclose(() => {
|
||||||
|
console.warn('Draft connection closed; retrying in 5s.');
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
|
||||||
|
});
|
||||||
|
this.start();
|
||||||
|
},
|
||||||
|
start: function () {
|
||||||
|
this.connection.start()
|
||||||
|
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Draft connection failed to start; retrying in 5s.', err);
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => this.init(), 5000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Registers the currently open draft and joins its signal group. handlers:
|
||||||
|
{ onReady, onExpiring, onClosed }. */
|
||||||
|
bind: function (token, handlers) {
|
||||||
|
if (!token) { return; }
|
||||||
|
this.active = $.extend({ token: token }, handlers || {});
|
||||||
|
if (this.connection === null) { this.init(); }
|
||||||
|
this._invoke('JoinDraft', token);
|
||||||
|
},
|
||||||
|
/* Unregisters + leaves the group (editor closed). */
|
||||||
|
release: function (token) {
|
||||||
|
if (this.active && (!token || this.active.token === token)) { this.active = null; }
|
||||||
|
this._invoke('LeaveDraft', token);
|
||||||
|
},
|
||||||
|
_invoke: function (method, token) {
|
||||||
|
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
|
||||||
|
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
|
||||||
|
},
|
||||||
|
_dispatch: function (handler, payload, argOf) {
|
||||||
|
payload = payload || {};
|
||||||
|
if (!this.active || this.active.token !== payload.token) { return; }
|
||||||
|
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
Array.prototype.push.apply($ocms.ocmsmenu,[
|
Array.prototype.push.apply($ocms.ocmsmenu,[
|
||||||
{ lbl: $t.m_inv, id: 'm_inv', fnc: 'init:inv', ico: 'glyphicon glyphicon-list-alt'}
|
{ lbl: $t.m_inv, id: 'm_inv', fnc: 'init:inv', ico: 'glyphicon glyphicon-list-alt'}
|
||||||
@@ -3143,5 +3205,6 @@ $fis.notifications = {
|
|||||||
})();
|
})();
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$fis.notifications.init();
|
$fis.notifications.init();
|
||||||
|
$fis.draft.init();
|
||||||
$fis.ov();
|
$fis.ov();
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user