Add backend-authoritative invoice draft editing (ADR 0006/0007) #1

Merged
Stefan merged 12 commits from feature/backend-authoritative-draft-editing into main 2026-07-16 19:46:55 +02:00
18 changed files with 1237 additions and 615 deletions
Showing only changes of commit 42997c4f49 - Show all commits
+34 -53
View File
@@ -1,4 +1,3 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -6,9 +5,11 @@ using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the server-side port of the former client-side invoice math
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
/// backend (ADR 0006), this logic is finally unit-testable directly.
/// 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
{
@@ -21,16 +22,15 @@ public class InvoiceDraftCalculatorTests
return s;
}
// ── RecomputeTotals ──────────────────────────────────────────────────────
[Fact]
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
{
var s = SessionWith(@"[
{ 'Id':'10','items':[
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
{ 'Id':'11','items':[
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
{ '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);
@@ -49,8 +49,7 @@ public class InvoiceDraftCalculatorTests
[Fact]
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", p13b: true);
InvoiceDraftCalculator.RecomputeTotals(s);
@@ -70,41 +69,6 @@ public class InvoiceDraftCalculatorTests
Assert.Empty(s.Sums.VatByRate);
}
// ── RecomputeItem (quantChange port) ─────────────────────────────────────
[Theory]
[InlineData("Service", true)]
[InlineData("material", false)]
public void RecomputeItem_DerivesLineValuesFromQtyPriceVat(string type, bool isService)
{
var item = new JObject
{
["quantityhours"] = 5, ["net"] = "10", ["vat"] = "19", ["Type"] = type
};
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Equal(50m, item["net_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["vat_val"]!.Value<decimal>());
if (isService)
{
Assert.Equal(50m, item["svcnet_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["svcvat_val"]!.Value<decimal>());
}
else
{
Assert.Null(item["svcnet_val"]);
}
}
[Fact]
public void RecomputeItem_ZeroQuantity_LeavesValuesUntouched()
{
var item = new JObject { ["quantityhours"] = 0, ["net"] = "10", ["vat"] = "19", ["Type"] = "material" };
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Null(item["net_val"]); // guard qty>0 && price>0 not met → no derivation
}
// ── NormalizeRate ────────────────────────────────────────────────────────
[Theory]
[InlineData("19,0%", "19")]
[InlineData("7%", "7")]
@@ -115,23 +79,21 @@ public class InvoiceDraftCalculatorTests
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
// ── Validate ─────────────────────────────────────────────────────────────
[Fact]
public void Validate_ValidDraft_NoErrors()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
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")] // missing email → advisory
[InlineData("", "warning")]
[InlineData("not-an-email", "error")]
public void Validate_EmailProblems_AreFlagged(string email, string severity)
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat':'19%','Type':'material'}] }]");
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
s.New["invoiceemail"] = email;
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
@@ -150,9 +112,28 @@ public class InvoiceDraftCalculatorTests
[Fact]
public void Validate_UnknownVatRate_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat_val':0.5,'vat':'5%','Type':'material'}] }]");
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");
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies <see cref="DraftNotifier"/> targets the draft's SignalR group (keyed by
/// session token, ADR 0007) with the right method/payload, and — like the other
/// notification path — swallows hub failures so a missed coordination ping never
/// fails the underlying operation.
/// </summary>
public class InvoiceDraftNotifierTests
{
private sealed class CapturingClientProxy : IClientProxy
{
private readonly bool _throw;
public string? Method { get; private set; }
public object?[]? Args { get; private set; }
public CapturingClientProxy(bool doThrow = false) => _throw = doThrow;
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (_throw) throw new InvalidOperationException("hub down");
Method = method;
Args = args;
return Task.CompletedTask;
}
}
private sealed class StubHubClients : IHubClients
{
private readonly IClientProxy _proxy;
public string? RequestedGroup { get; private set; }
public StubHubClients(IClientProxy proxy) => _proxy = proxy;
public IClientProxy Group(string groupName) { RequestedGroup = groupName; return _proxy; }
public IClientProxy All => throw new NotImplementedException();
public IClientProxy AllExcept(IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy Client(string c) => throw new NotImplementedException();
public IClientProxy Clients(IReadOnlyList<string> c) => throw new NotImplementedException();
public IClientProxy Groups(IReadOnlyList<string> g) => throw new NotImplementedException();
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => throw new NotImplementedException();
public IClientProxy User(string u) => throw new NotImplementedException();
public IClientProxy Users(IReadOnlyList<string> u) => throw new NotImplementedException();
}
private sealed class StubHubContext : IHubContext<DraftPreviewHub>
{
public StubHubContext(IHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new NotImplementedException();
}
private static (DraftNotifier notifier, StubHubClients clients, CapturingClientProxy proxy) Create(bool doThrow = false)
{
var proxy = new CapturingClientProxy(doThrow);
var clients = new StubHubClients(proxy);
var notifier = new DraftNotifier(new StubHubContext(clients), NullLogger<DraftNotifier>.Instance);
return (notifier, clients, proxy);
}
[Fact]
public async Task SignalDraftReadyAsync_SendsToTokenGroupWithVersion()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("tok-1", 7);
Assert.Equal("tok-1", clients.RequestedGroup);
Assert.Equal("draftReady", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("tok-1", payload["token"]!.Value<string>());
Assert.Equal(7, payload["version"]!.Value<int>());
}
[Fact]
public async Task SignalExpiringAsync_SendsSecondsLeft()
{
var (notifier, _, proxy) = Create();
await notifier.SignalExpiringAsync("tok-2", 120);
Assert.Equal("draftExpiring", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal(120, payload["secondsLeft"]!.Value<int>());
}
[Fact]
public async Task SignalClosedAsync_SendsReason()
{
var (notifier, _, proxy) = Create();
await notifier.SignalClosedAsync("tok-3", "expired");
Assert.Equal("draftClosed", proxy.Method);
var payload = JObject.FromObject(proxy.Args![0]!);
Assert.Equal("expired", payload["reason"]!.Value<string>());
}
[Fact]
public async Task EmptyToken_DoesNotSend()
{
var (notifier, clients, proxy) = Create();
await notifier.SignalDraftReadyAsync("", 1);
Assert.Null(clients.RequestedGroup);
Assert.Null(proxy.Method);
}
[Fact]
public async Task HubFailure_IsSwallowed()
{
var (notifier, _, _) = Create(doThrow: true);
// Must not throw — a failed coordination ping cannot fail the caller's operation.
await notifier.SignalDraftReadyAsync("tok", 1);
await notifier.SignalExpiringAsync("tok", 60);
await notifier.SignalClosedAsync("tok", "expired");
}
}
+222 -22
View File
@@ -2,7 +2,6 @@ using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
@@ -15,8 +14,8 @@ namespace Fuchs.Tests;
/// <summary>
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
/// without any database, proving the backend-authoritative model behaves correctly
/// end-to-end at the service seam (ADR 0006).
/// 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
{
@@ -32,8 +31,10 @@ public class InvoiceDraftServiceTests
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) => 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();
@@ -41,18 +42,17 @@ public class InvoiceDraftServiceTests
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
{
var cfg = new ConfigurationBuilder().Build();
var cache = new InvoiceDraftCache(cfg);
var cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
var inv = new FakeInvoiceService();
var svc = new InvoiceDraftEditService(cache, inv, intranet: null!, NullLogger<InvoiceDraftEditService>.Instance);
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','items':[
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
'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]
@@ -85,18 +85,31 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
public void ApplyPatch_BlockReplace_RecomputesTotals()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.qty", Ref = "900", Value = JToken.FromObject(5) });
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 });
// qty 5 × price 10 = 50 net, 19% → 9.5 VAT.
Assert.Equal(50m, s2!.Sums.TotalNet);
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_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()
{
@@ -129,7 +142,6 @@ public class InvoiceDraftServiceTests
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
// The FdsInvoiceData handed to registration carries the session's server-computed totals.
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
@@ -137,19 +149,207 @@ public class InvoiceDraftServiceTests
Assert.Equal("19", vatRate.Value);
}
[Fact]
public async Task DiscardAsync_NeverSaved_ReturnsSessionUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var back = await svc.DiscardAsync(s.Token, "user1", null!);
Assert.Same(s, back); // no InvId → nothing to reload from the DB
}
[Fact]
public void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
[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));
}
}
@@ -17,32 +17,26 @@ public partial class IntranetController
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
// POST inv/dopen — { id? | payload? } → { token, version }
// 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)
{
InvoiceDraftSession session;
if (HasForm("id") && !string.IsNullOrEmpty(Form("id")))
if (!HasForm("payload"))
{
_logger.LogInformation("Draft dopen: from DB draft {InvId} user={User}", Form("id"), UserAccountID);
session = await _invoiceDrafts.OpenFromDraftAsync(Form("id"), UserAccountID, DbSec);
}
else if (HasForm("payload"))
{
_logger.LogInformation("Draft dopen: from payload user={User}", UserAccountID);
JObject payload;
try { payload = JObject.Parse(Form("payload")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
return BadRequest400();
}
session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
}
else
{
_logger.LogWarning("Draft dopen: neither 'id' nor 'payload' supplied user={User}", UserAccountID);
_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.
@@ -125,16 +119,6 @@ public partial class IntranetController
return await JSONAsync(new { history });
}
// POST inv/ddiscard — { token } → { ok, version }; reload from DB + draftReady
private async Task<IActionResult> HandleDraftDiscard(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var session = await _invoiceDrafts.DiscardAsync(Form("token"), UserAccountID, DbSec);
if (session == null) return DraftGone();
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dclose — { token } → { ok }
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
{
@@ -165,7 +165,6 @@ public partial class IntranetController
case "dpreview": return await HandleDraftPreview(fn, id, code);
case "dsave": return await HandleDraftSave(fn, id, code);
case "dhistory": return await HandleDraftHistory(fn, id, code);
case "ddiscard": return await HandleDraftDiscard(fn, id, code);
case "dclose": return await HandleDraftClose(fn, id, code);
default:
+20 -27
View File
@@ -9,32 +9,31 @@ namespace Fuchs.Services;
/// Orchestrates a live, backend-authoritative invoice draft editing session
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
/// open (seed the cache), apply single edits, build the view state, render a PDF
/// preview from the cache, flush to the DB ("Zwischenspeichern"), discard (reload
/// from the DB) and expose the change history. All totals/VAT are computed by
/// <see cref="InvoiceDraftCalculator"/> — the browser never calculates.
/// 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 for a brand-new draft from the editor's initially
/// assembled payload (<c>admin</c> / <c>new</c> / <c>req</c> blocks). Computes
/// totals + validation and returns the session (with its fresh token/version).
/// 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>
/// Seeds a cache session by loading an existing DB draft (<c>fds__getInvoice</c>)
/// and reshaping it into the editor's block/item structure. Computes + caches.
/// </summary>
Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
InvoiceDraftSession? Get(string token);
/// <summary>
/// Applies one editor change to the cached session: mutates the payload,
/// re-derives affected item math + totals, re-validates, appends a history entry
/// and bumps the version. Returns the mutated session, or null if the token is unknown.
/// 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);
@@ -45,8 +44,8 @@ public interface IInvoiceDraftService
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.
/// 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);
@@ -54,21 +53,15 @@ public interface IInvoiceDraftService
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
/// <summary>
/// Discards the session's in-memory changes by reloading it from the DB draft
/// (requires a prior flush / an existing <c>InvId</c>). Bumps the version so the
/// client refetches. Returns the reloaded session, or null if the token is unknown.
/// </summary>
Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Removes the session from the cache (explicit close/finalise). Returns true if one was present.</summary>
/// <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", "item.qty"); <see cref="Ref"/> is the item or
/// block id it applies to (when relevant); <see cref="Value"/> is the new value.
/// 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
{
+32 -184
View File
@@ -1,44 +1,37 @@
using System.Globalization;
using System.Web;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
/// <see cref="InvoiceDraftSession"/> (via <see cref="IInvoiceDraftCache"/>), applies
/// single edits, computes totals with <see cref="InvoiceDraftCalculator"/>, renders
/// 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. Deliberately I/O-thin so the calculation
/// remains unit-testable.
/// 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 Fuchs_intranet _intranet;
private readonly ILogger<InvoiceDraftEditService> _logger;
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
Fuchs_intranet intranet, ILogger<InvoiceDraftEditService> logger)
ILogger<InvoiceDraftEditService> logger)
{
_cache = cache;
_invoices = invoices;
_intranet = intranet;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
// ── Open ─────────────────────────────────────────────────────────────────
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
{
@@ -58,17 +51,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return session;
}
public async Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec)
{
var session = new InvoiceDraftSession { Token = NewToken(), UserAccountId = userAccountId, InvId = invId };
await LoadDraftIntoAsync(session, invId, userAccountId, dbSec);
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Draft session {Token} opened from DB draft {InvId} (user={User})",
session.Token, invId, userAccountId);
return session;
}
public InvoiceDraftSession? Get(string token) => _cache.Get(token);
// ── Patch ──────────────────────────────────────────────────────────────────
@@ -118,17 +100,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
case "p13b":
oldValue = Str(s.Admin["p13b"]);
bool next = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value)
: !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = next;
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
return true;
case "item.qty": return SetItem(s, d, "quantityhours", recompute: true, ref oldValue);
case "item.price": return SetItem(s, d, "net", recompute: true, ref oldValue);
case "item.note": return SetItem(s, d, "Note", recompute: false, ref oldValue);
case "item.remove": return RemoveItem(s, d, ref oldValue);
case "block.combine":
case "item.combine": return SetBlockFlag(s, d, "onesum", ref oldValue);
case "block.replace": return ReplaceBlock(s, d, ref oldValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue);
default: return false;
}
@@ -161,31 +136,22 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return true;
}
private static bool SetItem(InvoiceDraftSession s, InvoiceDraftDelta d, string key, bool recompute, ref string oldValue)
/// <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)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item[key]);
item[key] = d.Value ?? JValue.CreateString(d.ValueString);
if (recompute) InvoiceDraftCalculator.RecomputeItem(item);
return true;
}
private static bool RemoveItem(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item["NameOrNumber"]).ne(Str(item["htmltext"]));
item.Remove();
return true;
}
private static bool SetBlockFlag(InvoiceDraftSession s, InvoiceDraftDelta d, string key, ref string oldValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block[key]);
block[key] = d.Value != null && d.Value.Type != JTokenType.Null ? AsBool(d.Value) : !AsBool(block[key]);
if (d.Value is not JObject nb) return false;
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);
}
return true;
}
@@ -229,7 +195,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
// ── Flush / preview / discard ─────────────────────────────────────────────
// ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
@@ -258,30 +224,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return _invoices.GenerateInvoicePdf(fds, draft: true);
}
public async Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
if (string.IsNullOrEmpty(session.InvId))
{
_logger.LogInformation("Draft {Token} discard requested but never saved — nothing to reload (user={User})", token, userAccountId);
return session;
}
await LoadDraftIntoAsync(session, session.InvId, userAccountId, dbSec);
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = userAccountId,
Target = "discard",
NewValue = "Änderungen verworfen",
Version = session.Version
});
_cache.Set(session);
_logger.LogInformation("Draft {Token} discarded, reloaded from DB invoice {InvId} (user={User})", token, session.InvId, userAccountId);
return session;
}
public bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
@@ -300,16 +242,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return null;
}
private static JObject? FindItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && bo["items"] is JArray items)
foreach (var it in items)
if (it is JObject io && Str(io["Id"]) == itemId) return io;
return null;
}
/// <summary>Builds the <see cref="FdsInvoiceData"/> from the session — the server-side port of <c>invcPayload</c>.</summary>
/// <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();
@@ -375,8 +313,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
idx++;
if (idx > 2) break;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
}
return new GenericObjectDictionary(d);
}
@@ -391,96 +329,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return string.Join(",", tokens);
}
/// <summary>Loads the DB draft (<c>fds__getInvoice</c>) into the session's payload — the port of HandleInvoiceGet + BuildInvoiceRequestList.</summary>
private async Task LoadDraftIntoAsync(InvoiceDraftSession session, string invId, string userAccountId, DatabaseSecurity dbSec)
{
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invId) };
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("LoadDraftIntoAsync sql exception for {InvId}: {Ex}", invId, dset.Exception);
var adminDic = dset.Table("admin").FirstRow.toObjectDictionary();
var invDic = dset.Table("inv").FirstRow.toObjectDictionary();
string invoiceOptions = invDic.nz("InvoiceOptions", "");
bool p13b = invoiceOptions.Split(',').Contains("§13b");
string setmode = invoiceOptions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))?["setmode:".Length..] ?? "";
var admin = JObject.FromObject(adminDic);
admin["type"] = admin["type"] ?? JValue.CreateString(invDic.nz("InvoiceType").Substr(0, 1));
admin["p13b"] = p13b;
if (!string.IsNullOrEmpty(setmode)) admin["setmode"] = setmode;
var nw = new JObject
{
["invoicetitle"] = invDic.nz("InvoiceTitle"),
["title"] = invDic.nz("InvoiceTitle"),
["invoiceaddress"] = invDic.nz("SendToAddress"),
["invoiceemail"] = invDic.nz("SendToEmail"),
["provisionlocation"] = invDic.nz("ProvisionLocation"),
["loc"] = invDic.nz("ProvisionLocation"),
["provisionperiod"] = invDic.nz("ProvisionPeriod"),
["CustomValues"] = invDic.nz("CustomValues"),
["paymentterm"] = invDic.nz("PaymentTerm")
};
session.Admin = admin;
session.New = nw;
session.Req = BuildDraftBlocks(dset);
session.IsDraft = invDic.getItem("IsFinal", false) is not true;
}
/// <summary>Reshapes the <c>fds__getInvoice</c> req/itm tables into the editor's block/item JSON (port of BuildInvoiceRequestList).</summary>
private static JArray BuildDraftBlocks(SQLDataSet dset)
{
var blocks = new JArray();
foreach (System.Data.DataRow rq in dset.Tables("req").Select("",
dset.Tables("req").Columns.Contains("order") ? "order" : ""))
{
var rdic = rq.toObjectDictionary();
var block = new JObject
{
["Id"] = rdic["mfr__servicerequest"]?.ToString() ?? "",
["InvRqId"] = rdic["Id"]?.ToString() ?? "",
["text"] = HttpUtility.HtmlDecode(rdic["title"]?.ToString() ?? "")
};
var items = new JArray();
if (dset.Contains("itm"))
{
foreach (System.Data.DataRow sitm in dset.Tables("itm").Select(
$"[InvRqId] = '{rdic["Id"]}'",
dset.Tables("itm").Columns.Contains("order") ? "order" : ""))
{
var di = sitm.toObjectDictionary();
double net = Convert.ToDouble(di.no("value_total", 0));
double vat = Convert.ToDouble(di.no("vat", 0));
items.Add(new JObject
{
["Id"] = di["Id"]?.ToString() ?? "",
["net_val"] = net,
["vat_val"] = net * vat * 0.01,
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = Convert.ToDouble(di.no("value_service", 0)),
["svcvat_val"] = 0,
["net"] = Convert.ToDouble(di.no("value", 0)),
["quantity"] = di.nz("Quantity"),
["Type"] = di.nz("Type"),
["Note"] = di.nz("Text"),
["htmltext"] = di.nz("Text"),
["position"] = di.nz("Position"),
["SortOrder"] = di.nz("SortOrder")
});
}
}
block["items"] = items;
blocks.Add(block);
}
return blocks;
}
// ── token helpers ─────────────────────────────────────────────────────────
private static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
+21 -62
View File
@@ -4,49 +4,22 @@ using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, pure port of the invoice totals/VAT math that used to live in the
/// browser (<c>quantChange</c> + <c>invSumUpdate</c> in <c>fis.inv_shared.js</c>).
/// This is the authoritative calculation for a live draft (ADR 0006): given the
/// editable payload of an <see cref="InvoiceDraftSession"/>, it (re)computes each
/// item's line values, aggregates block/rate totals into
/// <see cref="InvoiceDraftSession.Sums"/>, and runs the plausibility/consistency
/// checks into <see cref="InvoiceDraftSession.ValidationMessages"/>.
/// Server-side, pure aggregation of an invoice draft's totals/VAT — the authoritative
/// replacement for the browser's <c>invSumUpdate</c> footer math (ADR 0006). The user's
/// requirement is that the <b>sums</b> live in the backend cache, not the frontend.
///
/// Kept static and free of I/O so it is exhaustively unit-testable — the payoff the
/// old <c>EVAL_live_invoice_editing.md</c> predicted once the truth moved server-side.
/// It reads each block's persisted line contract (<c>block.itm</c> = the editor's <c>co</c>
/// objects: <c>vt</c>=net, <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT,
/// <c>vat</c>=rate) — exactly the shape the editor already posts and the PDF/persistence
/// already consume — so no line data is re-derived or re-shaped. It then applies the §13b
/// reverse-charge rule and validates. Static/pure, hence exhaustively unit-testable.
/// </summary>
public static class InvoiceDraftCalculator
{
/// <summary>
/// Re-derives a single item's line values from quantity × net price × VAT rate —
/// the port of the editor's <c>quantChange</c>. Only applied when an item's
/// quantity/price actually changes (osum/set/text lines keep their stored values,
/// exactly as the client only ran <c>quantChange</c> on edited quantity rows).
/// Mirrors the guard <c>qty &gt; 0 &amp;&amp; price &gt; 0</c>.
/// </summary>
public static void RecomputeItem(JObject item)
{
int qty = (int)Dec(item["quantityhours"]);
decimal net = Dec(item["net"]);
decimal vat = RatePercent(Str(item["vat"])) * 0.01m; // "19%"/"19,0%" → 0.19
if (qty > 0 && net > 0)
{
decimal netVal = decimal.Round(qty * net, 2, MidpointRounding.AwayFromZero);
decimal vatVal = decimal.Round(qty * net * vat, 2, MidpointRounding.AwayFromZero);
item["net_val"] = netVal;
item["vat_val"] = vatVal;
if (string.Equals(Str(item["Type"]), "service", StringComparison.OrdinalIgnoreCase))
{
item["svcnet_val"] = netVal;
item["svcvat_val"] = vatVal;
}
}
}
/// <summary>
/// Aggregates all line items into the draft's totals — the port of <c>invSumUpdate</c>'s
/// <c>csms</c> accumulation plus the §13b reverse-charge rule (VAT suppressed → gross = net).
/// VAT is grouped by the item's rate string (matching the editor's <c>sms.vat</c> map).
/// Aggregates every block's line values into the draft's totals — the port of
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
/// VAT is grouped by the line's rate string (matching the editor's <c>sms.vat</c> map).
/// </summary>
public static void RecomputeTotals(InvoiceDraftSession session)
{
@@ -58,15 +31,15 @@ public static class InvoiceDraftCalculator
if (blockTok is not JObject block) continue;
decimal blockNet = 0;
string blockId = Str(block["Id"]);
if (block["items"] is JArray items)
if (block["itm"] is JArray lines)
{
foreach (var itemTok in items)
foreach (var lineTok in lines)
{
if (itemTok is not JObject item) continue;
decimal netVal = Dec(item["net_val"]);
decimal vatVal = Dec(item["vat_val"]);
decimal svcNet = Dec(item["svcnet_val"]);
decimal svcVat = Dec(item["svcvat_val"]);
if (lineTok is not JObject co) continue;
decimal netVal = Dec(co["vt"]);
decimal vatVal = Dec(co["vv"]);
decimal svcNet = Dec(co["vs"]);
decimal svcVat = Dec(co["vsv"]);
sums.ServiceNet += svcNet;
sums.ServiceVat += svcVat;
@@ -75,7 +48,7 @@ public static class InvoiceDraftCalculator
sums.TotalGross += netVal + vatVal;
blockNet += netVal;
string rate = NormalizeRate(Str(item["vat"]));
string rate = NormalizeRate(Str(co["vat"]));
if (rate.Length > 0)
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
}
@@ -106,30 +79,23 @@ public static class InvoiceDraftCalculator
void Add(string field, string sev, string msg) =>
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
// Recipient email
string email = Str(session.New["invoiceemail"]).Trim();
if (email.Length == 0)
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
// Recipient address
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
// At least one priced line
if (!HasAnyItem(session))
Add("items", "error", "Die Rechnung enthält keine Positionen.");
// VAT rate sanity (only when not reverse-charge)
if (!Flag(session.Admin, "p13b"))
{
foreach (var rate in session.Sums.VatByRate.Keys)
if (!IsKnownVatRate(rate))
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
}
// Negative total
if (session.Sums.TotalGross < 0)
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
}
@@ -138,7 +104,7 @@ public static class InvoiceDraftCalculator
private static bool HasAnyItem(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
if (blockTok is JObject block && block["items"] is JArray items && items.Count > 0)
if (blockTok is JObject block && block["itm"] is JArray lines && lines.Count > 0)
return true;
return false;
}
@@ -152,7 +118,7 @@ public static class InvoiceDraftCalculator
}
private static string Str(JToken? token) =>
token == null || token.Type == JTokenType.Null ? "" : token.Value<string>() ?? "";
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
private static bool Flag(JObject obj, string key)
{
@@ -176,13 +142,6 @@ public static class InvoiceDraftCalculator
private static bool IsKnownVatRate(string rate) => rate is "0" or "7" or "19";
/// <summary>Parses a VAT rate string ("19%", "19,0%", "7") to its numeric percent (German/invariant tolerant).</summary>
internal static decimal RatePercent(string? raw)
{
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
}
private static bool IsValidEmail(string email)
{
int at = email.IndexOf('@');
+216 -77
View File
@@ -99,11 +99,202 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
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) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
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());
$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 || []);
},
/* 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'); } }
});
},
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */
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); } });
tbl.data('dhashes', next);
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 }));
},
/* 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');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -499,6 +690,8 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -713,6 +906,13 @@ $inv.invSumUpdate = function () {
}
tbl.data('sms', sms);
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) {
let wn = '';
@@ -800,7 +1000,13 @@ $inv.rendersrq = function () {
$$.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.sprev(true);
};
@@ -855,6 +1061,7 @@ $inv.sp13b = () => {
} else {
}
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
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -909,6 +1116,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -925,6 +1133,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email;
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));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true
});
@@ -949,82 +1158,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$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');
}
});
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
unchanged and is invoked from the preview modal's confirm handler. */
$inv.ssave = () => { $inv.d.save(); };
$inv.sprev = (change) => { $inv.d.preview(); };
$inv.rReload = () => {
try {
let s = $('#listframe ul.rql:first').data();
@@ -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 {
display: block;
position: relative;
+44
View File
@@ -405,6 +405,50 @@ table.if td.num {
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 {
display: block;
position: relative;
+216 -77
View File
@@ -646,11 +646,202 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
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) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
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());
$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 || []);
},
/* 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'); } }
});
},
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */
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); } });
tbl.data('dhashes', next);
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 }));
},
/* 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');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -1046,6 +1237,8 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -1260,6 +1453,13 @@ $inv.invSumUpdate = function () {
}
tbl.data('sms', sms);
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) {
let wn = '';
@@ -1347,7 +1547,13 @@ $inv.rendersrq = function () {
$$.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.sprev(true);
};
@@ -1402,6 +1608,7 @@ $inv.sp13b = () => {
} else {
}
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
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1456,6 +1663,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -1472,6 +1680,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email;
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));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true
});
@@ -1496,82 +1705,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$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');
}
});
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
unchanged and is invoked from the preview modal's confirm handler. */
$inv.ssave = () => { $inv.d.save(); };
$inv.sprev = (change) => { $inv.d.preview(); };
$inv.rReload = () => {
try {
let s = $('#listframe ul.rql:first').data();
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -542,6 +542,50 @@ table.if th.keep, table.if td.keep {
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 {
display: block;
position: relative;
+216 -77
View File
@@ -627,11 +627,202 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
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) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
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());
$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 || []);
},
/* 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'); } }
});
},
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */
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); } });
tbl.data('dhashes', next);
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 }));
},
/* 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');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -1027,6 +1218,8 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -1241,6 +1434,13 @@ $inv.invSumUpdate = function () {
}
tbl.data('sms', sms);
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) {
let wn = '';
@@ -1328,7 +1528,13 @@ $inv.rendersrq = function () {
$$.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.sprev(true);
};
@@ -1383,6 +1589,7 @@ $inv.sp13b = () => {
} else {
}
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
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1437,6 +1644,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -1453,6 +1661,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email;
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));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true
});
@@ -1477,82 +1686,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$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');
}
});
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload the cached draft is flushed / rendered by
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
unchanged and is invoked from the preview modal's confirm handler. */
$inv.ssave = () => { $inv.d.save(); };
$inv.sprev = (change) => { $inv.d.preview(); };
$inv.rReload = () => {
try {
let s = $('#listframe ul.rql:first').data();
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long