Compare commits

..
4 Commits
Author SHA1 Message Date
Stefan 5c0fdc6c1d Changed Reminder Systematics analogue to invoices 2026-07-10 22:50:26 +02:00
Stefan 83d1c28b29 Refactor code structure and remove redundant sections for improved readability and maintainability 2026-07-10 21:03:55 +02:00
Stefan 42997c4f49 Refactor code structure for improved readability and maintainability 2026-07-10 14:29:51 +02:00
StefanandClaude Opus 4.8 af445c015e 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>
2026-07-10 13:29:35 +02:00
59 changed files with 5436 additions and 424 deletions
+52
View File
@@ -0,0 +1,52 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the block projection that feeds the PDF item table: each service-request
/// group exposes its heading (the section title the editor shows) and its line items,
/// so the PDF can print a heading row per block and the flat item list still works.
/// </summary>
public class FdsInvoiceDataBlocksTests
{
private static FdsInvoiceData FromReq(string reqJson) =>
new(JObject.Parse(@"{'admin':{'type':'r'},'new':{},'sms':{},'req':" + reqJson + "}"));
[Fact]
public void InvoiceBlocks_ExposesHeadingFromTextThenNme_AndItems()
{
var inv = FromReq(@"[
{'Id':'1','text':'Sektion A','items':[{'id':'a','type':'material','title':'X','price_net':10,'total_net':10}]},
{'Id':'2','nme':'Sektion B','items':[{'id':'b','type':'material','title':'Y','price_net':20,'total_net':20}]}
]");
var blocks = inv.InvoiceBlocks;
Assert.Equal(2, blocks.Count);
Assert.Equal("Sektion A", blocks[0].Heading);
Assert.Equal("Sektion B", blocks[1].Heading); // falls back to nme
Assert.Single(blocks[0].Items);
Assert.Equal("X", blocks[0].Items[0]["title"]);
}
[Fact]
public void InvoiceBlocks_MissingHeading_IsEmpty()
{
var inv = FromReq(@"[{'Id':'1','items':[{'id':'a','type':'material','total_net':5}]}]");
Assert.Equal("", Assert.Single(inv.InvoiceBlocks).Heading);
}
[Fact]
public void InvoiceItems_StillFlattensAcrossBlocks()
{
var inv = FromReq(@"[
{'Id':'1','text':'A','items':[{'id':'a','type':'material','total_net':10}]},
{'Id':'2','text':'B','items':[{'id':'b','type':'material','total_net':20},{'id':'c','type':'material','total_net':30}]}
]");
Assert.Equal(new[] { "a", "b", "c" }, inv.InvoiceItems.Select(i => i["id"]!.ToString()).ToArray());
}
}
+113
View File
@@ -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);
}
}
+212
View File
@@ -0,0 +1,212 @@
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the server-side aggregation of invoice draft totals (the port of the
/// former client-side <c>invSumUpdate</c> footer math). The truth now lives in the
/// backend (ADR 0006), so this is unit-testable directly. Line values are read from
/// each block's <c>itm</c> array (the editor's <c>co</c> objects: <c>vt</c>=net,
/// <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT, <c>vat</c>=rate).
/// </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;
}
[Fact]
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'},
{'vt':50,'vv':9.5,'vs':50,'vsv':9.5,'vat':'19%'} ] },
{ 'Id':'11','itm':[
{'vt':200,'vv':14,'vs':0,'vsv':0,'vat':'7%'} ] }
]");
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','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", 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);
}
[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));
[Fact]
public void Validate_ValidDraft_NoErrors()
{
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
}
[Theory]
[InlineData("", "warning")]
[InlineData("not-an-email", "error")]
public void Validate_EmailProblems_AreFlagged(string email, string severity)
{
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
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','itm':[{'vt':10,'vv':0.5,'vat':'5%'}] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
}
[Fact]
public void Validate_MissingAddress_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
s.New["invoiceaddress"] = "";
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
}
[Fact]
public void Validate_NegativeTotal_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':-50,'vv':0,'vat':''}] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
}
// ── RecomputePositions ────────────────────────────────────────────────────
private static string Pos(InvoiceDraftSession s, int block, int line) =>
((JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line])["p"]!.ToString();
[Fact]
public void RecomputePositions_NumbersPricedLinesContinuouslyAcrossBlocks()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1}, {'id':'b','typ':'service','vt':2} ] },
{ 'Id':'11','itm':[ {'id':'c','typ':'material','vt':3} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0));
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 1, 0)); // continuous, not restarting per block
}
[Fact]
public void RecomputePositions_SkipsHeadingAndFreeTextLines()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'t','typ':'Title','vt':0},
{'id':'a','typ':'material','vt':1},
{'id':'x','typ':'Text','vt':0},
{'id':'b','typ':'material','vt':2} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("", Pos(s, 0, 0)); // title carries no number
Assert.Equal("1", Pos(s, 0, 1));
Assert.Equal("", Pos(s, 0, 2)); // free text carries no number
Assert.Equal("2", Pos(s, 0, 3));
}
[Fact]
public void RecomputePositions_NumbersSetHeaderLikeAnyItem()
{
// A set header is numbered just like the editor numbers it — only text/title lines are skipped.
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'h','typ':'set','vt':1000},
{'id':'a','typ':'material','vt':600},
{'id':'b','typ':'material','vt':400} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // set header keeps position 1 (matches the editor)
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 0, 2));
}
[Fact]
public void RecomputePositions_AfterBlockOrderChange_RenumbersToNewSequence()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1} ] },
{ 'Id':'11','itm':[ {'id':'b','typ':'material','vt':2} ] }
]");
// Simulate a section reorder: swap the two blocks.
var b0 = s.Req[0]; var b1 = s.Req[1];
s.Req = new JArray(b1.DeepClone(), b0.DeepClone());
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // formerly block 11's item is now position 1
Assert.Equal("2", Pos(s, 1, 0));
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies <see cref="DraftNotifier"/> targets the draft's SignalR group (keyed by
/// session token, ADR 0007) with the right method/payload, and — like the other
/// notification path — swallows hub failures so a missed coordination ping never
/// fails the underlying operation.
/// </summary>
public class InvoiceDraftNotifierTests
{
private sealed class CapturingClientProxy : IClientProxy
{
private readonly bool _throw;
public string? Method { get; private set; }
public object?[]? Args { get; private set; }
public CapturingClientProxy(bool doThrow = false) => _throw = doThrow;
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (_throw) throw new InvalidOperationException("hub down");
Method = method;
Args = args;
return Task.CompletedTask;
}
}
private sealed class StubHubClients : IHubClients
{
private readonly IClientProxy _proxy;
public string? RequestedGroup { get; private set; }
public StubHubClients(IClientProxy proxy) => _proxy = proxy;
public IClientProxy Group(string groupName) { RequestedGroup = groupName; return _proxy; }
public IClientProxy All => throw new NotImplementedException();
public IClientProxy AllExcept(IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy Client(string c) => throw new NotImplementedException();
public IClientProxy Clients(IReadOnlyList<string> c) => throw new NotImplementedException();
public IClientProxy Groups(IReadOnlyList<string> g) => throw new NotImplementedException();
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy User(string u) => throw new NotImplementedException();
public IClientProxy Users(IReadOnlyList<string> u) => throw new NotImplementedException();
}
private sealed class StubHubContext : IHubContext<DraftPreviewHub>
{
public StubHubContext(IHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new NotImplementedException();
}
private static (DraftNotifier notifier, StubHubClients clients, CapturingClientProxy proxy) Create(bool doThrow = false)
{
var proxy = new CapturingClientProxy(doThrow);
var clients = new StubHubClients(proxy);
var notifier = new DraftNotifier(new StubHubContext(clients), NullLogger<DraftNotifier>.Instance);
return (notifier, clients, proxy);
}
[Fact]
public async Task SignalDraftReadyAsync_SendsToTokenGroupWithVersion()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("tok-1", 7);
Assert.Equal("tok-1", clients.RequestedGroup);
Assert.Equal("draftReady", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("tok-1", payload["token"]!.Value<string>());
Assert.Equal(7, payload["version"]!.Value<int>());
}
[Fact]
public async Task SignalExpiringAsync_SendsSecondsLeft()
{
var (notifier, _, proxy) = Create();
await notifier.SignalExpiringAsync("tok-2", 120);
Assert.Equal("draftExpiring", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal(120, payload["secondsLeft"]!.Value<int>());
}
[Fact]
public async Task SignalClosedAsync_SendsReason()
{
var (notifier, _, proxy) = Create();
await notifier.SignalClosedAsync("tok-3", "expired");
Assert.Equal("draftClosed", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("expired", payload["reason"]!.Value<string>());
}
[Fact]
public async Task EmptyToken_DoesNotSend()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("", 1);
Assert.Null(clients.RequestedGroup);
Assert.Null(proxy.Method);
}
[Fact]
public async Task HubFailure_IsSwallowed()
{
var (notifier, _, _) = Create(doThrow: true);
// Must not throw — a failed coordination ping cannot fail the caller's operation.
await notifier.SignalDraftReadyAsync("tok", 1);
await notifier.SignalExpiringAsync("tok", 60);
await notifier.SignalClosedAsync("tok", "expired");
}
}
+479
View File
@@ -0,0 +1,479 @@
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
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 a database, proving the backend-authoritative model behaves correctly at
/// the service seam (ADR 0006). Blocks use the editor's <c>itm</c>/<c>items</c> shape.
/// </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 FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft;
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
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 cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
var inv = new FakeInvoiceService();
var svc = new InvoiceDraftEditService(cache, inv, 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','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}]
}");
[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_BlockReplace_RecomputesTotals()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':50,'vv':9.5,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':50,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
Assert.Equal(50m, s2!.Sums.TotalNet);
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_BlockRemove_EmptiesDraftAndFlagsNoItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.remove", Ref = "1" });
Assert.Equal(0m, s2!.Sums.TotalNet);
Assert.Contains(s2.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
}
[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);
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 void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
[Theory]
[InlineData("address", "invoiceaddress")]
[InlineData("title", "invoicetitle")]
[InlineData("provisionperiod", "provisionperiod")]
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
}
[Fact]
public void ApplyPatch_ProvisionLocation_MirrorsLocAndProvisionlocation()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("Baustelle 7") });
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
}
[Fact]
public void ApplyPatch_Contact_BuildsCustomValuesJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
{
Target = "contact",
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
});
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
}
[Fact]
public void ApplyPatch_SetmodeDelta_UpdatesAdmin()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("itemprices") });
Assert.Equal("itemprices", s2!.Admin["setmode"]!.Value<string>());
}
[Fact]
public void ApplyPatch_P13bExplicitFalse_TurnsOffAndRestoresVat()
{
var (svc, _, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true; // start reverse-charge
var s = svc.OpenFromPayload(payload, "user1");
Assert.Empty(s.Sums.VatByRate);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
Assert.Equal(119m, s2!.Sums.TotalGross); // VAT restored
Assert.Equal(19m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_BlockReplace_InsertsWhenBlockIsNew()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'2','text':'Zusatz','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],
'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "2", Value = newBlock });
Assert.Equal(2, s2!.Req.Count);
Assert.Equal(130m, s2.Sums.TotalNet); // 100 (block 1) + 30 (new block 2)
Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
}
[Fact]
public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Titel 2") });
var s3 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Adr 3") });
Assert.Equal(3, s3!.Version);
Assert.Equal(3, s3.History.Count);
Assert.Equal(new[] { "email", "title", "address" }, s3.History.Select(h => h.Target).ToArray());
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
}
[Fact]
public void BuildState_ExposesPayloadSumsValidationAndVersion()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
Assert.Equal(1, state["version"]!.Value<int>());
Assert.Equal(100m, state["sums"]!["total_net"]!.Value<decimal>());
Assert.Equal(119m, state["sums"]!["total_gross"]!.Value<decimal>());
Assert.Equal(19m, state["sums"]!["vat"]!["19"]!.Value<decimal>());
Assert.Single((JArray)state["req"]!);
Assert.Equal(1, state["historyCount"]!.Value<int>());
Assert.NotNull(state["validation"]);
}
[Fact]
public async Task FlushToDbAsync_ExistingInvId_UpdatesInsteadOfCreates()
{
var (svc, inv, _) = NewService();
var payload = Payload();
payload["invid"] = "INV7";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(inv.LastChange); // prior InvId → update path
}
[Fact]
public async Task FlushToDbAsync_MapsInvoiceOptionsFrom13bAndSetmode()
{
var (svc, inv, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true;
payload["admin"]!["setmode"] = "itemprices";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
Assert.Contains("§13b", options);
Assert.Contains("setmode:itemprices", options);
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
Assert.NotNull(doc);
Assert.True(inv.PreviewDraft); // always rendered as a draft
var reg = inv.PreviewInvoice!.InvoiceRegistration!;
Assert.Equal("Rechnung", reg.getString("InvoiceTitle"));
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
Assert.Equal("19", reg.getString("InvoiceVAT_1")); // rate synthesised from server sums
Assert.True(inv.PreviewInvoice.IsDraft);
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _, _) = NewService();
Assert.Null(svc.RenderPreview("ghost"));
}
[Fact]
public void Close_RemovesSession_ThenReportsFalse()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.True(svc.Close(s.Token));
Assert.Null(svc.Get(s.Token));
Assert.False(svc.Close(s.Token));
}
// ── HTML sanitisation (values must never reach the DB/PDF wrapped in tags) ─
[Theory]
[InlineData("provisionperiod", "provisionperiod")]
[InlineData("title", "invoicetitle")]
[InlineData("email", "invoiceemail")]
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("<p>18.06.2026</p>") });
Assert.Equal("18.06.2026", s2!.New[newKey]!.Value<string>()); // no <p> tags stored
Assert.Equal("18.06.2026", Assert.Single(s2.History).NewValue);
}
[Fact]
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
{
Target = "address",
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>5080 Laufenburg</p>")
});
Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ScalarField_DecodesEntities()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Tom &amp; Jerry") });
Assert.Equal("Tom & Jerry", s2!.New["invoicetitle"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ProvisionLocation_SanitisesAndMirrorsLoc()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("<p>Baustelle 7</p>") });
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
}
// ── Change history records the changed field, not the whole block JSON ─────
[Fact]
public void ApplyPatch_BlockReplace_HistoryNewValueIsSectionText_NotJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'1','text':'<p>Neue Überschrift</p>',
'itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
var h = Assert.Single(s2!.History);
Assert.Equal("Neue Überschrift", h.NewValue); // the heading, sanitised — never the block JSON
Assert.DoesNotContain("{", h.NewValue);
Assert.Equal("Auftrag", h.OldValue);
// and the cached block text is stored clean too
Assert.Equal("Neue Überschrift", ((JObject)s2.Req[0])["text"]!.Value<string>());
}
// ── Section reorder ───────────────────────────────────────────────────────
private static JObject TwoBlockPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
'req':[
{'Id':'1','text':'A','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]},
{'Id':'2','text':'B','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}
]}");
[Fact]
public void ApplyPatch_BlockOrder_ReordersReqAndRenumbersPositions()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
Assert.Equal(new[] { "1", "2" }, s.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
Assert.Equal("1", ((JObject)((JArray)((JObject)s2.Req[0])["itm"]!)[0])["p"]!.ToString()); // block 2's item now position 1
Assert.Equal(130m, s2.Sums.TotalNet); // totals unaffected by reorder
var h = Assert.Single(s2.History);
Assert.Equal("1,2", h.OldValue);
Assert.Equal("2,1", h.NewValue);
}
[Fact]
public void ApplyPatch_BlockOrder_UnchangedSequence_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['1','2']") });
Assert.Equal(0, s2!.Version); // no-op: no version bump, no history
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_BlockOrder_UnknownIds_KeepMentionedFirstThenRest()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
// Only name block 2; block 1 is unmentioned and must be kept (appended after).
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','ghost']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
}
}
@@ -0,0 +1,92 @@
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exhaustively exercises the pure reminder-draft aggregation/validation (ADR 0006,
/// the reminder mirror of <see cref="InvoiceDraftCalculatorTests"/>). Being static/pure,
/// the open-amount math and the plausibility checks are unit-testable without a DB.
/// </summary>
public class ReminderDraftCalculatorTests
{
private static ReminderDraftSession Session(string newJson) =>
new() { New = JObject.Parse(newJson) };
[Theory]
[InlineData("{'amount':119,'amount_payed':0}", 119, 0, 119)]
[InlineData("{'amount':119,'amount_payed':20}", 119, 20, 99)]
[InlineData("{'amount':'119,50','amount_payed':'19,50'}", 119.50, 19.50, 100)] // German decimals
[InlineData("{'amount':'100.00','amount_payed':'40.00'}", 100, 40, 60)] // invariant decimals
[InlineData("{}", 0, 0, 0)] // missing → 0
[InlineData("{'amount':50,'amount_payed':80}", 50, 80, -30)] // overpaid → negative
public void RecomputeTotals_ComputesOpenAmount(string newJson, double total, double payed, double open)
{
var s = Session(newJson);
ReminderDraftCalculator.RecomputeTotals(s);
Assert.Equal((decimal)total, s.Sums.AmountTotal);
Assert.Equal((decimal)payed, s.Sums.AmountPayed);
Assert.Equal((decimal)open, s.Sums.AmountOpen);
}
[Fact]
public void Validate_EmptyEmail_Warns()
{
var s = Session("{'amount':119,'invoiceaddress':'Weg 1','subject':'X'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "warning");
}
[Theory]
[InlineData("bad")]
[InlineData("no-at-sign.de")]
[InlineData("trailing@dot.")]
public void Validate_InvalidEmail_Errors(string email)
{
var s = Session($"{{'amount':119,'invoiceemail':'{email}','invoiceaddress':'Weg 1','subject':'X'}}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "error");
}
[Fact]
public void Validate_ValidEmail_NoEmailMessage()
{
var s = Session("{'amount':119,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'X'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.DoesNotContain(s.ValidationMessages, m => m.Field == "email");
}
[Fact]
public void Validate_EmptyAddressAndSubject_Warn()
{
var s = Session("{'amount':119,'invoiceemail':'a@b.de'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
Assert.Contains(s.ValidationMessages, m => m.Field == "subject" && m.Severity == "warning");
}
[Theory]
[InlineData("{'amount':0,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
[InlineData("{'amount':50,'amount_payed':80,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
public void Validate_NonPositiveOpenAmount_Warns(string newJson)
{
var s = Session(newJson);
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "amount" && m.Severity == "warning");
}
[Fact]
public void Validate_HealthyDraft_HasNoMessages()
{
var s = Session("{'amount':119,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung'}");
ReminderDraftCalculator.RecomputeTotals(s);
ReminderDraftCalculator.Validate(s);
Assert.Empty(s.ValidationMessages);
}
}
+307
View File
@@ -0,0 +1,307 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
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 reminder draft edit orchestrator's pure paths (open/patch/history/flush)
/// without a database — the reminder mirror of <see cref="InvoiceDraftServiceTests"/>,
/// proving the backend-authoritative model behaves correctly at the service seam (ADR 0006).
/// </summary>
public class ReminderDraftServiceTests
{
/// <summary>Captures the reminder handed to registration and returns it with a fake DB id — no SQL.</summary>
private sealed class FakeReminderService : IReminderService
{
public FdsReminderData? Registered;
public bool? LastChange;
public FdsReminderData? PreviewReminder;
public bool? PreviewDraft;
public Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
{
Registered = reminder;
LastChange = change;
reminder.ReminderRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "REM42" });
return Task.FromResult(reminder);
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) { PreviewReminder = reminder; PreviewDraft = draft; return new Document(); }
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
}
private static (ReminderDraftEditService svc, FakeReminderService rem) NewService()
{
var cache = new ReminderDraftCache(new ConfigurationBuilder().Build());
var rem = new FakeReminderService();
var svc = new ReminderDraftEditService(cache, rem, NullLogger<ReminderDraftEditService>.Instance);
return (svc, rem);
}
private static JObject Payload() => JObject.Parse(@"{
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
}");
[Fact]
public void OpenFromPayload_SeedsSessionAndComputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.False(string.IsNullOrEmpty(s.Token));
Assert.Equal(0, s.Version);
Assert.Equal(119m, s.Sums.AmountTotal);
Assert.Equal(119m, s.Sums.AmountOpen);
}
[Fact]
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { 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);
}
[Fact]
public void ApplyPatch_Amount_RecomputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
Assert.Equal(200m, s2!.Sums.AmountTotal);
Assert.Equal(200m, s2.Sums.AmountOpen);
}
[Fact]
public void ApplyPatch_AmountPayed_RecomputesOpenAmount()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
Assert.Equal(100m, s2!.Sums.AmountOpen); // 119 - 19
}
[Fact]
public void ApplyPatch_AmountFromGermanString_NormalisesToInvariant()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject("249,90") });
Assert.Equal("249.90", s2!.New["amount"]!.Value<string>()); // stored invariant
Assert.Equal(249.90m, s2.Sums.AmountTotal);
}
[Fact]
public void ApplyPatch_UnknownToken_ReturnsNull()
{
var (svc, _) = NewService();
Assert.Null(svc.ApplyPatch("ghost", new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
}
[Fact]
public void ApplyPatch_UnknownTarget_IsNoOp()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
}
[Theory]
[InlineData("subject", "subject")]
[InlineData("address", "invoiceaddress")]
[InlineData("text", "text")]
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
}
[Theory]
[InlineData("subject", "subject")]
[InlineData("email", "invoiceemail")]
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("<p>clean me</p>") });
Assert.Equal("clean me", s2!.New[newKey]!.Value<string>());
Assert.DoesNotContain("<", s2.New[newKey]!.Value<string>());
}
[Fact]
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
{
Target = "address",
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>40000 Düsseldorf</p>")
});
Assert.Equal("Firma AG\nWeg 1\n40000 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
}
[Fact]
public void ApplyPatch_Contact_BuildsCustomValuesJson()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
{
Target = "contact",
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
});
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
}
[Fact]
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "subject", Value = JToken.FromObject("Mahnung 2") });
var s3 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
Assert.Equal(3, s3!.Version);
Assert.Equal(new[] { "email", "subject", "amount" }, s3.History.Select(h => h.Target).ToArray());
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
}
[Fact]
public void BuildState_ExposesPayloadSumsValidationAndVersion()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
Assert.Equal(1, state["version"]!.Value<int>());
Assert.Equal(119m, state["sums"]!["amount_total"]!.Value<decimal>());
Assert.Equal(100m, state["sums"]!["amount_open"]!.Value<decimal>());
Assert.Equal(1, state["historyCount"]!.Value<int>());
Assert.NotNull(state["validation"]);
}
[Fact]
public async Task FlushToDbAsync_RegistersAndSetsRemId_CreatePath()
{
var (svc, rem) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result);
Assert.Equal("REM42", result!.Id);
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
// the email/subject the editor set must reach registration
Assert.Equal("a@b.de", rem.Registered!.RawInvoiceEmail);
Assert.Equal("Zahlungserinnerung", rem.Registered!.NewValues!.getString("subject"));
Assert.Equal("INV5", rem.Registered!.RawInvId);
}
[Fact]
public async Task FlushToDbAsync_ExistingRemId_UpdatePath()
{
var (svc, rem) = NewService();
var payload = Payload();
payload["remid"] = "REM7";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(rem.LastChange); // prior RemId → update path
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, rem) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
Assert.NotNull(doc);
Assert.True(rem.PreviewDraft);
Assert.True(rem.PreviewReminder!.IsDraft);
var reg = rem.PreviewReminder!.ReminderRegistration!;
Assert.Equal("Zahlungserinnerung", reg.getString("subject"));
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
Assert.Equal("R2026-1", reg.getString("InvoiceId"));
// the synthesised single-invoice row the reminder table renders
Assert.Single(rem.PreviewReminder!.ReminderItems);
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _) = NewService();
Assert.Null(svc.RenderPreview("ghost"));
}
[Fact]
public void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
[Fact]
public void Close_RemovesSession_ThenReportsFalse()
{
var (svc, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.True(svc.Close(s.Token));
Assert.Null(svc.Get(s.Token));
Assert.False(svc.Close(s.Token));
}
}
@@ -0,0 +1,130 @@
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 — { payload } → { token, version }
// The editor assembles the initial draft (from a service request or a reloaded DB draft
// via the existing render paths) and seeds the authoritative session here. Reload/discard
// is the client re-fetching + re-seeding, so there is no server-side DB reshaping.
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
{
if (!HasForm("payload"))
{
_logger.LogWarning("Draft dopen: 'payload' missing user={User}", UserAccountID);
return BadRequest400();
}
JObject payload;
try { payload = JObject.Parse(Form("payload")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
return BadRequest400();
}
var session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
_logger.LogInformation("Draft dopen: session {Token} (invId={InvId}) user={User}", session.Token, session.InvId, UserAccountID);
// 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/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,15 @@ 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 "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 });
@@ -97,6 +97,15 @@ public partial class IntranetController
case "idoc": return await HandleReminderIdoc(fn, id, code); case "idoc": return await HandleReminderIdoc(fn, id, code);
case "resend": return await HandleReminderResend(fn, id, code); case "resend": return await HandleReminderResend(fn, id, code);
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
case "dopen": return await HandleReminderDraftOpen(fn, id, code);
case "dstate": return await HandleReminderDraftState(fn, id, code);
case "dpatch": return await HandleReminderDraftPatch(fn, id, code);
case "dpreview": return await HandleReminderDraftPreview(fn, id, code);
case "dsave": return await HandleReminderDraftSave(fn, id, code);
case "dhistory": return await HandleReminderDraftHistory(fn, id, code);
case "dclose": return await HandleReminderDraftClose(fn, id, code);
case "lrem": case "lrem":
{ {
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
@@ -0,0 +1,122 @@
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 reminder draft editing (ADR 0006) — the
// reminder mirror of IntranetController.InvoiceDraft.cs. The browser posts single edits
// here; the server mutates the in-memory session (the source of truth), recomputes the
// open amount / validates, and pings the editing browser over the shared DraftPreviewHub
// (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries only signals.
public partial class IntranetController
{
// POST rem/dopen — { payload } → { token, version }
private async Task<IActionResult> HandleReminderDraftOpen(string fn, string id, string code)
{
if (!HasForm("payload"))
{
_logger.LogWarning("Reminder draft dopen: 'payload' missing user={User}", UserAccountID);
return BadRequest400();
}
JObject payload;
try { payload = JObject.Parse(Form("payload")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Reminder draft dopen: invalid payload JSON user={User}", UserAccountID);
return BadRequest400();
}
var session = _reminderDrafts.OpenFromPayload(payload, UserAccountID);
_logger.LogInformation("Reminder draft dopen: session {Token} (remId={RemId}) user={User}", session.Token, session.RemId, UserAccountID);
// 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).
return await JSONAsync(new { token = session.Token, version = session.Version });
}
// POST rem/dstate — { token } → full view state
private async Task<IActionResult> HandleReminderDraftState(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var session = _reminderDrafts.Get(Form("token"));
if (session == null) return DraftGone();
return await JSONAsync(_reminderDrafts.BuildState(session));
}
// POST rem/dpatch — { token, delta } → { ok, version }; signals draftReady
private async Task<IActionResult> HandleReminderDraftPatch(string fn, string id, string code)
{
if (!HasForm("token", "delta")) return BadRequest400();
ReminderDraftDelta? delta;
try { delta = JsonConvert.DeserializeObject<ReminderDraftDelta>(Form("delta")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Reminder draft dpatch: invalid delta JSON user={User}", UserAccountID);
return BadRequest400();
}
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
var session = _reminderDrafts.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 rem/dpreview — { token } → { img[], total } (rendered straight from the cache)
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = _reminderDrafts.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 rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
private async Task<IActionResult> HandleReminderDraftSave(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _reminderDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.RemId);
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdRem == null) return DraftGone();
if (string.IsNullOrEmpty(fdRem.Id))
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
var after = _reminderDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, remid = fdRem.Id });
}
// POST rem/dhistory — { token } → { history[] }
private async Task<IActionResult> HandleReminderDraftHistory(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
if (_reminderDrafts.Get(Form("token")) == null) return DraftGone();
var history = _reminderDrafts.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 rem/dclose — { token } → { ok }
private async Task<IActionResult> HandleReminderDraftClose(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
bool ok = _reminderDrafts.Close(Form("token"));
_logger.LogDebug("Reminder draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
return await JSONAsync(new { ok });
}
}
@@ -369,6 +369,21 @@ public partial class IntranetController
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
/// <summary>
/// Serves the PDF inline (browser shows it) while advertising the real download filename —
/// both a quoted ASCII form and RFC 5987 <c>filename*</c> for spaces/non-ASCII. Works around
/// the OCORE FileContentResult helper, whose classic-MVC <c>ExecuteResult(ControllerContext)</c>
/// never runs under ASP.NET Core, so the filename was dropped and downloads used the "idoc"
/// endpoint segment.
/// </summary>
private void SetInlinePdfFilename(string filename)
{
string safe = (filename ?? "").Replace("\"", "").Replace("\r", " ").Replace("\n", " ").Trim();
if (safe.Length == 0) return;
Response.Headers["Content-Disposition"] =
$"inline; filename=\"{safe}\"; filename*=UTF-8''{Uri.EscapeDataString(safe)}";
}
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); } if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
@@ -381,9 +396,13 @@ public partial class IntranetController
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return ct != null if (ct == null)
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true) return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
SetInlinePdfFilename(filename);
return File(ct, "application/pdf");
} }
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
+10 -1
View File
@@ -35,6 +35,9 @@ 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 IReminderDraftService _reminderDrafts;
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 +65,10 @@ 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,
IReminderDraftService reminderDrafts,
IDraftNotifier draftNotifier)
{ {
_intranet = intranet; _intranet = intranet;
_mfr = mfr; _mfr = mfr;
@@ -76,6 +82,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_invoices = invoices; _invoices = invoices;
_reminders = reminders; _reminders = reminders;
_events = events; _events = events;
_invoiceDrafts = invoiceDrafts;
_reminderDrafts = reminderDrafts;
_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>
+146
View File
@@ -0,0 +1,146 @@
---
status: Active
lastUpdated: 2026-07-10
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/Services/ReminderDraft*"
- "Fuchs/Services/IReminderDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/ReminderDraftSession.cs"
- "Fuchs/code/ReminderDraftCalculator.cs"
- "Fuchs/Notifications/DraftPreviewHub.cs"
- "Fuchs/Notifications/*DraftNotifier*"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
- "Fuchs/Controllers/IntranetController.ReminderDraft.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 were the pilot;
reminders now mirror the same design (see "Reminders" below).
## 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), `RecomputePositions`
(numbers every line except heading/free-text lines continuously across the whole invoice —
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
including after a reorder),
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
exhaustively unit-tested.
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then
renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change
history records the **changed field** (e.g. the new heading text), never the whole block JSON.
The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows
every position's price (set members are priced like standalone lines; only `setonly` collapses
them), so the PDF preview mirrors the online editor.
- **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.
## Reminders (Zahlungserinnerung)
Reminders mirror the same backend-authoritative model with a reminder-shaped session. A
reminder chases a single invoiced amount, so the machinery is simpler than an invoice's:
there are no line-item blocks, VAT grouping or reordering — just recipient fields and the
amount pair.
- **Endpoints** are `rem/d*` (`dopen`/`dstate`/`dpatch`/`dpreview`/`dsave`/`dhistory`/`dclose`),
dispatched from `Do_Process_Reminder`. Finalise + email still runs through the existing
`rem/conf` (`HandleReminderConf`), exactly as invoices finalise through `req/sconf`.
- **Session** (`ReminderDraftSession`) holds the editor's `new` (subject / invoiceaddress /
invoiceemail / text / amount / amount_payed / CustomValues) and `rem` (invid / type /
invoiceid / invoicedate) blocks, plus server-computed `Sums` (`AmountTotal`, `AmountPayed`,
`AmountOpen`). It reuses the shared `ChangeHistoryEntry`; validation uses
`ReminderDraftValidationMessage`.
- **Calculation** (`ReminderDraftCalculator`, static/pure): `AmountOpen = AmountTotal AmountPayed`,
plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested.
- **Deltas** (`ReminderDraftDelta`): scalar `email`/`address`/`subject`/`text` (HTML-sanitised via
the shared `InvoiceDraftEditService.HtmlToPlain`), the numeric `amount`/`amount_payed`
(normalised to an invariant decimal string), and `contact` (→ `CustomValues`).
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
groups serve invoice and reminder drafts alike.
- **Frontend** (`$inv.rd` in `fis.inv_shared.js`) opens/joins on `rem/dopen`, posts one delta per
inline edit and per item-row amount change, renders the open-amount footer + validation from the
server state, and previews/finalises through `rem/dpreview``rem/dsave``rem/conf`. It shares
the invoice editor DOM; `$inv.d` and `$inv.rd` each key off their own token, so the shared inline
editor safely no-ops for whichever mode is inactive.
### Reminder key files
- `Fuchs/code/ReminderDraftSession.cs` — session + `ReminderDraftSums` + `ReminderDraftValidationMessage`.
- `Fuchs/code/ReminderDraftCalculator.cs` — pure open-amount recompute + validate.
- `Fuchs/Services/ReminderDraftCache.cs` / `IReminderDraftCache.cs` — in-memory store + TTL.
- `Fuchs/Services/ReminderDraftEditService.cs` / `IReminderDraftService.cs` — orchestration + delta contract.
- `Fuchs/Services/ReminderDraftExpiryService.cs` — idle warn/evict monitor.
- `Fuchs/Controllers/IntranetController.ReminderDraft.cs``rem/d*` endpoints.
## 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.
+12
View File
@@ -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.
+12 -3
View File
@@ -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.
+1
View File
@@ -12,6 +12,7 @@ public enum DomainEventType
InvoiceFileCreationFailed, InvoiceFileCreationFailed,
InvoiceSendFailed, InvoiceSendFailed,
ReminderDraftCreated, ReminderDraftCreated,
ReminderDraftUpdated,
ReminderFileCreated, ReminderFileCreated,
ReminderSentToCustomer, ReminderSentToCustomer,
ReminderResentToCustomer, ReminderResentToCustomer,
+45
View File
@@ -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);
}
}
}
+31
View File
@@ -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);
}
+8
View File
@@ -76,6 +76,12 @@ public sealed class EventService : IEventService
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId) public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder))); => PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
public Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId)
{
var type = changed ? DomainEventType.ReminderDraftUpdated : DomainEventType.ReminderDraftCreated;
return PublishAsync(new DomainEvent(type, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
}
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId) public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
{ {
var ctx = ReminderContext(reminder); var ctx = ReminderContext(reminder);
@@ -176,6 +182,8 @@ public sealed class EventService : IEventService
Ctx(domainEvent, "message"), Ctx(domainEvent, "message"),
DomainEventType.ReminderDraftCreated => DomainEventType.ReminderDraftCreated =>
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.", $"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
DomainEventType.ReminderDraftUpdated =>
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde aktualisiert.",
DomainEventType.ReminderFileCreated => DomainEventType.ReminderFileCreated =>
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.", $"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
DomainEventType.ReminderSentToCustomer => DomainEventType.ReminderSentToCustomer =>
+20
View File
@@ -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);
}
+1
View File
@@ -13,6 +13,7 @@ public interface IEventService
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = ""); Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId); Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId);
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId); Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false); Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId); Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
+15
View File
@@ -110,6 +110,20 @@ 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>();
// Live, backend-authoritative reminder draft editing (ADR 0006) — the reminder
// mirror of the invoice draft services above, sharing the DraftPreviewHub/notifier.
builder.Services.AddSingleton<IReminderDraftCache, ReminderDraftCache>();
builder.Services.AddScoped<IReminderDraftService, ReminderDraftEditService>();
builder.Services.AddHostedService<ReminderDraftExpiryService>();
// 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 +198,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(
+26
View File
@@ -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; }
}
+75
View File
@@ -0,0 +1,75 @@
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") and expose the
/// change history. All totals/VAT are aggregated by <see cref="InvoiceDraftCalculator"/>
/// — the browser never sums.
///
/// Reload/discard is handled by the client (re-fetch the DB draft via the existing
/// <c>inv/get</c> render path and re-seed), so there is no server-side DB reshaping here.
/// </summary>
public interface IInvoiceDraftService
{
/// <summary>
/// Seeds a new cache session from the editor's assembled payload
/// (<c>admin</c> / <c>new</c> / <c>req</c> blocks, each block carrying the editor's
/// <c>itm</c>/<c>items</c> line arrays). Computes totals + validation and returns the
/// session (with its fresh token/version). An <c>invid</c> in the payload marks it as
/// an update of an existing DB draft.
/// </summary>
InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId);
/// <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-aggregates
/// 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>Removes the session from the cache (explicit close/discard/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", "block.replace"); <see cref="Ref"/> is the block
/// id it applies to (when relevant); <see cref="Value"/> is the new value (a scalar for
/// fields, or a full block object for <c>block.replace</c>).
/// </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();
}
+27
View File
@@ -0,0 +1,27 @@
using Fuchs.intranet;
namespace Fuchs.Services;
/// <summary>
/// In-memory store of live reminder draft editing sessions (see ADR 0006, mirroring
/// <see cref="IInvoiceDraftCache"/>). Singleton, single-instance only — scale-out would
/// need a distributed cache / sticky sessions (documented limitation). Keyed by the
/// session token.
/// </summary>
public interface IReminderDraftCache
{
/// <summary>Stores (or replaces) a session under its token.</summary>
void Set(ReminderDraftSession session);
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
ReminderDraftSession? Get(string token);
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
ReminderDraftSession? Remove(string token);
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
IReadOnlyList<ReminderDraftSession> Snapshot();
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
TimeSpan IdleTtl { get; }
}
+73
View File
@@ -0,0 +1,73 @@
using Fuchs.intranet;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Orchestrates a live, backend-authoritative reminder draft editing session (ADR 0006,
/// mirroring <see cref="IInvoiceDraftService"/>). Owns the lifecycle around a
/// <see cref="ReminderDraftSession"/>: open (seed the cache), apply single edits, build the
/// view state, render a PDF preview from the cache, flush to the DB ("Zwischenspeichern")
/// and expose the change history. The open amount is aggregated by
/// <see cref="ReminderDraftCalculator"/> — the browser never sums.
///
/// Reload/discard is handled by the client (re-fetch the DB draft / prep data via the
/// existing <c>rem/get</c> path and re-seed), so there is no server-side DB reshaping here.
/// </summary>
public interface IReminderDraftService
{
/// <summary>
/// Seeds a new cache session from the editor's assembled payload (<c>new</c> / <c>rem</c>
/// blocks). Computes the open amount + validation and returns the session (with its fresh
/// token/version). A <c>remid</c> in the payload marks it as an update of an existing DB draft.
/// </summary>
ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId);
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
ReminderDraftSession? Get(string token);
/// <summary>
/// Applies one editor change to the cached session: mutates the payload, re-aggregates the
/// open amount, re-validates, appends a history entry and bumps the version. Returns the
/// mutated session, or null if the token is unknown.
/// </summary>
ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta);
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
object BuildState(ReminderDraftSession 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 reminder registration path
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsReminderData?> 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>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token);
}
/// <summary>
/// A single editor change posted to <c>rem/dpatch</c>. <see cref="Target"/> names the
/// field/operation (e.g. "email", "subject", "amount"); <see cref="Ref"/> is reserved for
/// future per-item edits; <see cref="Value"/> is the new value (a scalar for fields, or a
/// small object for <c>contact</c>).
/// </summary>
public sealed class ReminderDraftDelta
{
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();
}
+60
View File
@@ -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();
}
+439
View File
@@ -0,0 +1,439 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
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, aggregates totals with <see cref="InvoiceDraftCalculator"/>, renders
/// previews and flushes to the DB by reusing the existing <see cref="IInvoiceService"/>
/// registration path — no new persistence. The session stores the editor's own block
/// shape (<c>itm</c>/<c>items</c> line arrays), which the PDF/persistence already consume,
/// so nothing is re-shaped server-side.
/// </summary>
public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
private readonly IInvoiceDraftCache _cache;
private readonly IInvoiceService _invoices;
private readonly ILogger<InvoiceDraftEditService> _logger;
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
ILogger<InvoiceDraftEditService> logger)
{
_cache = cache;
_invoices = invoices;
_logger = logger;
}
// ── 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 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 = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
/// and new value for the change history. Scalar text fields and the section heading are
/// sanitised from the editor's HTML (TinyMCE wraps inline edits in <c>&lt;p&gt;…&lt;/p&gt;</c>)
/// to plain text here — the backend is the single source of truth (ADR 0006), so no HTML ever
/// reaches the DB, the PDF or a reloaded draft, regardless of which UI path produced it.
/// </summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation":
oldValue = Str(s.New["provisionlocation"]);
newValue = HtmlToPlain(d.ValueString);
s.New["provisionlocation"] = newValue;
s.New["loc"] = newValue; // editor mirrors both
return true;
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue, ref newValue);
case "p13b":
oldValue = Str(s.Admin["p13b"]);
bool p13b = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = p13b;
newValue = p13b ? "§13b" : "";
return true;
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
default: return false;
}
}
private static bool SetNewText(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
newValue = HtmlToPlain(d.ValueString);
s.New[key] = newValue;
return true;
}
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.Admin[key]);
newValue = d.ValueString;
s.Admin[key] = newValue;
return true;
}
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
JObject cvo = (JObject)prev.DeepClone();
if (d.Value is JObject vo)
{
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
}
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
return true;
}
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject nb) return false;
SanitizeBlockText(nb);
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
var existing = FindBlock(s, bid);
if (existing != null)
{
oldValue = Str(existing["text"]);
existing.Replace(nb);
}
else
{
oldValue = "";
s.Req.Add(nb);
}
newValue = Str(nb["text"]); // the section heading — never the whole block JSON
return true;
}
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block["text"]);
newValue = "";
block.Remove();
return true;
}
/// <summary>
/// Reorders the service-request blocks to the id sequence the editor posts after a section
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
/// in their current relative order at the end. Totals are unaffected; item position numbers
/// are renumbered by <see cref="Refresh"/> and pushed back to the browser via the view state.
/// </summary>
private static bool ReorderBlocks(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JArray order) return false;
var current = s.Req.OfType<JObject>().ToList();
oldValue = string.Join(",", current.Select(b => Str(b["Id"])));
var byId = current.ToDictionary(b => Str(b["Id"]), b => b);
var ordered = new List<JObject>();
var seen = new HashSet<string>();
foreach (var idTok in order)
{
string id = Str(idTok);
if (byId.TryGetValue(id, out var blk) && seen.Add(id)) ordered.Add(blk);
}
foreach (var b in current) // append blocks the order list didn't mention, in place
if (seen.Add(Str(b["Id"]))) ordered.Add(b);
newValue = string.Join(",", ordered.Select(b => Str(b["Id"])));
if (oldValue == newValue) return false; // no-op reorder
s.Req.Clear();
foreach (var b in ordered) s.Req.Add(b);
return true;
}
/// <summary>Strips the editor's HTML from a block's heading (<c>text</c>/<c>nme</c>) before it is cached.</summary>
private static void SanitizeBlockText(JObject block)
{
if (block["text"] != null) block["text"] = HtmlToPlain(Str(block["text"]));
if (block["nme"] != null) block["nme"] = HtmlToPlain(Str(block["nme"]));
}
// ── View state / history ────────────────────────────────────────────────
public object BuildState(InvoiceDraftSession session)
{
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 ────────────────────────────────────────────────────────
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 bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(InvoiceDraftSession session)
{
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.RecomputePositions(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;
}
/// <summary>
/// Builds the <see cref="FdsInvoiceData"/> from the session — the server-side equivalent
/// of <c>invcPayload</c>. The session already holds the editor's <c>req</c> block shape
/// (<c>itm</c>/<c>items</c> line arrays) that registration and the PDF consume, so the
/// blocks pass through unchanged; only the header/total normalisation is applied.
/// </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);
}
// ── 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();
}
/// <summary>
/// Converts the editor's HTML (TinyMCE-wrapped inline edits, e.g. <c>&lt;p&gt;18.06.2026&lt;/p&gt;</c>)
/// to plain text: line-break-producing tags become newlines, remaining tags are stripped and
/// entities decoded. Multi-line fields (address, Leistungsort) keep their line breaks — the PDF
/// splits those on <c>\n</c>/<c>&lt;br&gt;</c> — while single-line fields collapse to one line.
/// Blank lines are removed so a stray <c>&lt;p&gt;&lt;/p&gt;</c> never becomes an empty row.
/// </summary>
internal static string HtmlToPlain(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "";
if (raw.IndexOf('<') < 0 && raw.IndexOf('&') < 0) return raw.Trim();
// Turn line-break / block-close tags into newlines before stripping the rest.
string s = Regex.Replace(raw, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
s = Regex.Replace(s, @"</\s*(p|div|li|tr|h[1-6])\s*>", "\n", RegexOptions.IgnoreCase);
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(s);
string text = System.Net.WebUtility.HtmlDecode(doc.DocumentNode.InnerText);
var lines = text.Replace("\r\n", "\n").Replace('\r', '\n')
.Split('\n')
.Select(l => l.Trim())
.Where(l => l.Length > 0);
return string.Join("\n", lines).Trim();
}
}
@@ -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);
}
}
}
}
+56 -2
View File
@@ -63,10 +63,64 @@ public class InvoiceService : IInvoiceService
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary()); inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true; inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft); // Reconstruct the service-request blocks + line items so the PDF renders the positions.
// Without this the reloaded/finalized invoice showed an empty item table (only the header
// + totals came from InvoiceRegistration), i.e. it did not match the cached preview.
inv.Req = BuildPdfRequestBlocks(dset);
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft} blocks={Blocks}", inv.Id, inv.IsDraft, inv.Req?.Count ?? 0);
return inv; return inv;
} }
/// <summary>
/// Rebuilds the invoice's service-request groups and line items from the persisted
/// <c>req</c>/<c>itm</c> tables (<c>fds__getInvoice</c>) into the exact block shape the PDF
/// consumes (<see cref="FdsInvoiceData.InvoiceBlocks"/> → item contract
/// <c>type/title/desc/qty/price_net/total_net</c>). Item order follows the persisted
/// <c>SortOrder</c>, so a reordered draft renders in its saved order — making the finalized
/// PDF and the re-downloaded (<c>idoc</c>) document identical to the cached preview.
/// </summary>
private static List<Dictionary<string, object>> BuildPdfRequestBlocks(SQLDataSet dset)
{
var blocks = new List<Dictionary<string, object>>();
if (!dset.Contains("req")) return blocks;
var reqTable = dset.Tables("req");
string reqSort = reqTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow rq in reqTable.Select("", reqSort))
{
var rdic = rq.toObjectDictionary();
var items = new List<Dictionary<string, object?>>();
if (dset.Contains("itm"))
{
var itmTable = dset.Tables("itm");
string itmSort = itmTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow it in itmTable.Select($"[InvRqId] = '{rdic.nz("Id")}'", itmSort))
{
var d = it.toObjectDictionary();
// The persisted "Text" holds the item's rendered HTML (the editor's co.t); it is
// the full title+description, so it maps to the contract's desc (title stays empty).
items.Add(new Dictionary<string, object?>
{
["id"] = d.nz("mfr__item"),
["type"] = d.nz("Type"),
["title"] = "",
["desc"] = d.nz("Text"),
["qty"] = d.nz("Quantity"),
["price_net"] = d.no("value", 0),
["total_net"] = d.no("value_total", 0)
});
}
}
blocks.Add(new Dictionary<string, object>
{
["Id"] = rdic.nz("mfr__servicerequest"),
["text"] = System.Net.WebUtility.HtmlDecode(rdic.nz("title")),
["items"] = items
});
}
return blocks;
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec) string userAccountId, DatabaseSecurity dbSec)
{ {
@@ -212,7 +266,7 @@ public class InvoiceService : IInvoiceService
var reg = invoice.InvoiceRegistration; var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks var tb = new FuchsPdf.FdsTextBlocks
{ {
AdminRef = reg?.getString("Id") ?? "", AdminRef = (reg?.getString("InvoiceId") ?? "").ne(reg?.getString("Id") ?? ""),
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray() ? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
: Array.Empty<string>(), : Array.Empty<string>(),
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Concurrent;
using Fuchs.intranet;
using Microsoft.Extensions.Configuration;
namespace Fuchs.Services;
/// <summary>
/// Single-instance, in-memory implementation of <see cref="IReminderDraftCache"/> backed
/// by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session token — the
/// reminder mirror of <see cref="InvoiceDraftCache"/>. A plain dictionary (rather than
/// <c>IMemoryCache</c>) is used on purpose: the <see cref="ReminderDraftExpiryService"/>
/// 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 shared with invoices under
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
/// </summary>
public sealed class ReminderDraftCache : IReminderDraftCache
{
private readonly ConcurrentDictionary<string, ReminderDraftSession> _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 ReminderDraftCache(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(ReminderDraftSession session)
{
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
session.Touch();
_sessions[session.Token] = session;
}
public ReminderDraftSession? 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 ReminderDraftSession? Remove(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return _sessions.TryRemove(token, out var s) ? s : null;
}
public IReadOnlyList<ReminderDraftSession> Snapshot() => _sessions.Values.ToList();
}
+275
View File
@@ -0,0 +1,275 @@
using System.Globalization;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Services;
/// <summary>
/// Backend-authoritative reminder draft editing (ADR 0006) — the reminder mirror of
/// <see cref="InvoiceDraftEditService"/>. Holds the truth in a
/// <see cref="ReminderDraftSession"/> (via <see cref="IReminderDraftCache"/>), applies
/// single edits, aggregates the open amount with <see cref="ReminderDraftCalculator"/>,
/// renders previews and flushes to the DB by reusing the existing
/// <see cref="IReminderService"/> registration path — no new persistence. The session
/// stores the editor's own block shape (<c>new</c>/<c>rem</c>), which the PDF/persistence
/// already consume, so nothing is re-shaped server-side.
/// </summary>
public sealed class ReminderDraftEditService : IReminderDraftService
{
private readonly IReminderDraftCache _cache;
private readonly IReminderService _reminders;
private readonly ILogger<ReminderDraftEditService> _logger;
public ReminderDraftEditService(IReminderDraftCache cache, IReminderService reminders,
ILogger<ReminderDraftEditService> logger)
{
_cache = cache;
_reminders = reminders;
_logger = logger;
}
// ── Open ─────────────────────────────────────────────────────────────────
public ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId)
{
var session = new ReminderDraftSession
{
Token = NewToken(),
UserAccountId = userAccountId,
RemId = payload["remid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
};
session.New = payload["new"] as JObject ?? new JObject();
session.Rem = payload["rem"] as JObject ?? new JObject();
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Reminder draft session {Token} opened from payload (remId={RemId}, user={User})",
session.Token, session.RemId, userAccountId);
return session;
}
public ReminderDraftSession? Get(string token) => _cache.Get(token);
// ── Patch ──────────────────────────────────────────────────────────────────
public ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta)
{
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Reminder 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 = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
/// and new value for the change history. Scalar text fields are sanitised from the editor's
/// HTML (TinyMCE wraps inline edits in <c>&lt;p&gt;…&lt;/p&gt;</c>) to plain text (via
/// <see cref="InvoiceDraftEditService.HtmlToPlain"/>) — the backend is the single source of
/// truth (ADR 0006), so no HTML ever reaches the DB or the PDF.
/// </summary>
private static bool ApplyDelta(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "subject": return SetNewText(s, "subject", d, ref oldValue, ref newValue);
case "text": return SetNewText(s, "text", d, ref oldValue, ref newValue);
case "amount": return SetNewNumber(s, "amount", d, ref oldValue, ref newValue);
case "amount_payed": return SetNewNumber(s, "amount_payed", d, ref oldValue, ref newValue);
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
default: return false;
}
}
private static bool SetNewText(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
newValue = InvoiceDraftEditService.HtmlToPlain(d.ValueString);
s.New[key] = newValue;
return true;
}
/// <summary>Stores a numeric field, normalising German/invariant input to an invariant decimal string.</summary>
private static bool SetNewNumber(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
decimal parsed = ReminderDraftCalculator.Dec(d.Value ?? JValue.CreateString(InvoiceDraftEditService.HtmlToPlain(d.ValueString)));
newValue = parsed.ToString(CultureInfo.InvariantCulture);
s.New[key] = newValue;
return true;
}
private static bool SetContact(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
JObject cvo = (JObject)prev.DeepClone();
if (d.Value is JObject vo)
{
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
}
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
return true;
}
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
// ── View state / history ────────────────────────────────────────────────
public object BuildState(ReminderDraftSession session)
{
session.Touch();
return new
{
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
{
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
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 ────────────────────────────────────────────────────────
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
bool change = !string.IsNullOrEmpty(session.RemId);
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (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 = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true);
}
public bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(ReminderDraftSession session)
{
ReminderDraftCalculator.RecomputeTotals(session);
ReminderDraftCalculator.Validate(session);
}
private static string NewToken() => Guid.NewGuid().ToString("N");
/// <summary>
/// Builds the <see cref="FdsReminderData"/> from the session — the server-side equivalent of
/// the editor's <c>remc</c> payload. The session already holds the editor's <c>new</c>/<c>rem</c>
/// shape that registration consumes, so the blocks pass through unchanged.
/// </summary>
private static FdsReminderData BuildReminderData(ReminderDraftSession session)
{
var jobj = new JObject
{
["new"] = session.New.DeepClone(),
["rem"] = session.Rem.DeepClone()
};
return new FdsReminderData(jobj);
}
/// <summary>
/// Synthesises the <c>ReminderRegistration</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__getReminder</c>/<c>fds__createReminder</c> would return for a
/// draft, including the single-invoice <c>invoices</c> row the reminder table renders.
/// </summary>
private static GenericObjectDictionary SynthesizeRegistration(ReminderDraftSession session)
{
string invoiceId = Str(session.Rem["invoiceid"]).ne(Str(session.Rem["InvoiceId"]));
var invoices = new JArray
{
new JObject
{
["InvoiceDate"] = Str(session.Rem["invoicedate"]),
["DocumentName"] = "",
["InvoiceTitle"] = string.IsNullOrEmpty(invoiceId) ? "" : $"Rechnung {invoiceId}",
["InvoiceBalance"] = session.Sums.AmountTotal,
["amount_open"] = session.Sums.AmountOpen
}
};
var d = new Dictionary<string, object>
{
["Id"] = session.RemId,
["type"] = Str(session.Rem["type"]).ne("R"),
["subject"] = Str(session.New["subject"]),
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["InvoiceId"] = invoiceId,
["amount_open"] = session.Sums.AmountOpen,
["PaymentTerm"] = Str(session.Rem["paymentterm"]),
["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
return new GenericObjectDictionary(d);
}
// ── 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 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,69 @@
using Fuchs.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
/// <summary>
/// Background monitor for the reminder draft cache (ADR 0006) — the reminder mirror of
/// <see cref="InvoiceDraftExpiryService"/>. 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 shared <see cref="DraftPreviewHub"/>
/// via <see cref="IDraftNotifier"/> (the token-keyed groups serve invoices and reminders alike).
/// </summary>
public sealed class ReminderDraftExpiryService : BackgroundService
{
private readonly IReminderDraftCache _cache;
private readonly IDraftNotifier _notifier;
private readonly ILogger<ReminderDraftExpiryService> _logger;
private readonly TimeSpan _warnLead;
private readonly TimeSpan _interval;
public ReminderDraftExpiryService(IReminderDraftCache cache, IDraftNotifier notifier,
IConfiguration configuration, ILogger<ReminderDraftExpiryService> 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("Reminder 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);
}
}
}
}
+3
View File
@@ -26,6 +26,9 @@
"CheckMfr": false, "CheckMfr": false,
"CheckPdfLicense": true "CheckPdfLicense": true
}, },
"Mailer": {
"Enabled": true
},
"Email": { "Email": {
"OverrideRecipient": "service@emails.processweb.de" "OverrideRecipient": "service@emails.processweb.de"
}, },
+31 -2
View File
@@ -19,7 +19,7 @@ public class FdsInvoiceData
public GenericObjectDictionary? Admin { get; private set; } public GenericObjectDictionary? Admin { get; private set; }
public GenericObjectDictionary? NewValues { get; private set; } public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Sms { get; private set; } public GenericObjectDictionary? Sms { get; private set; }
public List<Dictionary<string, object>>? Req { get; private set; } public List<Dictionary<string, object>>? Req { get; internal set; }
public GenericObjectDictionary? InvoiceRegistration { get; internal set; } public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
public bool IsDraft { get; internal set; } = true; public bool IsDraft { get; internal set; } = true;
@@ -46,9 +46,25 @@ public class FdsInvoiceData
get get
{ {
var result = new List<Dictionary<string, object?>>(); var result = new List<Dictionary<string, object?>>();
foreach (var block in InvoiceBlocks) result.AddRange(block.Items);
return result;
}
}
/// <summary>
/// The service-request groups as they should render on the invoice: each block carries its
/// heading (the section title the editor shows) and its line items. The PDF renders a heading
/// row per block followed by that block's items, so the online editor and the PDF stay in sync.
/// </summary>
public List<InvoiceBlock> InvoiceBlocks
{
get
{
var result = new List<InvoiceBlock>();
if (Req == null) return result; if (Req == null) return result;
foreach (var req in Req) foreach (var req in Req)
{ {
var items = new List<Dictionary<string, object?>>();
if (req.TryGetValue("items", out var itmsObj)) if (req.TryGetValue("items", out var itmsObj))
{ {
IEnumerable<Dictionary<string, object?>>? itms = IEnumerable<Dictionary<string, object?>>? itms =
@@ -56,8 +72,12 @@ public class FdsInvoiceData
?? (itmsObj is JArray ja ?? (itmsObj is JArray ja
? ja.ToObject<List<Dictionary<string, object?>>>() ? ja.ToObject<List<Dictionary<string, object?>>>()
: null); : null);
if (itms != null) result.AddRange(itms); if (itms != null) items.AddRange(itms);
} }
string heading = "";
if (req.TryGetValue("text", out var th) && th != null) heading = th.ToString() ?? "";
if (heading.Length == 0 && req.TryGetValue("nme", out var nh) && nh != null) heading = nh.ToString() ?? "";
result.Add(new InvoiceBlock { Heading = heading, Items = items });
} }
return result; return result;
} }
@@ -196,3 +216,12 @@ public class FdsInvoiceData
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0; return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
} }
} }
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
public sealed class InvoiceBlock
{
/// <summary>The section heading (editor's <c>text</c>/<c>nme</c>); empty when the group has none.</summary>
public string Heading { get; init; } = "";
/// <summary>The group's line items (the editor's <c>items</c> contract).</summary>
public List<Dictionary<string, object?>> Items { get; init; } = new();
}
+65 -22
View File
@@ -137,6 +137,30 @@ public static class FuchsPdf
public static string TranslatePaymentTerm(string pt) => public static string TranslatePaymentTerm(string pt) =>
pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen"); pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
/// <summary>
/// Maps one editor item-contract entry to a display line, mirroring the online editor: the
/// item's own price/total is shown, a set header is emphasised, and free-text/heading lines
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
/// </summary>
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
{
string type = i.nz("type", "").ToLowerInvariant();
bool isText = type is "text" or "title";
ParseDec(i.no("price_net", 0), out decimal price);
ParseDec(i.no("total_net", 0), out decimal total);
return new InvoiceSetLine
{
Title = i.nz("title", ""),
Desc = i.nz("desc", ""),
Qty = i.nz("qty", ""),
PriceNet = price,
TotalNet = total,
ShowPrice = !isText,
IsSetHeader = type == "set"
};
}
/// <summary> /// <summary>
/// Parses a numeric value coming from JSON deserialization (long/double), SQL /// Parses a numeric value coming from JSON deserialization (long/double), SQL
/// (decimal), or an already-invariant numeric string. Numeric CLR types are /// (decimal), or an already-invariant numeric string. Numeric CLR types are
@@ -593,30 +617,49 @@ public static class FuchsPdf
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left; hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
} }
// Data rows — resolved through the set-display mode (see InvoiceSetPricing). // Data rows — grouped by service-request block (see FdsInvoiceData.InvoiceBlocks).
// For invoices without sets this passes items through unchanged; for sets it // Per the product decision the PDF must mirror the online editor exactly: each section
// emits set header + members per the chosen mode, blanking price cells where // prints its heading, every position shows its own price, and positions are numbered the
// a line should show no price. Totals come from the registration balance, so // same way the editor numbers them (every line except free-text/heading lines, including a
// the mode is purely presentational. // set header). Set-display collapsing is honoured only for the explicit SetOnly mode; every
// other mode renders the items flat, so nothing is silently blanked or renumbered.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions")); var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 1; int pos = 0;
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode)) foreach (var block in inv.InvoiceBlocks)
{ {
var row = tbl.AddRow(); if (!string.IsNullOrWhiteSpace(block.Heading))
row.HeightRule = RowHeightRule.Auto; {
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base"; var hr = tbl.AddRow();
var titleCell = row.Cells[1].AddParagraph(); hr.HeightRule = RowHeightRule.Auto;
titleCell.Style = "TblCell_RTitle"; hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold); hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
else titleCell.AddText(line.Title); }
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base"; var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base"; ? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum"; : block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right; foreach (var line in lines)
row.Cells[4].Format.Alignment = ParagraphAlignment.Right; {
pos++; bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
{
var titleCell = row.Cells[1].AddParagraph();
titleCell.Style = "TblCell_RTitle";
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
else titleCell.AddText(line.Title);
}
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
}
} }
// Totals // Totals
+177
View File
@@ -0,0 +1,177 @@
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, pure aggregation of an invoice draft's totals/VAT — the authoritative
/// replacement for the browser's <c>invSumUpdate</c> footer math (ADR 0006). The user's
/// requirement is that the <b>sums</b> live in the backend cache, not the frontend.
///
/// It reads each block's persisted line contract (<c>block.itm</c> = the editor's <c>co</c>
/// objects: <c>vt</c>=net, <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT,
/// <c>vat</c>=rate) — exactly the shape the editor already posts and the PDF/persistence
/// already consume — so no line data is re-derived or re-shaped. It then applies the §13b
/// reverse-charge rule and validates. Static/pure, hence exhaustively unit-testable.
/// </summary>
public static class InvoiceDraftCalculator
{
/// <summary>
/// Aggregates every block's line values into the draft's totals — the port of
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
/// VAT is grouped by the line's rate string (matching the editor's <c>sms.vat</c> map).
/// </summary>
public static void RecomputeTotals(InvoiceDraftSession session)
{
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["itm"] is JArray lines)
{
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
decimal netVal = Dec(co["vt"]);
decimal vatVal = Dec(co["vv"]);
decimal svcNet = Dec(co["vs"]);
decimal svcVat = Dec(co["vsv"]);
sums.ServiceNet += svcNet;
sums.ServiceVat += svcVat;
sums.TotalNet += netVal;
sums.TotalVat += vatVal;
sums.TotalGross += netVal + vatVal;
blockNet += netVal;
string rate = NormalizeRate(Str(co["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>
/// Renumbers the visible line positions authoritatively (the port of the client-side
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
/// keeps the online editor and the PDF preview showing the same position numbers, including
/// after a reorder.
/// </summary>
public static void RecomputePositions(InvoiceDraftSession session)
{
int pos = 0;
foreach (var blockTok in session.Req)
{
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
}
}
}
/// <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));
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.");
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
if (!HasAnyItem(session))
Add("items", "error", "Die Rechnung enthält keine Positionen.");
if (!Flag(session.Admin, "p13b"))
foreach (var rate in session.Sums.VatByRate.Keys)
if (!IsKnownVatRate(rate))
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
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["itm"] is JArray lines && lines.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.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
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";
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;
}
}
+111
View File
@@ -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; }
}
+92
View File
@@ -0,0 +1,92 @@
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, pure aggregation of a reminder draft's open amount — the authoritative
/// replacement for the browser's inline figure (ADR 0006, mirroring
/// <see cref="InvoiceDraftCalculator"/>). The user's requirement is that the computed
/// figure lives in the backend cache, not the frontend.
///
/// A reminder chases a single invoiced amount: <c>AmountOpen = AmountTotal - AmountPayed</c>
/// (both read from the editor's <c>new</c> block). Static/pure, hence exhaustively
/// unit-testable.
/// </summary>
public static class ReminderDraftCalculator
{
/// <summary>Recomputes the reminder's open amount from the edited <c>amount</c> / <c>amount_payed</c>.</summary>
public static void RecomputeTotals(ReminderDraftSession session)
{
decimal total = Dec(session.New["amount"]);
decimal payed = Dec(session.New["amount_payed"]);
session.Sums = new ReminderDraftSums
{
AmountTotal = total,
AmountPayed = payed,
AmountOpen = total - payed
};
}
/// <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(ReminderDraftSession session)
{
session.ValidationMessages.Clear();
void Add(string field, string sev, string msg) =>
session.ValidationMessages.Add(new ReminderDraftValidationMessage(field, sev, msg));
string email = Str(session.New["invoiceemail"]).Trim();
if (email.Length == 0)
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Mahnung kann nicht per E-Mail versandt werden.");
else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Anschrift hinterlegt.");
if (Str(session.New["subject"]).Trim().Length == 0)
Add("subject", "warning", "Es ist kein Betreff hinterlegt.");
if (session.Sums.AmountOpen <= 0)
Add("amount", "warning", "Der offene Betrag ist null oder negativ — es besteht keine offene Forderung.");
}
// ── helpers ──────────────────────────────────────────────────────────────
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50" / "1.234,56") and invariant ("12.50") strings.</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 ParseAmount(Str(token));
}
/// <summary>
/// Parses a currency string, resolving the German/invariant ambiguity: a value with both
/// separators treats "." as thousands and "," as decimal ("1.234,56"); a value with only ","
/// treats it as the decimal separator ("12,50"); otherwise it is parsed invariant ("1234.56").
/// </summary>
internal static decimal ParseAmount(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 0) return 0;
bool hasComma = s.Contains(','), hasDot = s.Contains('.');
if (hasComma && hasDot) s = s.Replace(".", "").Replace(',', '.'); // German "1.234,56"
else if (hasComma) s = s.Replace(',', '.'); // German "12,50"
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
}
private static string Str(JToken? token) =>
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
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;
}
}
+81
View File
@@ -0,0 +1,81 @@
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, in-memory editing state for a single reminder (Zahlungserinnerung)
/// draft — the authoritative source of truth while a back-office user edits a draft in
/// the browser. This mirrors <see cref="InvoiceDraftSession"/> for reminders (ADR 0006):
/// the browser is a pure view/input layer that posts single changes
/// (<see cref="Fuchs.Services.ReminderDraftDelta"/>); the server mutates this session,
/// recomputes the open amount 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.IReminderDraftService"/> (mirroring the
/// <see cref="FdsReminderData"/> / <see cref="Fuchs.Services.IReminderService"/> split).
/// The editable payload is kept as the exact JSON shape the editor already speaks
/// (<c>new</c> / <c>rem</c>), so flushing to the DB can reuse
/// <see cref="Fuchs.Services.IReminderService.RegisterReminderAsync"/> unchanged.
/// The change-history record type (<see cref="ChangeHistoryEntry"/>) is shared with the
/// invoice draft; validation messages use the reminder-specific
/// <see cref="ReminderDraftValidationMessage"/>.
/// </summary>
public sealed class ReminderDraftSession
{
/// <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 reminder id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
public string RemId { 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>Recipient/new fields: subject, invoiceaddress, invoiceemail, text, amount, amount_payed, CustomValues…</summary>
public JObject New { get; set; } = new();
/// <summary>Reference fields: invid, type, level, invoiceid, invoicedate, sender…</summary>
public JObject Rem { get; set; } = new();
// ── Computed (by the draft service; never trusted from the client) ───────
/// <summary>Server-computed open-amount aggregation — the values the client used to compute inline.</summary>
public ReminderDraftSums Sums { get; set; } = new();
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
public List<ReminderDraftValidationMessage> 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 reminder totals — the authoritative open-amount for the draft.</summary>
public sealed class ReminderDraftSums
{
/// <summary>Invoiced amount (gross) the reminder chases.</summary>
public decimal AmountTotal { get; set; }
/// <summary>Amount already paid against the invoice.</summary>
public decimal AmountPayed { get; set; }
/// <summary>Still-open amount (<c>AmountTotal - AmountPayed</c>) — the reminder's headline figure.</summary>
public decimal AmountOpen { get; set; }
}
/// <summary>A single plausibility/consistency finding for the reminder draft.</summary>
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "amount").</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 ReminderDraftValidationMessage(string Field, string Severity, string Message);
+62
View File
@@ -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
View File
@@ -1,4 +1,5 @@
$(document).ready(function () { $(document).ready(function () {
$fis.notifications.init(); $fis.notifications.init();
$fis.draft.init();
$fis.ov(); $fis.ov();
}); });
+419 -126
View File
@@ -99,11 +99,375 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
}
if (booln(r, false) === true) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); m.push({ lbl: $ict.rel, fnc: $inv.rReload });
} }
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */ return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
}; };
/* Backend-authoritative draft editing controller (ADR 0006/0007)
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
validation from the authoritative server state. It is invoice-only: reminders
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
so they keep the legacy stateless flow untouched. */
$inv.d = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
hashes: function () {
let bai = $inv.d.tbl().data('bai') || [], h = {};
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
$fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s),
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
});
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dstate'), data: { token: t }, success: (state) => {
$inv.d.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } },
complete: () => { $inv.d.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* Push the server's authoritative position numbers back onto the rendered rows so the online
editor and the PDF preview always agree (the server numbers priced lines continuously; the
browser must not keep its own numbering). Only the position cell is touched no re-render. */
applyPositions: function (tbl, req) {
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
if (!co || (co.id || '') === '') { return; }
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
}));
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
$inv.d.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
});
},
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
tbl.data('dhashes', next).data('dorder', order);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
/* Render the totals footer from the server sums (port of invSumUpdate's footer half). */
footer: function (tbl, sums, admin) {
let ft = tbl.children('tfoot').empty(); tbl.nextAll('.fnote').remove();
let p13b = bool(admin.p13b, false);
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst));
let fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
rwcy('Netto', sums.total_net || 0);
if (p13b === false) { $.each(sums.vat || {}, (rate, amt) => rwcy($rct.vat + ' ' + rate + '%', amt, 'tvat')); }
rwcy('Summe', sums.total_gross || 0);
let itype = (admin.type || '');
if (itype === 'i') { fn($rct.note2); fn($rct.note4); }
else if (itype === 'c') { fn($rct.note2); }
else {
fn(string($rct.note3, [fnum(((sums.service_net || 0) + (sums.service_vat || 0)) * (admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum((sums.service_net || 0) + (sums.service_vat || 0), $rct.cst), fnum(sums.service_net || 0, $rct.cst), fnum(sums.service_vat || 0, $rct.cst)]));
}
if (p13b === true) { fn($rct.note13b); }
},
validation: function (msgs) {
let frm = $('div.invoice_layout'); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise, cancel = discard. */
preview: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout();
let email = ($inv.d.tbl().data('new') || {}).invoiceemail || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (r) => { $inv.d.tbl().data('invid', r.invid); },
error: () => { alert($ict.eis); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
discard: function () {
let invid = $inv.d.tbl().data('invid') || '';
if (invid === '') { alert('Es wurde noch kein Zwischenstand gespeichert, der wiederhergestellt werden könnte.'); return; }
if (confirm('Alle Änderungen verwerfen und den zuletzt gespeicherten Stand neu laden?') === false) { return; }
$inv.d.close();
$inv.cntInv({ id: invid });
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Rechnungsentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.d.token();
$inv.d.tbl().removeData('dtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Rechnungsentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Rechnungsentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.d.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('inv/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.d.tbl().removeData('dtoken');
}
};
/* Backend-authoritative reminder draft editing (ADR 0006/0007)
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
editor safely no-ops for the mode that is not active. */
$inv.rd = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
/* Seed the authoritative server session from the assembled reminder editor payload. */
seed: function (payload) {
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
$fis.draft.bind(r.token, {
onReady: () => $inv.rd.refresh(),
onExpiring: (s) => $inv.rd.warnExpiry(s),
onClosed: (reason) => $inv.rd.closed(reason)
});
$inv.rd.refresh();
}, error: () => { l.rC('freeze'); }
});
},
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
refresh: function (cb) {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
complete: () => { $inv.rd.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
$inv.rd.footer(tbl, state.sums || {});
$inv.rd.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.rd.token(); if (t === '') { return; }
$inv.rd.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.rd.refresh(); },
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
});
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.rd.token() === '') { return; }
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
let target = map[nme]; if (!target) { return; }
$inv.rd.sync({ Target: target, Value: val });
},
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
syncAmount: function (amount, amount_payed) {
if ($inv.rd.token() === '') { return; }
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
},
/* Render the open-amount footer from the server sums. */
footer: function (tbl, sums) {
let ft = tbl.children('tfoot').empty();
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
},
validation: function (msgs) {
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
preview: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout();
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
$inv.rd.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.rd.token();
$inv.rd.tbl().removeData('rdtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.rd.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.rd.tbl().removeData('rdtoken');
}
};
$inv.cInv2 = function (data) { $inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1); let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 }); let o = $ocms.dlg(fr, { width: 1000 });
@@ -481,9 +845,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -499,6 +866,11 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') { if (typeof change === 'function') {
change(response.txt); change(response.txt);
} }
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
draft token is present, so only the active mode's session receives the delta. */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
}, },
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true } tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
} }
@@ -584,7 +956,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
@@ -713,6 +1089,13 @@ $inv.invSumUpdate = function () {
} }
tbl.data('sms', sms); tbl.data('sms', sms);
tbl.data('bai', ba); tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
model to the server session and switch to server-driven totals from then on. */
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
tbl.data('dseeding', true);
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
}
}; };
$inv.worknotes = function (rx) { $inv.worknotes = function (rx) {
let wn = ''; let wn = '';
@@ -800,7 +1183,13 @@ $inv.rendersrq = function () {
$$.tdc('currency isumval', istr); $$.tdc('currency isumval', istr);
} }
}; };
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); }; $inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
};
$inv.sedit = () => { $inv.sedit = () => {
$inv.sprev(true); $inv.sprev(true);
}; };
@@ -855,6 +1244,7 @@ $inv.sp13b = () => {
} else { } else {
} }
tbl.trigger('fds.inv'); tbl.trigger('fds.inv');
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
}; };
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing /* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net, / FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -909,6 +1299,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0); let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); } if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(','); d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
}; };
$inv.sctp = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
@@ -925,6 +1316,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email; cvo.contactEmail = response.email;
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
l.find('.ctpfrm').text(ne(response.name, response.email)); l.find('.ctpfrm').text(ne(response.name, response.email));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true }, typedvalues: true
}); });
@@ -949,82 +1341,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId); adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw }; return { admin: adm, req: d.bai, sms: d.sms, new: nw };
}; };
$inv.ssave = () => { /* Zwischenspeichern and preview now run against the backend-authoritative session
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); (ADR 0006): no full-invoice re-upload the cached draft is flushed / rendered by
$inv.t_fds_inv(); token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
l.aC('freeze'); unchanged and is invoked from the preview modal's confirm handler. */
$ocms.postXT({ $inv.ssave = () => { $inv.d.save(); };
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => { $inv.sprev = (change) => { $inv.d.preview(); };
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.sprev = (change) => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
change = bool(change, false);
$inv.t_fds_inv();
l.aC('freeze');
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
}
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
}
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('req/sdel'), data: {
id: invid
}
});
}
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.rReload = () => { $inv.rReload = () => {
try { try {
let s = $('#listframe ul.rql:first').data(); let s = $('#listframe ul.rql:first').data();
@@ -1203,6 +1525,8 @@ $inv.eRowR = function (ev) {
$.extend(tdta.rm, res); $.extend(tdta.rm, res);
tbl.data(tdta); tbl.data(tdta);
$inv.rRemRw.call(row, tdta); $inv.rRemRw.call(row, tdta);
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
}, typedvalues: true }, typedvalues: true
}); });
}; };
@@ -1243,57 +1567,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
the backend owns the open-amount computation and validation; inline edits and the
item-row dialog post single deltas (see $inv.rd). */
let nw = rif.tbl.data('new');
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
$inv.rd.seed({
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
new: nw
});
}, complete: () => { }, complete: () => {
//o.c.trigger('modal_close'); //o.c.trigger('modal_close');
} }
}); });
}; };
$inv.rprev = () => { $inv.rprev = () => {
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(); /* Preview + finalise now run through the backend-authoritative session ($inv.rd):
$.extend(d.new, tbl.find('tbody > tr:first').data()); the PDF renders straight from the server cache (no rem/prep DB write), and confirm
l.aC('freeze'); flushes (rem/dsave) then finalises + emails (rem/conf). */
//console.debug({ rem: d.rm, new: d.new }); $inv.rd.preview();
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
let ct = $(this);
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('rem/del'), data: {
id: remid
}
});
}
$inv.rReload();
}
});
}
});
}; };
$inv.sis = (id) => { $inv.sis = (id) => {
if (confirm($ict.sisc)) { if (confirm($ict.sisc)) {
@@ -399,6 +399,33 @@
} }
} }
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
rendered above the editor, and the change-history dialog table. */
.edit_frm .invoice_layout .dvalidation {
margin: 0 0 1rem 0;
&.hidden { display: none; }
.dvmsg {
padding: 0.4rem 0.7rem;
margin: 0.25rem 0;
border-radius: 0.2rem;
font-size: 0.9rem;
border-left: 4px solid #999;
background: #f5f5f5;
&.error { border-left-color: #c0392b; background: #fdecea; color: #922; }
&.warning { border-left-color: #e0a800; background: #fff8e1; color: #7a5c00; }
&.info { border-left-color: #3498db; background: #eaf4fb; color: #1c5a82; }
}
}
.dhist {
.invtbl { width: 100%; }
td, th { padding: 0.3rem 0.6rem; text-align: left; vertical-align: top; }
tbody tr:nth-child(even) { background: #fafafa; }
}
.modal-body .lstfrm { .modal-body .lstfrm {
display: block; display: block;
position: relative; position: relative;
+10 -2
View File
@@ -63,7 +63,8 @@ if (!Element.prototype.closest) {
this._dragging = false; this._dragging = false;
this._dragHandleClass = this._options.dragHandleClass || ''; this._dragHandleClass = this._options.dragHandleClass || '';
this._parentident = this._options.parentident || ''; this._parentident = this._options.parentident || '';
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null; this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
this._container.setAttribute("data-is-sortable", 1); this._container.setAttribute("data-is-sortable", 1);
this._container.classList.add("sortable"); this._container.classList.add("sortable");
@@ -213,10 +214,17 @@ if (!Element.prototype.closest) {
} }
}, },
// on item release/drop // on item release/drop
_onRelease: function (e) { _onRelease: function (e) {
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
var wasDragging = this._dragging === true && this._clickItem !== null;
this._dragging = false; this._dragging = false;
this._trashDragItem(); this._trashDragItem();
// Fire a single "drag finished" callback so callers can commit the new order once,
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
if (wasDragging && typeof this._onend === 'function') {
this._onend();
}
}, },
// on item drag/move // on item drag/move
+44
View File
@@ -405,6 +405,50 @@ table.if td.num {
animation: fis_spin 0.8s linear infinite; animation: fis_spin 0.8s linear infinite;
} }
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
rendered above the editor, and the change-history dialog table. */
.edit_frm .invoice_layout .dvalidation {
margin: 0 0 1rem 0;
}
.edit_frm .invoice_layout .dvalidation.hidden {
display: none;
}
.edit_frm .invoice_layout .dvalidation .dvmsg {
padding: 0.4rem 0.7rem;
margin: 0.25rem 0;
border-radius: 0.2rem;
font-size: 0.9rem;
border-left: 4px solid #999;
background: #f5f5f5;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
border-left-color: #c0392b;
background: #fdecea;
color: #922;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
border-left-color: #e0a800;
background: #fff8e1;
color: #7a5c00;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
border-left-color: #3498db;
background: #eaf4fb;
color: #1c5a82;
}
.dhist .invtbl {
width: 100%;
}
.dhist td, .dhist th {
padding: 0.3rem 0.6rem;
text-align: left;
vertical-align: top;
}
.dhist tbody tr:nth-child(even) {
background: #fafafa;
}
.modal-body .lstfrm { .modal-body .lstfrm {
display: block; display: block;
position: relative; position: relative;
+419 -126
View File
@@ -646,11 +646,375 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
}
if (booln(r, false) === true) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); m.push({ lbl: $ict.rel, fnc: $inv.rReload });
} }
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */ return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
}; };
/* Backend-authoritative draft editing controller (ADR 0006/0007)
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
validation from the authoritative server state. It is invoice-only: reminders
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
so they keep the legacy stateless flow untouched. */
$inv.d = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
hashes: function () {
let bai = $inv.d.tbl().data('bai') || [], h = {};
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
$fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s),
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
});
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dstate'), data: { token: t }, success: (state) => {
$inv.d.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } },
complete: () => { $inv.d.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* Push the server's authoritative position numbers back onto the rendered rows so the online
editor and the PDF preview always agree (the server numbers priced lines continuously; the
browser must not keep its own numbering). Only the position cell is touched no re-render. */
applyPositions: function (tbl, req) {
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
if (!co || (co.id || '') === '') { return; }
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
}));
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
$inv.d.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
});
},
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
tbl.data('dhashes', next).data('dorder', order);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
/* Render the totals footer from the server sums (port of invSumUpdate's footer half). */
footer: function (tbl, sums, admin) {
let ft = tbl.children('tfoot').empty(); tbl.nextAll('.fnote').remove();
let p13b = bool(admin.p13b, false);
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst));
let fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
rwcy('Netto', sums.total_net || 0);
if (p13b === false) { $.each(sums.vat || {}, (rate, amt) => rwcy($rct.vat + ' ' + rate + '%', amt, 'tvat')); }
rwcy('Summe', sums.total_gross || 0);
let itype = (admin.type || '');
if (itype === 'i') { fn($rct.note2); fn($rct.note4); }
else if (itype === 'c') { fn($rct.note2); }
else {
fn(string($rct.note3, [fnum(((sums.service_net || 0) + (sums.service_vat || 0)) * (admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum((sums.service_net || 0) + (sums.service_vat || 0), $rct.cst), fnum(sums.service_net || 0, $rct.cst), fnum(sums.service_vat || 0, $rct.cst)]));
}
if (p13b === true) { fn($rct.note13b); }
},
validation: function (msgs) {
let frm = $('div.invoice_layout'); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise, cancel = discard. */
preview: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout();
let email = ($inv.d.tbl().data('new') || {}).invoiceemail || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (r) => { $inv.d.tbl().data('invid', r.invid); },
error: () => { alert($ict.eis); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
discard: function () {
let invid = $inv.d.tbl().data('invid') || '';
if (invid === '') { alert('Es wurde noch kein Zwischenstand gespeichert, der wiederhergestellt werden könnte.'); return; }
if (confirm('Alle Änderungen verwerfen und den zuletzt gespeicherten Stand neu laden?') === false) { return; }
$inv.d.close();
$inv.cntInv({ id: invid });
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Rechnungsentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.d.token();
$inv.d.tbl().removeData('dtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Rechnungsentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Rechnungsentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.d.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('inv/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.d.tbl().removeData('dtoken');
}
};
/* Backend-authoritative reminder draft editing (ADR 0006/0007)
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
editor safely no-ops for the mode that is not active. */
$inv.rd = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
/* Seed the authoritative server session from the assembled reminder editor payload. */
seed: function (payload) {
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
$fis.draft.bind(r.token, {
onReady: () => $inv.rd.refresh(),
onExpiring: (s) => $inv.rd.warnExpiry(s),
onClosed: (reason) => $inv.rd.closed(reason)
});
$inv.rd.refresh();
}, error: () => { l.rC('freeze'); }
});
},
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
refresh: function (cb) {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
complete: () => { $inv.rd.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
$inv.rd.footer(tbl, state.sums || {});
$inv.rd.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.rd.token(); if (t === '') { return; }
$inv.rd.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.rd.refresh(); },
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
});
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.rd.token() === '') { return; }
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
let target = map[nme]; if (!target) { return; }
$inv.rd.sync({ Target: target, Value: val });
},
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
syncAmount: function (amount, amount_payed) {
if ($inv.rd.token() === '') { return; }
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
},
/* Render the open-amount footer from the server sums. */
footer: function (tbl, sums) {
let ft = tbl.children('tfoot').empty();
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
},
validation: function (msgs) {
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
preview: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout();
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
$inv.rd.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.rd.token();
$inv.rd.tbl().removeData('rdtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.rd.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.rd.tbl().removeData('rdtoken');
}
};
$inv.cInv2 = function (data) { $inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1); let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 }); let o = $ocms.dlg(fr, { width: 1000 });
@@ -1028,9 +1392,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1046,6 +1413,11 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') { if (typeof change === 'function') {
change(response.txt); change(response.txt);
} }
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
draft token is present, so only the active mode's session receives the delta. */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
}, },
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true } tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
} }
@@ -1131,7 +1503,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
@@ -1260,6 +1636,13 @@ $inv.invSumUpdate = function () {
} }
tbl.data('sms', sms); tbl.data('sms', sms);
tbl.data('bai', ba); tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
model to the server session and switch to server-driven totals from then on. */
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
tbl.data('dseeding', true);
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
}
}; };
$inv.worknotes = function (rx) { $inv.worknotes = function (rx) {
let wn = ''; let wn = '';
@@ -1347,7 +1730,13 @@ $inv.rendersrq = function () {
$$.tdc('currency isumval', istr); $$.tdc('currency isumval', istr);
} }
}; };
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); }; $inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
};
$inv.sedit = () => { $inv.sedit = () => {
$inv.sprev(true); $inv.sprev(true);
}; };
@@ -1402,6 +1791,7 @@ $inv.sp13b = () => {
} else { } else {
} }
tbl.trigger('fds.inv'); tbl.trigger('fds.inv');
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
}; };
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing /* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net, / FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1456,6 +1846,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0); let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); } if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(','); d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
}; };
$inv.sctp = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
@@ -1472,6 +1863,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email; cvo.contactEmail = response.email;
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
l.find('.ctpfrm').text(ne(response.name, response.email)); l.find('.ctpfrm').text(ne(response.name, response.email));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true }, typedvalues: true
}); });
@@ -1496,82 +1888,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId); adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw }; return { admin: adm, req: d.bai, sms: d.sms, new: nw };
}; };
$inv.ssave = () => { /* Zwischenspeichern and preview now run against the backend-authoritative session
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); (ADR 0006): no full-invoice re-upload the cached draft is flushed / rendered by
$inv.t_fds_inv(); token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
l.aC('freeze'); unchanged and is invoked from the preview modal's confirm handler. */
$ocms.postXT({ $inv.ssave = () => { $inv.d.save(); };
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => { $inv.sprev = (change) => { $inv.d.preview(); };
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.sprev = (change) => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
change = bool(change, false);
$inv.t_fds_inv();
l.aC('freeze');
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
}
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
}
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('req/sdel'), data: {
id: invid
}
});
}
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.rReload = () => { $inv.rReload = () => {
try { try {
let s = $('#listframe ul.rql:first').data(); let s = $('#listframe ul.rql:first').data();
@@ -1750,6 +2072,8 @@ $inv.eRowR = function (ev) {
$.extend(tdta.rm, res); $.extend(tdta.rm, res);
tbl.data(tdta); tbl.data(tdta);
$inv.rRemRw.call(row, tdta); $inv.rRemRw.call(row, tdta);
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
}, typedvalues: true }, typedvalues: true
}); });
}; };
@@ -1790,57 +2114,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
the backend owns the open-amount computation and validation; inline edits and the
item-row dialog post single deltas (see $inv.rd). */
let nw = rif.tbl.data('new');
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
$inv.rd.seed({
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
new: nw
});
}, complete: () => { }, complete: () => {
//o.c.trigger('modal_close'); //o.c.trigger('modal_close');
} }
}); });
}; };
$inv.rprev = () => { $inv.rprev = () => {
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(); /* Preview + finalise now run through the backend-authoritative session ($inv.rd):
$.extend(d.new, tbl.find('tbody > tr:first').data()); the PDF renders straight from the server cache (no rem/prep DB write), and confirm
l.aC('freeze'); flushes (rem/dsave) then finalises + emails (rem/conf). */
//console.debug({ rem: d.rm, new: d.new }); $inv.rd.preview();
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
let ct = $(this);
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('rem/del'), data: {
id: remid
}
});
}
$inv.rReload();
}
});
}
});
}; };
$inv.sis = (id) => { $inv.sis = (id) => {
if (confirm($ict.sisc)) { if (confirm($ict.sisc)) {
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+73 -2
View File
@@ -2533,7 +2533,8 @@ if (!Element.prototype.closest) {
this._dragging = false; this._dragging = false;
this._dragHandleClass = this._options.dragHandleClass || ''; this._dragHandleClass = this._options.dragHandleClass || '';
this._parentident = this._options.parentident || ''; this._parentident = this._options.parentident || '';
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null; this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
this._container.setAttribute("data-is-sortable", 1); this._container.setAttribute("data-is-sortable", 1);
this._container.classList.add("sortable"); this._container.classList.add("sortable");
@@ -2683,10 +2684,17 @@ if (!Element.prototype.closest) {
} }
}, },
// on item release/drop // on item release/drop
_onRelease: function (e) { _onRelease: function (e) {
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
var wasDragging = this._dragging === true && this._clickItem !== null;
this._dragging = false; this._dragging = false;
this._trashDragItem(); this._trashDragItem();
// Fire a single "drag finished" callback so callers can commit the new order once,
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
if (wasDragging && typeof this._onend === 'function') {
this._onend();
}
}, },
// on item drag/move // on item drag/move
@@ -3129,6 +3137,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 +3213,6 @@ $fis.notifications = {
})(); })();
$(document).ready(function () { $(document).ready(function () {
$fis.notifications.init(); $fis.notifications.init();
$fis.draft.init();
$fis.ov(); $fis.ov();
}); });
+2 -2
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -542,6 +542,50 @@ table.if th.keep, table.if td.keep {
animation: fis_spin 0.8s linear infinite; animation: fis_spin 0.8s linear infinite;
} }
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
rendered above the editor, and the change-history dialog table. */
.edit_frm .invoice_layout .dvalidation {
margin: 0 0 1rem 0;
}
.edit_frm .invoice_layout .dvalidation.hidden {
display: none;
}
.edit_frm .invoice_layout .dvalidation .dvmsg {
padding: 0.4rem 0.7rem;
margin: 0.25rem 0;
border-radius: 0.2rem;
font-size: 0.9rem;
border-left: 4px solid #999;
background: #f5f5f5;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
border-left-color: #c0392b;
background: #fdecea;
color: #922;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
border-left-color: #e0a800;
background: #fff8e1;
color: #7a5c00;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
border-left-color: #3498db;
background: #eaf4fb;
color: #1c5a82;
}
.dhist .invtbl {
width: 100%;
}
.dhist td, .dhist th {
padding: 0.3rem 0.6rem;
text-align: left;
vertical-align: top;
}
.dhist tbody tr:nth-child(even) {
background: #fafafa;
}
.modal-body .lstfrm { .modal-body .lstfrm {
display: block; display: block;
position: relative; position: relative;
+419 -126
View File
@@ -627,11 +627,375 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
}
if (booln(r, false) === true) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); m.push({ lbl: $ict.rel, fnc: $inv.rReload });
} }
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */ return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
}; };
/* Backend-authoritative draft editing controller (ADR 0006/0007)
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
validation from the authoritative server state. It is invoice-only: reminders
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
so they keep the legacy stateless flow untouched. */
$inv.d = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
hashes: function () {
let bai = $inv.d.tbl().data('bai') || [], h = {};
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
$fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s),
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
});
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dstate'), data: { token: t }, success: (state) => {
$inv.d.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } },
complete: () => { $inv.d.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* Push the server's authoritative position numbers back onto the rendered rows so the online
editor and the PDF preview always agree (the server numbers priced lines continuously; the
browser must not keep its own numbering). Only the position cell is touched no re-render. */
applyPositions: function (tbl, req) {
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
if (!co || (co.id || '') === '') { return; }
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
}));
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
$inv.d.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
});
},
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
tbl.data('dhashes', next).data('dorder', order);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
/* Render the totals footer from the server sums (port of invSumUpdate's footer half). */
footer: function (tbl, sums, admin) {
let ft = tbl.children('tfoot').empty(); tbl.nextAll('.fnote').remove();
let p13b = bool(admin.p13b, false);
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst));
let fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
rwcy('Netto', sums.total_net || 0);
if (p13b === false) { $.each(sums.vat || {}, (rate, amt) => rwcy($rct.vat + ' ' + rate + '%', amt, 'tvat')); }
rwcy('Summe', sums.total_gross || 0);
let itype = (admin.type || '');
if (itype === 'i') { fn($rct.note2); fn($rct.note4); }
else if (itype === 'c') { fn($rct.note2); }
else {
fn(string($rct.note3, [fnum(((sums.service_net || 0) + (sums.service_vat || 0)) * (admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum((sums.service_net || 0) + (sums.service_vat || 0), $rct.cst), fnum(sums.service_net || 0, $rct.cst), fnum(sums.service_vat || 0, $rct.cst)]));
}
if (p13b === true) { fn($rct.note13b); }
},
validation: function (msgs) {
let frm = $('div.invoice_layout'); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise, cancel = discard. */
preview: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout();
let email = ($inv.d.tbl().data('new') || {}).invoiceemail || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (r) => { $inv.d.tbl().data('invid', r.invid); },
error: () => { alert($ict.eis); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
discard: function () {
let invid = $inv.d.tbl().data('invid') || '';
if (invid === '') { alert('Es wurde noch kein Zwischenstand gespeichert, der wiederhergestellt werden könnte.'); return; }
if (confirm('Alle Änderungen verwerfen und den zuletzt gespeicherten Stand neu laden?') === false) { return; }
$inv.d.close();
$inv.cntInv({ id: invid });
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Rechnungsentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.d.token();
$inv.d.tbl().removeData('dtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Rechnungsentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Rechnungsentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.d.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('inv/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.d.tbl().removeData('dtoken');
}
};
/* Backend-authoritative reminder draft editing (ADR 0006/0007)
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
editor safely no-ops for the mode that is not active. */
$inv.rd = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
/* Seed the authoritative server session from the assembled reminder editor payload. */
seed: function (payload) {
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
$fis.draft.bind(r.token, {
onReady: () => $inv.rd.refresh(),
onExpiring: (s) => $inv.rd.warnExpiry(s),
onClosed: (reason) => $inv.rd.closed(reason)
});
$inv.rd.refresh();
}, error: () => { l.rC('freeze'); }
});
},
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
refresh: function (cb) {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
complete: () => { $inv.rd.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
$inv.rd.footer(tbl, state.sums || {});
$inv.rd.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.rd.token(); if (t === '') { return; }
$inv.rd.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.rd.refresh(); },
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
});
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.rd.token() === '') { return; }
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
let target = map[nme]; if (!target) { return; }
$inv.rd.sync({ Target: target, Value: val });
},
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
syncAmount: function (amount, amount_payed) {
if ($inv.rd.token() === '') { return; }
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
},
/* Render the open-amount footer from the server sums. */
footer: function (tbl, sums) {
let ft = tbl.children('tfoot').empty();
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
},
validation: function (msgs) {
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
preview: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout();
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
$inv.rd.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.rd.token();
$inv.rd.tbl().removeData('rdtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.rd.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.rd.tbl().removeData('rdtoken');
}
};
$inv.cInv2 = function (data) { $inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1); let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 }); let o = $ocms.dlg(fr, { width: 1000 });
@@ -1009,9 +1373,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1027,6 +1394,11 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') { if (typeof change === 'function') {
change(response.txt); change(response.txt);
} }
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
draft token is present, so only the active mode's session receives the delta. */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
}, },
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true } tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
} }
@@ -1112,7 +1484,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
@@ -1241,6 +1617,13 @@ $inv.invSumUpdate = function () {
} }
tbl.data('sms', sms); tbl.data('sms', sms);
tbl.data('bai', ba); tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
model to the server session and switch to server-driven totals from then on. */
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
tbl.data('dseeding', true);
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
}
}; };
$inv.worknotes = function (rx) { $inv.worknotes = function (rx) {
let wn = ''; let wn = '';
@@ -1328,7 +1711,13 @@ $inv.rendersrq = function () {
$$.tdc('currency isumval', istr); $$.tdc('currency isumval', istr);
} }
}; };
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); }; $inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
};
$inv.sedit = () => { $inv.sedit = () => {
$inv.sprev(true); $inv.sprev(true);
}; };
@@ -1383,6 +1772,7 @@ $inv.sp13b = () => {
} else { } else {
} }
tbl.trigger('fds.inv'); tbl.trigger('fds.inv');
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
}; };
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing /* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net, / FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1437,6 +1827,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0); let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); } if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(','); d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
}; };
$inv.sctp = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
@@ -1453,6 +1844,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email; cvo.contactEmail = response.email;
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
l.find('.ctpfrm').text(ne(response.name, response.email)); l.find('.ctpfrm').text(ne(response.name, response.email));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true }, typedvalues: true
}); });
@@ -1477,82 +1869,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId); adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw }; return { admin: adm, req: d.bai, sms: d.sms, new: nw };
}; };
$inv.ssave = () => { /* Zwischenspeichern and preview now run against the backend-authoritative session
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); (ADR 0006): no full-invoice re-upload the cached draft is flushed / rendered by
$inv.t_fds_inv(); token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
l.aC('freeze'); unchanged and is invoked from the preview modal's confirm handler. */
$ocms.postXT({ $inv.ssave = () => { $inv.d.save(); };
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => { $inv.sprev = (change) => { $inv.d.preview(); };
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.sprev = (change) => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
change = bool(change, false);
$inv.t_fds_inv();
l.aC('freeze');
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
}
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
}
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('req/sdel'), data: {
id: invid
}
});
}
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.rReload = () => { $inv.rReload = () => {
try { try {
let s = $('#listframe ul.rql:first').data(); let s = $('#listframe ul.rql:first').data();
@@ -1731,6 +2053,8 @@ $inv.eRowR = function (ev) {
$.extend(tdta.rm, res); $.extend(tdta.rm, res);
tbl.data(tdta); tbl.data(tdta);
$inv.rRemRw.call(row, tdta); $inv.rRemRw.call(row, tdta);
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
}, typedvalues: true }, typedvalues: true
}); });
}; };
@@ -1771,57 +2095,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
the backend owns the open-amount computation and validation; inline edits and the
item-row dialog post single deltas (see $inv.rd). */
let nw = rif.tbl.data('new');
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
$inv.rd.seed({
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
new: nw
});
}, complete: () => { }, complete: () => {
//o.c.trigger('modal_close'); //o.c.trigger('modal_close');
} }
}); });
}; };
$inv.rprev = () => { $inv.rprev = () => {
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(); /* Preview + finalise now run through the backend-authoritative session ($inv.rd):
$.extend(d.new, tbl.find('tbody > tr:first').data()); the PDF renders straight from the server cache (no rem/prep DB write), and confirm
l.aC('freeze'); flushes (rem/dsave) then finalises + emails (rem/conf). */
//console.debug({ rem: d.rm, new: d.new }); $inv.rd.preview();
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
let ct = $(this);
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('rem/del'), data: {
id: remid
}
});
}
$inv.rReload();
}
});
}
});
}; };
$inv.sis = (id) => { $inv.sis = (id) => {
if (confirm($ict.sisc)) { if (confirm($ict.sisc)) {
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long