Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
using System.Linq;
|
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -6,9 +5,11 @@ using Xunit;
|
|||||||
namespace Fuchs.Tests;
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the server-side port of the former client-side invoice math
|
/// Verifies the server-side aggregation of invoice draft totals (the port of the
|
||||||
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
|
/// former client-side <c>invSumUpdate</c> footer math). The truth now lives in the
|
||||||
/// backend (ADR 0006), this logic is finally unit-testable directly.
|
/// 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>
|
/// </summary>
|
||||||
public class InvoiceDraftCalculatorTests
|
public class InvoiceDraftCalculatorTests
|
||||||
{
|
{
|
||||||
@@ -21,16 +22,15 @@ public class InvoiceDraftCalculatorTests
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RecomputeTotals ──────────────────────────────────────────────────────
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
||||||
{
|
{
|
||||||
var s = SessionWith(@"[
|
var s = SessionWith(@"[
|
||||||
{ 'Id':'10','items':[
|
{ 'Id':'10','itm':[
|
||||||
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
|
{'vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'},
|
||||||
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
|
{'vt':50,'vv':9.5,'vs':50,'vsv':9.5,'vat':'19%'} ] },
|
||||||
{ 'Id':'11','items':[
|
{ 'Id':'11','itm':[
|
||||||
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
|
{'vt':200,'vv':14,'vs':0,'vsv':0,'vat':'7%'} ] }
|
||||||
]");
|
]");
|
||||||
|
|
||||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
@@ -49,8 +49,7 @@ public class InvoiceDraftCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
|
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
|
||||||
{
|
{
|
||||||
var s = SessionWith(@"[{ 'Id':'1','items':[
|
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", p13b: true);
|
||||||
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
|
|
||||||
|
|
||||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
|
||||||
@@ -70,41 +69,6 @@ public class InvoiceDraftCalculatorTests
|
|||||||
Assert.Empty(s.Sums.VatByRate);
|
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]
|
[Theory]
|
||||||
[InlineData("19,0%", "19")]
|
[InlineData("19,0%", "19")]
|
||||||
[InlineData("7%", "7")]
|
[InlineData("7%", "7")]
|
||||||
@@ -115,23 +79,21 @@ public class InvoiceDraftCalculatorTests
|
|||||||
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
|
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
|
||||||
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
|
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
|
||||||
|
|
||||||
// ── Validate ─────────────────────────────────────────────────────────────
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_ValidDraft_NoErrors()
|
public void Validate_ValidDraft_NoErrors()
|
||||||
{
|
{
|
||||||
var s = SessionWith(@"[{ 'Id':'1','items':[
|
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]");
|
||||||
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
|
|
||||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
InvoiceDraftCalculator.Validate(s);
|
InvoiceDraftCalculator.Validate(s);
|
||||||
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
|
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("", "warning")] // missing email → advisory
|
[InlineData("", "warning")]
|
||||||
[InlineData("not-an-email", "error")]
|
[InlineData("not-an-email", "error")]
|
||||||
public void Validate_EmailProblems_AreFlagged(string email, string severity)
|
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;
|
s.New["invoiceemail"] = email;
|
||||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
InvoiceDraftCalculator.Validate(s);
|
InvoiceDraftCalculator.Validate(s);
|
||||||
@@ -150,9 +112,28 @@ public class InvoiceDraftCalculatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_UnknownVatRate_IsWarning()
|
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.RecomputeTotals(s);
|
||||||
InvoiceDraftCalculator.Validate(s);
|
InvoiceDraftCalculator.Validate(s);
|
||||||
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
|
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 System.Threading.Tasks;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Fuchs.Services;
|
using Fuchs.Services;
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using MigraDoc.DocumentObjectModel;
|
using MigraDoc.DocumentObjectModel;
|
||||||
@@ -15,8 +14,8 @@ namespace Fuchs.Tests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
|
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||||
/// without any database, proving the backend-authoritative model behaves correctly
|
/// without a database, proving the backend-authoritative model behaves correctly at
|
||||||
/// end-to-end at the service seam (ADR 0006).
|
/// the service seam (ADR 0006). Blocks use the editor's <c>itm</c>/<c>items</c> shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class InvoiceDraftServiceTests
|
public class InvoiceDraftServiceTests
|
||||||
{
|
{
|
||||||
@@ -32,8 +31,10 @@ public class InvoiceDraftServiceTests
|
|||||||
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
||||||
return Task.FromResult(invoice);
|
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 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[]> 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[]> 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();
|
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()
|
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
|
||||||
{
|
{
|
||||||
var cfg = new ConfigurationBuilder().Build();
|
var cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
|
||||||
var cache = new InvoiceDraftCache(cfg);
|
|
||||||
var inv = new FakeInvoiceService();
|
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);
|
return (svc, inv, cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JObject Payload() => JObject.Parse(@"{
|
private static JObject Payload() => JObject.Parse(@"{
|
||||||
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
'req':[{'Id':'1','items':[
|
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'}],
|
||||||
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
|
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}]
|
||||||
}");
|
}");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -85,18 +85,31 @@ public class InvoiceDraftServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
|
public void ApplyPatch_BlockReplace_RecomputesTotals()
|
||||||
{
|
{
|
||||||
var (svc, _, _) = NewService();
|
var (svc, _, _) = NewService();
|
||||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
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(50m, s2!.Sums.TotalNet);
|
||||||
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
|
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]
|
[Fact]
|
||||||
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
|
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.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||||
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
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 prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
|
||||||
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
|
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
|
||||||
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
|
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
@@ -137,19 +149,207 @@ public class InvoiceDraftServiceTests
|
|||||||
Assert.Equal("19", vatRate.Value);
|
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]
|
[Fact]
|
||||||
public void GetHistory_UnknownToken_IsEmpty()
|
public void GetHistory_UnknownToken_IsEmpty()
|
||||||
{
|
{
|
||||||
var (svc, _, _) = NewService();
|
var (svc, _, _) = NewService();
|
||||||
Assert.Empty(svc.GetHistory("ghost"));
|
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,18 +17,17 @@ public partial class IntranetController
|
|||||||
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
/// <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" });
|
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)
|
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
InvoiceDraftSession session;
|
if (!HasForm("payload"))
|
||||||
if (HasForm("id") && !string.IsNullOrEmpty(Form("id")))
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Draft dopen: from DB draft {InvId} user={User}", Form("id"), UserAccountID);
|
_logger.LogWarning("Draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||||
session = await _invoiceDrafts.OpenFromDraftAsync(Form("id"), UserAccountID, DbSec);
|
return BadRequest400();
|
||||||
}
|
}
|
||||||
else if (HasForm("payload"))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Draft dopen: from payload user={User}", UserAccountID);
|
|
||||||
JObject payload;
|
JObject payload;
|
||||||
try { payload = JObject.Parse(Form("payload")); }
|
try { payload = JObject.Parse(Form("payload")); }
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -36,13 +35,8 @@ public partial class IntranetController
|
|||||||
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||||
return BadRequest400();
|
return BadRequest400();
|
||||||
}
|
}
|
||||||
session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
var session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
||||||
}
|
_logger.LogInformation("Draft dopen: session {Token} (invId={InvId}) user={User}", session.Token, session.InvId, UserAccountID);
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Draft dopen: neither 'id' nor 'payload' supplied user={User}", UserAccountID);
|
|
||||||
return BadRequest400();
|
|
||||||
}
|
|
||||||
// The browser holds the token from this response and fetches dstate directly; there is
|
// 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
|
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
|
||||||
// only subsequent server-side changes.
|
// only subsequent server-side changes.
|
||||||
@@ -125,16 +119,6 @@ public partial class IntranetController
|
|||||||
return await JSONAsync(new { history });
|
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 }
|
// POST inv/dclose — { token } → { ok }
|
||||||
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
|
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 "dpreview": return await HandleDraftPreview(fn, id, code);
|
||||||
case "dsave": return await HandleDraftSave(fn, id, code);
|
case "dsave": return await HandleDraftSave(fn, id, code);
|
||||||
case "dhistory": return await HandleDraftHistory(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);
|
case "dclose": return await HandleDraftClose(fn, id, code);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -9,32 +9,31 @@ namespace Fuchs.Services;
|
|||||||
/// Orchestrates a live, backend-authoritative invoice draft editing session
|
/// Orchestrates a live, backend-authoritative invoice draft editing session
|
||||||
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
|
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
|
||||||
/// open (seed the cache), apply single edits, build the view state, render a PDF
|
/// 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
|
/// preview from the cache, flush to the DB ("Zwischenspeichern") and expose the
|
||||||
/// from the DB) and expose the change history. All totals/VAT are computed by
|
/// change history. All totals/VAT are aggregated by <see cref="InvoiceDraftCalculator"/>
|
||||||
/// <see cref="InvoiceDraftCalculator"/> — the browser never calculates.
|
/// — 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>
|
/// </summary>
|
||||||
public interface IInvoiceDraftService
|
public interface IInvoiceDraftService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds a new cache session for a brand-new draft from the editor's initially
|
/// Seeds a new cache session from the editor's assembled payload
|
||||||
/// assembled payload (<c>admin</c> / <c>new</c> / <c>req</c> blocks). Computes
|
/// (<c>admin</c> / <c>new</c> / <c>req</c> blocks, each block carrying the editor's
|
||||||
/// totals + validation and returns the session (with its fresh token/version).
|
/// <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>
|
/// </summary>
|
||||||
InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId);
|
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>
|
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
|
||||||
InvoiceDraftSession? Get(string token);
|
InvoiceDraftSession? Get(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies one editor change to the cached session: mutates the payload,
|
/// Applies one editor change to the cached session: mutates the payload, re-aggregates
|
||||||
/// re-derives affected item math + totals, re-validates, appends a history entry
|
/// totals, re-validates, appends a history entry and bumps the version. Returns the
|
||||||
/// and bumps the version. Returns the mutated session, or null if the token is unknown.
|
/// mutated session, or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
|
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
|
||||||
|
|
||||||
@@ -45,8 +44,8 @@ public interface IInvoiceDraftService
|
|||||||
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
|
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the cached session to the DB via the existing invoice registration
|
/// Persists the cached session to the DB via the existing invoice registration path
|
||||||
/// path ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
||||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
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>
|
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||||
Document? RenderPreview(string token);
|
Document? RenderPreview(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
|
||||||
/// Discards the session's in-memory changes by reloading it from the DB draft
|
|
||||||
/// (requires a prior flush / an existing <c>InvId</c>). Bumps the version so the
|
|
||||||
/// client refetches. Returns the reloaded session, or null if the token is unknown.
|
|
||||||
/// </summary>
|
|
||||||
Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
|
||||||
|
|
||||||
/// <summary>Removes the session from the cache (explicit close/finalise). Returns true if one was present.</summary>
|
|
||||||
bool Close(string token);
|
bool Close(string token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single editor change posted to <c>inv/dpatch</c>. <see cref="Target"/> names the
|
/// 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
|
/// field/operation (e.g. "email", "p13b", "block.replace"); <see cref="Ref"/> is the block
|
||||||
/// block id it applies to (when relevant); <see cref="Value"/> is the new value.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class InvoiceDraftDelta
|
public sealed class InvoiceDraftDelta
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,44 +1,37 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Web;
|
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MigraDoc.DocumentObjectModel;
|
using MigraDoc.DocumentObjectModel;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using OCORE.security;
|
using OCORE.security;
|
||||||
using OCORE.SQL;
|
|
||||||
using static OCORE.commons;
|
using static OCORE.commons;
|
||||||
using static OCORE.OCORE_dictionaries;
|
using static OCORE.OCORE_dictionaries;
|
||||||
using static OCORE.SQL.sql;
|
|
||||||
|
|
||||||
namespace Fuchs.Services;
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
|
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
|
||||||
/// <see cref="InvoiceDraftSession"/> (via <see cref="IInvoiceDraftCache"/>), applies
|
/// <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"/>
|
/// 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
|
/// registration path — no new persistence. The session stores the editor's own block
|
||||||
/// remains unit-testable.
|
/// shape (<c>itm</c>/<c>items</c> line arrays), which the PDF/persistence already consume,
|
||||||
|
/// so nothing is re-shaped server-side.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||||
{
|
{
|
||||||
private readonly IInvoiceDraftCache _cache;
|
private readonly IInvoiceDraftCache _cache;
|
||||||
private readonly IInvoiceService _invoices;
|
private readonly IInvoiceService _invoices;
|
||||||
private readonly Fuchs_intranet _intranet;
|
|
||||||
private readonly ILogger<InvoiceDraftEditService> _logger;
|
private readonly ILogger<InvoiceDraftEditService> _logger;
|
||||||
|
|
||||||
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
|
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
|
||||||
Fuchs_intranet intranet, ILogger<InvoiceDraftEditService> logger)
|
ILogger<InvoiceDraftEditService> logger)
|
||||||
{
|
{
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_intranet = intranet;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
|
||||||
|
|
||||||
// ── Open ─────────────────────────────────────────────────────────────────
|
// ── Open ─────────────────────────────────────────────────────────────────
|
||||||
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
||||||
{
|
{
|
||||||
@@ -58,17 +51,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
return session;
|
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);
|
public InvoiceDraftSession? Get(string token) => _cache.Get(token);
|
||||||
|
|
||||||
// ── Patch ──────────────────────────────────────────────────────────────────
|
// ── Patch ──────────────────────────────────────────────────────────────────
|
||||||
@@ -118,17 +100,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
|
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
|
||||||
case "p13b":
|
case "p13b":
|
||||||
oldValue = Str(s.Admin["p13b"]);
|
oldValue = Str(s.Admin["p13b"]);
|
||||||
bool next = d.Value != null && d.Value.Type != JTokenType.Null
|
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null
|
||||||
? AsBool(d.Value)
|
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
|
||||||
: !AsBool(s.Admin["p13b"]); // toggle when no explicit value
|
|
||||||
s.Admin["p13b"] = next;
|
|
||||||
return true;
|
return true;
|
||||||
case "item.qty": return SetItem(s, d, "quantityhours", recompute: true, ref oldValue);
|
case "block.replace": return ReplaceBlock(s, d, ref oldValue);
|
||||||
case "item.price": return SetItem(s, d, "net", recompute: true, ref oldValue);
|
|
||||||
case "item.note": return SetItem(s, d, "Note", recompute: false, ref oldValue);
|
|
||||||
case "item.remove": return RemoveItem(s, d, ref oldValue);
|
|
||||||
case "block.combine":
|
|
||||||
case "item.combine": return SetBlockFlag(s, d, "onesum", ref oldValue);
|
|
||||||
case "block.remove": return RemoveBlock(s, d, ref oldValue);
|
case "block.remove": return RemoveBlock(s, d, ref oldValue);
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
@@ -161,31 +136,22 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
return true;
|
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 (d.Value is not JObject nb) return false;
|
||||||
if (item == null) return false;
|
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
|
||||||
oldValue = Str(item[key]);
|
var existing = FindBlock(s, bid);
|
||||||
item[key] = d.Value ?? JValue.CreateString(d.ValueString);
|
if (existing != null)
|
||||||
if (recompute) InvoiceDraftCalculator.RecomputeItem(item);
|
{
|
||||||
return true;
|
oldValue = Str(existing["text"]);
|
||||||
|
existing.Replace(nb);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
private static bool RemoveItem(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
|
||||||
{
|
{
|
||||||
var item = FindItem(s, d.Ref);
|
oldValue = "";
|
||||||
if (item == null) return false;
|
s.Req.Add(nb);
|
||||||
oldValue = Str(item["NameOrNumber"]).ne(Str(item["htmltext"]));
|
|
||||||
item.Remove();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SetBlockFlag(InvoiceDraftSession s, InvoiceDraftDelta d, string key, ref string oldValue)
|
|
||||||
{
|
|
||||||
var block = FindBlock(s, d.Ref);
|
|
||||||
if (block == null) return false;
|
|
||||||
oldValue = Str(block[key]);
|
|
||||||
block[key] = d.Value != null && d.Value.Type != JTokenType.Null ? AsBool(d.Value) : !AsBool(block[key]);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +195,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
_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)
|
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
@@ -258,30 +224,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
return _invoices.GenerateInvoicePdf(fds, draft: true);
|
return _invoices.GenerateInvoicePdf(fds, draft: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
|
||||||
{
|
|
||||||
var session = _cache.Get(token);
|
|
||||||
if (session == null) return null;
|
|
||||||
if (string.IsNullOrEmpty(session.InvId))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Draft {Token} discard requested but never saved — nothing to reload (user={User})", token, userAccountId);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
await LoadDraftIntoAsync(session, session.InvId, userAccountId, dbSec);
|
|
||||||
Refresh(session);
|
|
||||||
session.Version++;
|
|
||||||
session.History.Add(new ChangeHistoryEntry
|
|
||||||
{
|
|
||||||
UserAccountId = userAccountId,
|
|
||||||
Target = "discard",
|
|
||||||
NewValue = "Änderungen verworfen",
|
|
||||||
Version = session.Version
|
|
||||||
});
|
|
||||||
_cache.Set(session);
|
|
||||||
_logger.LogInformation("Draft {Token} discarded, reloaded from DB invoice {InvId} (user={User})", token, session.InvId, userAccountId);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Close(string token) => _cache.Remove(token) != null;
|
public bool Close(string token) => _cache.Remove(token) != null;
|
||||||
|
|
||||||
// ── Internals ──────────────────────────────────────────────────────────────
|
// ── Internals ──────────────────────────────────────────────────────────────
|
||||||
@@ -300,16 +242,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JObject? FindItem(InvoiceDraftSession s, string itemId)
|
/// <summary>
|
||||||
{
|
/// Builds the <see cref="FdsInvoiceData"/> from the session — the server-side equivalent
|
||||||
foreach (var b in s.Req)
|
/// of <c>invcPayload</c>. The session already holds the editor's <c>req</c> block shape
|
||||||
if (b is JObject bo && bo["items"] is JArray items)
|
/// (<c>itm</c>/<c>items</c> line arrays) that registration and the PDF consume, so the
|
||||||
foreach (var it in items)
|
/// blocks pass through unchanged; only the header/total normalisation is applied.
|
||||||
if (it is JObject io && Str(io["Id"]) == itemId) return io;
|
/// </summary>
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Builds the <see cref="FdsInvoiceData"/> from the session — the server-side port of <c>invcPayload</c>.</summary>
|
|
||||||
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
|
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
|
||||||
{
|
{
|
||||||
var adm = (JObject)session.Admin.DeepClone();
|
var adm = (JObject)session.Admin.DeepClone();
|
||||||
@@ -391,96 +329,6 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
return string.Join(",", tokens);
|
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 ─────────────────────────────────────────────────────────
|
// ── token helpers ─────────────────────────────────────────────────────────
|
||||||
private static string Str(JToken? t) =>
|
private static string Str(JToken? t) =>
|
||||||
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
|
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;
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-side, pure port of the invoice totals/VAT math that used to live in the
|
/// Server-side, pure aggregation of an invoice draft's totals/VAT — the authoritative
|
||||||
/// browser (<c>quantChange</c> + <c>invSumUpdate</c> in <c>fis.inv_shared.js</c>).
|
/// replacement for the browser's <c>invSumUpdate</c> footer math (ADR 0006). The user's
|
||||||
/// This is the authoritative calculation for a live draft (ADR 0006): given the
|
/// requirement is that the <b>sums</b> live in the backend cache, not the frontend.
|
||||||
/// editable payload of an <see cref="InvoiceDraftSession"/>, it (re)computes each
|
|
||||||
/// item's line values, aggregates block/rate totals into
|
|
||||||
/// <see cref="InvoiceDraftSession.Sums"/>, and runs the plausibility/consistency
|
|
||||||
/// checks into <see cref="InvoiceDraftSession.ValidationMessages"/>.
|
|
||||||
///
|
///
|
||||||
/// Kept static and free of I/O so it is exhaustively unit-testable — the payoff the
|
/// It reads each block's persisted line contract (<c>block.itm</c> = the editor's <c>co</c>
|
||||||
/// old <c>EVAL_live_invoice_editing.md</c> predicted once the truth moved server-side.
|
/// 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>
|
/// </summary>
|
||||||
public static class InvoiceDraftCalculator
|
public static class InvoiceDraftCalculator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Re-derives a single item's line values from quantity × net price × VAT rate —
|
/// Aggregates every block's line values into the draft's totals — the port of
|
||||||
/// the port of the editor's <c>quantChange</c>. Only applied when an item's
|
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
|
||||||
/// quantity/price actually changes (osum/set/text lines keep their stored values,
|
/// VAT is grouped by the line's rate string (matching the editor's <c>sms.vat</c> map).
|
||||||
/// exactly as the client only ran <c>quantChange</c> on edited quantity rows).
|
|
||||||
/// Mirrors the guard <c>qty > 0 && price > 0</c>.
|
|
||||||
/// </summary>
|
|
||||||
public static void RecomputeItem(JObject item)
|
|
||||||
{
|
|
||||||
int qty = (int)Dec(item["quantityhours"]);
|
|
||||||
decimal net = Dec(item["net"]);
|
|
||||||
decimal vat = RatePercent(Str(item["vat"])) * 0.01m; // "19%"/"19,0%" → 0.19
|
|
||||||
if (qty > 0 && net > 0)
|
|
||||||
{
|
|
||||||
decimal netVal = decimal.Round(qty * net, 2, MidpointRounding.AwayFromZero);
|
|
||||||
decimal vatVal = decimal.Round(qty * net * vat, 2, MidpointRounding.AwayFromZero);
|
|
||||||
item["net_val"] = netVal;
|
|
||||||
item["vat_val"] = vatVal;
|
|
||||||
if (string.Equals(Str(item["Type"]), "service", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
item["svcnet_val"] = netVal;
|
|
||||||
item["svcvat_val"] = vatVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Aggregates all line items into the draft's totals — the port of <c>invSumUpdate</c>'s
|
|
||||||
/// <c>csms</c> accumulation plus the §13b reverse-charge rule (VAT suppressed → gross = net).
|
|
||||||
/// VAT is grouped by the item's rate string (matching the editor's <c>sms.vat</c> map).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void RecomputeTotals(InvoiceDraftSession session)
|
public static void RecomputeTotals(InvoiceDraftSession session)
|
||||||
{
|
{
|
||||||
@@ -58,15 +31,15 @@ public static class InvoiceDraftCalculator
|
|||||||
if (blockTok is not JObject block) continue;
|
if (blockTok is not JObject block) continue;
|
||||||
decimal blockNet = 0;
|
decimal blockNet = 0;
|
||||||
string blockId = Str(block["Id"]);
|
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;
|
if (lineTok is not JObject co) continue;
|
||||||
decimal netVal = Dec(item["net_val"]);
|
decimal netVal = Dec(co["vt"]);
|
||||||
decimal vatVal = Dec(item["vat_val"]);
|
decimal vatVal = Dec(co["vv"]);
|
||||||
decimal svcNet = Dec(item["svcnet_val"]);
|
decimal svcNet = Dec(co["vs"]);
|
||||||
decimal svcVat = Dec(item["svcvat_val"]);
|
decimal svcVat = Dec(co["vsv"]);
|
||||||
|
|
||||||
sums.ServiceNet += svcNet;
|
sums.ServiceNet += svcNet;
|
||||||
sums.ServiceVat += svcVat;
|
sums.ServiceVat += svcVat;
|
||||||
@@ -75,7 +48,7 @@ public static class InvoiceDraftCalculator
|
|||||||
sums.TotalGross += netVal + vatVal;
|
sums.TotalGross += netVal + vatVal;
|
||||||
blockNet += netVal;
|
blockNet += netVal;
|
||||||
|
|
||||||
string rate = NormalizeRate(Str(item["vat"]));
|
string rate = NormalizeRate(Str(co["vat"]));
|
||||||
if (rate.Length > 0)
|
if (rate.Length > 0)
|
||||||
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
|
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
|
||||||
}
|
}
|
||||||
@@ -106,30 +79,23 @@ public static class InvoiceDraftCalculator
|
|||||||
void Add(string field, string sev, string msg) =>
|
void Add(string field, string sev, string msg) =>
|
||||||
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
|
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
|
||||||
|
|
||||||
// Recipient email
|
|
||||||
string email = Str(session.New["invoiceemail"]).Trim();
|
string email = Str(session.New["invoiceemail"]).Trim();
|
||||||
if (email.Length == 0)
|
if (email.Length == 0)
|
||||||
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
|
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
|
||||||
else if (!IsValidEmail(email))
|
else if (!IsValidEmail(email))
|
||||||
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
||||||
|
|
||||||
// Recipient address
|
|
||||||
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||||
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
||||||
|
|
||||||
// At least one priced line
|
|
||||||
if (!HasAnyItem(session))
|
if (!HasAnyItem(session))
|
||||||
Add("items", "error", "Die Rechnung enthält keine Positionen.");
|
Add("items", "error", "Die Rechnung enthält keine Positionen.");
|
||||||
|
|
||||||
// VAT rate sanity (only when not reverse-charge)
|
|
||||||
if (!Flag(session.Admin, "p13b"))
|
if (!Flag(session.Admin, "p13b"))
|
||||||
{
|
|
||||||
foreach (var rate in session.Sums.VatByRate.Keys)
|
foreach (var rate in session.Sums.VatByRate.Keys)
|
||||||
if (!IsKnownVatRate(rate))
|
if (!IsKnownVatRate(rate))
|
||||||
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
|
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
|
||||||
}
|
|
||||||
|
|
||||||
// Negative total
|
|
||||||
if (session.Sums.TotalGross < 0)
|
if (session.Sums.TotalGross < 0)
|
||||||
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
|
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
|
||||||
}
|
}
|
||||||
@@ -138,7 +104,7 @@ public static class InvoiceDraftCalculator
|
|||||||
private static bool HasAnyItem(InvoiceDraftSession session)
|
private static bool HasAnyItem(InvoiceDraftSession session)
|
||||||
{
|
{
|
||||||
foreach (var blockTok in session.Req)
|
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 true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -152,7 +118,7 @@ public static class InvoiceDraftCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string Str(JToken? token) =>
|
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)
|
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";
|
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)
|
private static bool IsValidEmail(string email)
|
||||||
{
|
{
|
||||||
int at = email.IndexOf('@');
|
int at = email.IndexOf('@');
|
||||||
|
|||||||
@@ -99,11 +99,202 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||||
}
|
}
|
||||||
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
|
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
||||||
|
}
|
||||||
if (booln(r, false) === true) {
|
if (booln(r, false) === true) {
|
||||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
|
The server holds the truth for an invoice draft in an in-memory session; this
|
||||||
|
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||||
|
validation from the authoritative server state. It is invoice-only: reminders
|
||||||
|
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
|
||||||
|
so they keep the legacy stateless flow untouched. */
|
||||||
|
$inv.d = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
|
||||||
|
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
|
||||||
|
hashes: function () {
|
||||||
|
let bai = $inv.d.tbl().data('bai') || [], h = {};
|
||||||
|
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||||
|
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.d.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||||
|
$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) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -499,6 +690,8 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
||||||
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -713,6 +906,13 @@ $inv.invSumUpdate = function () {
|
|||||||
}
|
}
|
||||||
tbl.data('sms', sms);
|
tbl.data('sms', sms);
|
||||||
tbl.data('bai', ba);
|
tbl.data('bai', ba);
|
||||||
|
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||||
|
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||||
|
model to the server session and switch to server-driven totals from then on. */
|
||||||
|
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
|
||||||
|
tbl.data('dseeding', true);
|
||||||
|
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
$inv.worknotes = function (rx) {
|
$inv.worknotes = function (rx) {
|
||||||
let wn = '';
|
let wn = '';
|
||||||
@@ -800,7 +1000,13 @@ $inv.rendersrq = function () {
|
|||||||
$$.tdc('currency isumval', istr);
|
$$.tdc('currency isumval', istr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); };
|
$inv.t_fds_inv = () => {
|
||||||
|
let tbl = $('div.invoice_layout table.invi');
|
||||||
|
tbl.trigger('fds.inv');
|
||||||
|
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||||
|
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||||
|
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||||
|
};
|
||||||
$inv.sedit = () => {
|
$inv.sedit = () => {
|
||||||
$inv.sprev(true);
|
$inv.sprev(true);
|
||||||
};
|
};
|
||||||
@@ -855,6 +1061,7 @@ $inv.sp13b = () => {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
tbl.trigger('fds.inv');
|
tbl.trigger('fds.inv');
|
||||||
|
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
|
||||||
};
|
};
|
||||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||||
@@ -909,6 +1116,7 @@ $inv.setSetmode = (mode) => {
|
|||||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||||
d.inv.InvoiceOptions = opts.join(',');
|
d.inv.InvoiceOptions = opts.join(',');
|
||||||
|
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||||
};
|
};
|
||||||
$inv.sctp = () => {
|
$inv.sctp = () => {
|
||||||
let flds = $invcol.ctp;
|
let flds = $invcol.ctp;
|
||||||
@@ -925,6 +1133,7 @@ $inv.sctp = () => {
|
|||||||
cvo.contactEmail = response.email;
|
cvo.contactEmail = response.email;
|
||||||
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
||||||
l.find('.ctpfrm').text(ne(response.name, response.email));
|
l.find('.ctpfrm').text(ne(response.name, response.email));
|
||||||
|
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
|
||||||
|
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
@@ -949,82 +1158,12 @@ $inv.invcPayload = function (d) {
|
|||||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||||
};
|
};
|
||||||
$inv.ssave = () => {
|
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||||
$inv.t_fds_inv();
|
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
|
||||||
l.aC('freeze');
|
unchanged and is invoked from the preview modal's confirm handler. */
|
||||||
$ocms.postXT({
|
$inv.ssave = () => { $inv.d.save(); };
|
||||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
$inv.sprev = (change) => { $inv.d.preview(); };
|
||||||
$inv.cntInv({ id: response.id });
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.sprev = (change) => {
|
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
|
||||||
change = bool(change, false);
|
|
||||||
$inv.t_fds_inv();
|
|
||||||
l.aC('freeze');
|
|
||||||
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
|
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
|
||||||
if (invtp > 10) {
|
|
||||||
$$.dc('note warn', c).text($ict.tpe);
|
|
||||||
}
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
|
|
||||||
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
|
|
||||||
}
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
if (cresp.hasFile === true) {
|
|
||||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
|
||||||
}
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sdel'), data: {
|
|
||||||
id: invid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.rReload = () => {
|
$inv.rReload = () => {
|
||||||
try {
|
try {
|
||||||
let s = $('#listframe ul.rql:first').data();
|
let s = $('#listframe ul.rql:first').data();
|
||||||
|
|||||||
@@ -399,6 +399,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
|
||||||
|
rendered above the editor, and the change-history dialog table. */
|
||||||
|
.edit_frm .invoice_layout .dvalidation {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
|
||||||
|
&.hidden { display: none; }
|
||||||
|
|
||||||
|
.dvmsg {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 4px solid #999;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
&.error { border-left-color: #c0392b; background: #fdecea; color: #922; }
|
||||||
|
&.warning { border-left-color: #e0a800; background: #fff8e1; color: #7a5c00; }
|
||||||
|
&.info { border-left-color: #3498db; background: #eaf4fb; color: #1c5a82; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dhist {
|
||||||
|
.invtbl { width: 100%; }
|
||||||
|
td, th { padding: 0.3rem 0.6rem; text-align: left; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #fafafa; }
|
||||||
|
}
|
||||||
|
|
||||||
.modal-body .lstfrm {
|
.modal-body .lstfrm {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -405,6 +405,50 @@ table.if td.num {
|
|||||||
animation: fis_spin 0.8s linear infinite;
|
animation: fis_spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
|
||||||
|
rendered above the editor, and the change-history dialog table. */
|
||||||
|
.edit_frm .invoice_layout .dvalidation {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 4px solid #999;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
|
||||||
|
border-left-color: #c0392b;
|
||||||
|
background: #fdecea;
|
||||||
|
color: #922;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
|
||||||
|
border-left-color: #e0a800;
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #7a5c00;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
|
||||||
|
border-left-color: #3498db;
|
||||||
|
background: #eaf4fb;
|
||||||
|
color: #1c5a82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dhist .invtbl {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.dhist td, .dhist th {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.dhist tbody tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-body .lstfrm {
|
.modal-body .lstfrm {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+216
-77
@@ -646,11 +646,202 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||||
}
|
}
|
||||||
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
|
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
||||||
|
}
|
||||||
if (booln(r, false) === true) {
|
if (booln(r, false) === true) {
|
||||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
|
The server holds the truth for an invoice draft in an in-memory session; this
|
||||||
|
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||||
|
validation from the authoritative server state. It is invoice-only: reminders
|
||||||
|
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
|
||||||
|
so they keep the legacy stateless flow untouched. */
|
||||||
|
$inv.d = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
|
||||||
|
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
|
||||||
|
hashes: function () {
|
||||||
|
let bai = $inv.d.tbl().data('bai') || [], h = {};
|
||||||
|
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||||
|
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.d.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||||
|
$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) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -1046,6 +1237,8 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
||||||
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -1260,6 +1453,13 @@ $inv.invSumUpdate = function () {
|
|||||||
}
|
}
|
||||||
tbl.data('sms', sms);
|
tbl.data('sms', sms);
|
||||||
tbl.data('bai', ba);
|
tbl.data('bai', ba);
|
||||||
|
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||||
|
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||||
|
model to the server session and switch to server-driven totals from then on. */
|
||||||
|
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
|
||||||
|
tbl.data('dseeding', true);
|
||||||
|
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
$inv.worknotes = function (rx) {
|
$inv.worknotes = function (rx) {
|
||||||
let wn = '';
|
let wn = '';
|
||||||
@@ -1347,7 +1547,13 @@ $inv.rendersrq = function () {
|
|||||||
$$.tdc('currency isumval', istr);
|
$$.tdc('currency isumval', istr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); };
|
$inv.t_fds_inv = () => {
|
||||||
|
let tbl = $('div.invoice_layout table.invi');
|
||||||
|
tbl.trigger('fds.inv');
|
||||||
|
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||||
|
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||||
|
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||||
|
};
|
||||||
$inv.sedit = () => {
|
$inv.sedit = () => {
|
||||||
$inv.sprev(true);
|
$inv.sprev(true);
|
||||||
};
|
};
|
||||||
@@ -1402,6 +1608,7 @@ $inv.sp13b = () => {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
tbl.trigger('fds.inv');
|
tbl.trigger('fds.inv');
|
||||||
|
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
|
||||||
};
|
};
|
||||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||||
@@ -1456,6 +1663,7 @@ $inv.setSetmode = (mode) => {
|
|||||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||||
d.inv.InvoiceOptions = opts.join(',');
|
d.inv.InvoiceOptions = opts.join(',');
|
||||||
|
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||||
};
|
};
|
||||||
$inv.sctp = () => {
|
$inv.sctp = () => {
|
||||||
let flds = $invcol.ctp;
|
let flds = $invcol.ctp;
|
||||||
@@ -1472,6 +1680,7 @@ $inv.sctp = () => {
|
|||||||
cvo.contactEmail = response.email;
|
cvo.contactEmail = response.email;
|
||||||
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
||||||
l.find('.ctpfrm').text(ne(response.name, response.email));
|
l.find('.ctpfrm').text(ne(response.name, response.email));
|
||||||
|
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
|
||||||
|
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
@@ -1496,82 +1705,12 @@ $inv.invcPayload = function (d) {
|
|||||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||||
};
|
};
|
||||||
$inv.ssave = () => {
|
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||||
$inv.t_fds_inv();
|
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
|
||||||
l.aC('freeze');
|
unchanged and is invoked from the preview modal's confirm handler. */
|
||||||
$ocms.postXT({
|
$inv.ssave = () => { $inv.d.save(); };
|
||||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
$inv.sprev = (change) => { $inv.d.preview(); };
|
||||||
$inv.cntInv({ id: response.id });
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.sprev = (change) => {
|
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
|
||||||
change = bool(change, false);
|
|
||||||
$inv.t_fds_inv();
|
|
||||||
l.aC('freeze');
|
|
||||||
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
|
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
|
||||||
if (invtp > 10) {
|
|
||||||
$$.dc('note warn', c).text($ict.tpe);
|
|
||||||
}
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
|
|
||||||
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
|
|
||||||
}
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
if (cresp.hasFile === true) {
|
|
||||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
|
||||||
}
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sdel'), data: {
|
|
||||||
id: invid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.rReload = () => {
|
$inv.rReload = () => {
|
||||||
try {
|
try {
|
||||||
let s = $('#listframe ul.rql:first').data();
|
let s = $('#listframe ul.rql:first').data();
|
||||||
|
|||||||
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;
|
animation: fis_spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
|
||||||
|
rendered above the editor, and the change-history dialog table. */
|
||||||
|
.edit_frm .invoice_layout .dvalidation {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 4px solid #999;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
|
||||||
|
border-left-color: #c0392b;
|
||||||
|
background: #fdecea;
|
||||||
|
color: #922;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
|
||||||
|
border-left-color: #e0a800;
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #7a5c00;
|
||||||
|
}
|
||||||
|
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
|
||||||
|
border-left-color: #3498db;
|
||||||
|
background: #eaf4fb;
|
||||||
|
color: #1c5a82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dhist .invtbl {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.dhist td, .dhist th {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.dhist tbody tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-body .lstfrm {
|
.modal-body .lstfrm {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+216
-77
@@ -627,11 +627,202 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||||
}
|
}
|
||||||
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
|
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
||||||
|
}
|
||||||
if (booln(r, false) === true) {
|
if (booln(r, false) === true) {
|
||||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
|
The server holds the truth for an invoice draft in an in-memory session; this
|
||||||
|
object seeds it, sends single edits as deltas, and renders the totals footer +
|
||||||
|
validation from the authoritative server state. It is invoice-only: reminders
|
||||||
|
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
|
||||||
|
so they keep the legacy stateless flow untouched. */
|
||||||
|
$inv.d = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
|
||||||
|
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
|
||||||
|
hashes: function () {
|
||||||
|
let bai = $inv.d.tbl().data('bai') || [], h = {};
|
||||||
|
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||||
|
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.d.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||||
|
$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) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -1027,6 +1218,8 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
||||||
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -1241,6 +1434,13 @@ $inv.invSumUpdate = function () {
|
|||||||
}
|
}
|
||||||
tbl.data('sms', sms);
|
tbl.data('sms', sms);
|
||||||
tbl.data('bai', ba);
|
tbl.data('bai', ba);
|
||||||
|
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||||
|
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||||
|
model to the server session and switch to server-driven totals from then on. */
|
||||||
|
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
|
||||||
|
tbl.data('dseeding', true);
|
||||||
|
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
$inv.worknotes = function (rx) {
|
$inv.worknotes = function (rx) {
|
||||||
let wn = '';
|
let wn = '';
|
||||||
@@ -1328,7 +1528,13 @@ $inv.rendersrq = function () {
|
|||||||
$$.tdc('currency isumval', istr);
|
$$.tdc('currency isumval', istr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); };
|
$inv.t_fds_inv = () => {
|
||||||
|
let tbl = $('div.invoice_layout table.invi');
|
||||||
|
tbl.trigger('fds.inv');
|
||||||
|
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||||
|
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||||
|
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||||
|
};
|
||||||
$inv.sedit = () => {
|
$inv.sedit = () => {
|
||||||
$inv.sprev(true);
|
$inv.sprev(true);
|
||||||
};
|
};
|
||||||
@@ -1383,6 +1589,7 @@ $inv.sp13b = () => {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
tbl.trigger('fds.inv');
|
tbl.trigger('fds.inv');
|
||||||
|
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
|
||||||
};
|
};
|
||||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||||
@@ -1437,6 +1644,7 @@ $inv.setSetmode = (mode) => {
|
|||||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||||
d.inv.InvoiceOptions = opts.join(',');
|
d.inv.InvoiceOptions = opts.join(',');
|
||||||
|
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||||
};
|
};
|
||||||
$inv.sctp = () => {
|
$inv.sctp = () => {
|
||||||
let flds = $invcol.ctp;
|
let flds = $invcol.ctp;
|
||||||
@@ -1453,6 +1661,7 @@ $inv.sctp = () => {
|
|||||||
cvo.contactEmail = response.email;
|
cvo.contactEmail = response.email;
|
||||||
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
|
||||||
l.find('.ctpfrm').text(ne(response.name, response.email));
|
l.find('.ctpfrm').text(ne(response.name, response.email));
|
||||||
|
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
|
||||||
|
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
@@ -1477,82 +1686,12 @@ $inv.invcPayload = function (d) {
|
|||||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||||
};
|
};
|
||||||
$inv.ssave = () => {
|
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||||
$inv.t_fds_inv();
|
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
|
||||||
l.aC('freeze');
|
unchanged and is invoked from the preview modal's confirm handler. */
|
||||||
$ocms.postXT({
|
$inv.ssave = () => { $inv.d.save(); };
|
||||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
$inv.sprev = (change) => { $inv.d.preview(); };
|
||||||
$inv.cntInv({ id: response.id });
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.sprev = (change) => {
|
|
||||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
|
||||||
change = bool(change, false);
|
|
||||||
$inv.t_fds_inv();
|
|
||||||
l.aC('freeze');
|
|
||||||
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
|
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
|
||||||
if (invtp > 10) {
|
|
||||||
$$.dc('note warn', c).text($ict.tpe);
|
|
||||||
}
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
|
|
||||||
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
|
|
||||||
}
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
if (cresp.hasFile === true) {
|
|
||||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
|
||||||
}
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('req/sdel'), data: {
|
|
||||||
id: invid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, error: () => {
|
|
||||||
alert($ict.eis);
|
|
||||||
}, complete: () => {
|
|
||||||
l.rC('freeze');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$inv.rReload = () => {
|
$inv.rReload = () => {
|
||||||
try {
|
try {
|
||||||
let s = $('#listframe ul.rql:first').data();
|
let s = $('#listframe ul.rql:first').data();
|
||||||
|
|||||||
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