Compare commits
4
Commits
e53d8962ad
...
5c0fdc6c1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c0fdc6c1d | ||
|
|
83d1c28b29 | ||
|
|
42997c4f49 | ||
|
|
af445c015e |
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
|
||||||
|
/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftCacheTests
|
||||||
|
{
|
||||||
|
private static IConfiguration Config(int idle = 30, int warn = 5) =>
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
|
||||||
|
["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
private sealed class FakeNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
public readonly List<(string token, int version)> Ready = new();
|
||||||
|
public readonly List<(string token, int secondsLeft)> Expiring = new();
|
||||||
|
public readonly List<(string token, string reason)> Closed = new();
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache storage ─────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void SetGet_RoundTrips_AndUnknownTokenIsNull()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "abc" };
|
||||||
|
cache.Set(s);
|
||||||
|
Assert.Same(s, cache.Get("abc"));
|
||||||
|
Assert.Null(cache.Get("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Remove_EvictsSession()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "x" });
|
||||||
|
Assert.NotNull(cache.Remove("x"));
|
||||||
|
Assert.Null(cache.Get("x"));
|
||||||
|
Assert.Null(cache.Remove("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
|
||||||
|
cache.Set(s);
|
||||||
|
cache.Get("x");
|
||||||
|
Assert.False(s.ExpiryWarningSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expiry monitor ────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
|
||||||
|
var s = new InvoiceDraftSession { Token = "a" };
|
||||||
|
cache.Set(s);
|
||||||
|
|
||||||
|
// Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
Assert.True(s.ExpiryWarningSent);
|
||||||
|
|
||||||
|
// Another sweep while still idle must not spam a second warning.
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
|
||||||
|
// Past the TTL → evicted and the editor is told to close with a reason.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Closed);
|
||||||
|
Assert.Equal(("a", "expired"), notifier.Closed[0]);
|
||||||
|
Assert.Null(cache.Get("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_FreshSession_DoesNothing()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "fresh" });
|
||||||
|
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Evaluation — Backend-cached invoice editing over SignalR
|
# Evaluation — Backend-cached invoice editing over SignalR
|
||||||
|
|
||||||
|
> **⚠️ Superseded (2026-07-10).** This note's recommendation (keep the editor
|
||||||
|
> stateless; do **not** build the SignalR/server-cached model) was reversed by the
|
||||||
|
> product owner. Invoice draft editing is now backend-authoritative over an in-memory
|
||||||
|
> cache — see **ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)**,
|
||||||
|
> [`Decisions/0007-targeted-draft-signalr-groups.md`](Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
|
> and the concept doc [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md).
|
||||||
|
> The analysis below is retained for the historical rationale and the risks it flagged
|
||||||
|
> (server-held state, scaling/backplane, reconnect) — which the new design addresses or
|
||||||
|
> accepts explicitly as documented limitations.
|
||||||
|
|
||||||
|
|
||||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||||
|
|||||||
@@ -337,9 +337,18 @@ flowchart TD
|
|||||||
|
|
||||||
## 10. Key invariants worth remembering
|
## 10. Key invariants worth remembering
|
||||||
|
|
||||||
- **Stateless editor**: every preview/save/finalise call re-posts the full
|
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
|
||||||
`invc` JSON; the server never holds a partial invoice in memory or session
|
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
|
||||||
between requests (see `EVAL_live_invoice_editing.md`).
|
> **backend-authoritative** model where the server holds the draft in an in-memory
|
||||||
|
> cache (the single source of truth), the browser posts single edits and re-fetches on
|
||||||
|
> a SignalR signal, and totals are computed server-side. See ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
|
> and [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md). Finalise/email
|
||||||
|
> (§5–§6) are unchanged. The remaining invariants below still hold.
|
||||||
|
|
||||||
|
- **Stateless editor** *(legacy — see the note above; superseded by ADR 0006)*: every
|
||||||
|
preview/save/finalise call re-posts the full `invc` JSON; the server never holds a
|
||||||
|
partial invoice in memory or session between requests (see `EVAL_live_invoice_editing.md`).
|
||||||
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||||
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||||
mode (set pricing) never changes what the customer owes.
|
mode (set pricing) never changes what the customer owes.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public enum DomainEventType
|
|||||||
InvoiceFileCreationFailed,
|
InvoiceFileCreationFailed,
|
||||||
InvoiceSendFailed,
|
InvoiceSendFailed,
|
||||||
ReminderDraftCreated,
|
ReminderDraftCreated,
|
||||||
|
ReminderDraftUpdated,
|
||||||
ReminderFileCreated,
|
ReminderFileCreated,
|
||||||
ReminderSentToCustomer,
|
ReminderSentToCustomer,
|
||||||
ReminderResentToCustomer,
|
ReminderResentToCustomer,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
|
||||||
|
/// SignalR group named after the draft token so only the editing browser is notified.
|
||||||
|
/// Like <see cref="EventService.PublishAsync"/>, delivery failures are logged and
|
||||||
|
/// swallowed — a missed coordination ping must never fail the underlying operation
|
||||||
|
/// (the client also re-syncs on reconnect and on its next POST).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DraftNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
private readonly IHubContext<DraftPreviewHub> _hub;
|
||||||
|
private readonly ILogger<DraftNotifier> _logger;
|
||||||
|
|
||||||
|
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
|
||||||
|
|
||||||
|
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
|
||||||
|
///
|
||||||
|
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
|
||||||
|
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
|
||||||
|
/// signals must be <b>targeted</b> at the one browser editing a given draft.
|
||||||
|
/// Targeting is done with a SignalR group named after the draft's session token —
|
||||||
|
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
|
||||||
|
///
|
||||||
|
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
|
||||||
|
/// (see ADR 0006). The hub only manages group membership and delivers the server's
|
||||||
|
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public sealed class DraftPreviewHub : Hub
|
||||||
|
{
|
||||||
|
/// <summary>Subscribes this connection to a draft's signal group.</summary>
|
||||||
|
public Task JoinDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.AddToGroupAsync(Context.ConnectionId, token);
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
|
||||||
|
public Task LeaveDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory store of live invoice draft editing sessions (see ADR 0006).
|
||||||
|
/// Singleton, single-instance only — scale-out would need a distributed cache /
|
||||||
|
/// sticky sessions (documented limitation). Keyed by the session token.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInvoiceDraftCache
|
||||||
|
{
|
||||||
|
/// <summary>Stores (or replaces) a session under its token.</summary>
|
||||||
|
void Set(InvoiceDraftSession session);
|
||||||
|
|
||||||
|
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
|
||||||
|
InvoiceDraftSession? Get(string token);
|
||||||
|
|
||||||
|
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
|
||||||
|
InvoiceDraftSession? Remove(string token);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
|
||||||
|
IReadOnlyList<InvoiceDraftSession> Snapshot();
|
||||||
|
|
||||||
|
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
|
||||||
|
TimeSpan IdleTtl { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-instance, in-memory implementation of <see cref="IInvoiceDraftCache"/>
|
||||||
|
/// backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session
|
||||||
|
/// token. A plain dictionary (rather than <c>IMemoryCache</c>) is used on purpose:
|
||||||
|
/// the <see cref="InvoiceDraftExpiryService"/> needs to enumerate sessions and warn
|
||||||
|
/// the user <b>before</b> eviction, which opaque cache-entry expiry does not allow.
|
||||||
|
///
|
||||||
|
/// Idle TTL and the pre-expiry warning lead time are configurable under
|
||||||
|
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvoiceDraftCache : IInvoiceDraftCache
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, InvoiceDraftSession> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public TimeSpan IdleTtl { get; }
|
||||||
|
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
|
||||||
|
public TimeSpan ExpiryWarnLead { get; }
|
||||||
|
|
||||||
|
public InvoiceDraftCache(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
|
||||||
|
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||||
|
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
|
||||||
|
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
|
||||||
|
session.Touch();
|
||||||
|
_sessions[session.Token] = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceDraftSession? Get(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
if (_sessions.TryGetValue(token, out var s))
|
||||||
|
{
|
||||||
|
s.Touch();
|
||||||
|
// A touch resets the idle window, so a fresh warning is due next time it lapses.
|
||||||
|
s.ExpiryWarningSent = false;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceDraftSession? Remove(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return _sessions.TryRemove(token, out var s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<InvoiceDraftSession> Snapshot() => _sessions.Values.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,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><p>…</p></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><p>18.06.2026</p></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><br></c> — while single-line fields collapse to one line.
|
||||||
|
/// Blank lines are removed so a stray <c><p></p></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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><p>…</p></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
+52
-9
@@ -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,22 +617,41 @@ 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)
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||||
|
{
|
||||||
|
var hr = tbl.AddRow();
|
||||||
|
hr.HeightRule = RowHeightRule.Auto;
|
||||||
|
hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
|
||||||
|
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
|
||||||
|
? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
|
||||||
|
: block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
|
||||||
var row = tbl.AddRow();
|
var row = tbl.AddRow();
|
||||||
row.HeightRule = RowHeightRule.Auto;
|
row.HeightRule = RowHeightRule.Auto;
|
||||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
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();
|
var titleCell = row.Cells[1].AddParagraph();
|
||||||
titleCell.Style = "TblCell_RTitle";
|
titleCell.Style = "TblCell_RTitle";
|
||||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||||
else titleCell.AddText(line.Title);
|
else titleCell.AddText(line.Title);
|
||||||
|
}
|
||||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||||
@@ -616,7 +659,7 @@ public static class FuchsPdf
|
|||||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||||
pos++;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -284,3 +284,65 @@ $fis.notifications = {
|
|||||||
}, 9000);
|
}, 9000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
|
||||||
|
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
|
||||||
|
(group = session token) to re-fetch (draftReady), warns before idle expiry
|
||||||
|
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
|
||||||
|
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
|
||||||
|
$fis.draft = {
|
||||||
|
connection: null,
|
||||||
|
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
|
||||||
|
init: function () {
|
||||||
|
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl('/draftpreview')
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
|
||||||
|
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
|
||||||
|
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
|
||||||
|
/* Re-join the active draft's group after a (re)connect — group membership is
|
||||||
|
per-connection and is lost when the socket drops. */
|
||||||
|
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
|
||||||
|
this.connection.onclose(() => {
|
||||||
|
console.warn('Draft connection closed; retrying in 5s.');
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
|
||||||
|
});
|
||||||
|
this.start();
|
||||||
|
},
|
||||||
|
start: function () {
|
||||||
|
this.connection.start()
|
||||||
|
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Draft connection failed to start; retrying in 5s.', err);
|
||||||
|
this.connection = null;
|
||||||
|
setTimeout(() => this.init(), 5000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Registers the currently open draft and joins its signal group. handlers:
|
||||||
|
{ onReady, onExpiring, onClosed }. */
|
||||||
|
bind: function (token, handlers) {
|
||||||
|
if (!token) { return; }
|
||||||
|
this.active = $.extend({ token: token }, handlers || {});
|
||||||
|
if (this.connection === null) { this.init(); }
|
||||||
|
this._invoke('JoinDraft', token);
|
||||||
|
},
|
||||||
|
/* Unregisters + leaves the group (editor closed). */
|
||||||
|
release: function (token) {
|
||||||
|
if (this.active && (!token || this.active.token === token)) { this.active = null; }
|
||||||
|
this._invoke('LeaveDraft', token);
|
||||||
|
},
|
||||||
|
_invoke: function (method, token) {
|
||||||
|
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
|
||||||
|
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
|
||||||
|
},
|
||||||
|
_dispatch: function (handler, payload, argOf) {
|
||||||
|
payload = payload || {};
|
||||||
|
if (!this.active || this.active.token !== payload.token) { return; }
|
||||||
|
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$fis.notifications.init();
|
$fis.notifications.init();
|
||||||
|
$fis.draft.init();
|
||||||
$fis.ov();
|
$fis.ov();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -215,8 +216,15 @@ 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
|
||||||
|
|||||||
@@ -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
@@ -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)) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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");
|
||||||
@@ -2685,8 +2686,15 @@ 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();
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
@@ -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)) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user