Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 > 0 && price > 0</c>.
|
||||
/// </summary>
|
||||
public static void RecomputeItem(JObject item)
|
||||
{
|
||||
int qty = (int)Dec(item["quantityhours"]);
|
||||
decimal net = Dec(item["net"]);
|
||||
decimal vat = RatePercent(Str(item["vat"])) * 0.01m; // "19%"/"19,0%" → 0.19
|
||||
if (qty > 0 && net > 0)
|
||||
{
|
||||
decimal netVal = decimal.Round(qty * net, 2, MidpointRounding.AwayFromZero);
|
||||
decimal vatVal = decimal.Round(qty * net * vat, 2, MidpointRounding.AwayFromZero);
|
||||
item["net_val"] = netVal;
|
||||
item["vat_val"] = vatVal;
|
||||
if (string.Equals(Str(item["Type"]), "service", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item["svcnet_val"] = netVal;
|
||||
item["svcvat_val"] = vatVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates all line items into the draft's totals — the port of <c>invSumUpdate</c>'s
|
||||
/// <c>csms</c> accumulation plus the §13b reverse-charge rule (VAT suppressed → gross = net).
|
||||
/// VAT is grouped by the item's rate string (matching the editor's <c>sms.vat</c> map).
|
||||
/// 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('@');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user