Compare commits
5
Commits
d0002ccdad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b94b05c2 | ||
|
|
4443bba63c | ||
|
|
4145ab699c | ||
|
|
e2faf52cd2 | ||
|
|
546648533b |
@@ -0,0 +1,388 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The editor fires patches, state fetches, previews and saves concurrently for one draft token. The
|
||||||
|
/// session payload (<c>JArray</c>/<c>JObject</c>, history list) is not thread-safe, so the draft
|
||||||
|
/// services serialise every access on <c>SyncRoot</c> and hand out snapshots. Regression for the
|
||||||
|
/// production "Collection was modified" crash in <c>InvoiceSetStructure.RecomputeLevels</c>
|
||||||
|
/// (inv/dpatch, 2026-10-01). Flushes are additionally serialised on <c>FlushGate</c>: the
|
||||||
|
/// create/update decision in <c>FlushToDbAsync</c> is a read-then-write across an await, so two
|
||||||
|
/// overlapping saves of a new draft would otherwise both create a DB row.
|
||||||
|
/// </summary>
|
||||||
|
public class DraftSessionConcurrencyTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registration double: records every call, hands out a fresh id per <b>create</b> and echoes the
|
||||||
|
/// given id for an update — like the DB, so a duplicate create is visible as a second distinct id.
|
||||||
|
/// <see cref="Block"/>, when set, parks the first registration until released, which lets a test
|
||||||
|
/// pin down the exact interleaving instead of hoping for one.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeInvoiceService : IInvoiceService
|
||||||
|
{
|
||||||
|
private int _ids;
|
||||||
|
public readonly ConcurrentQueue<(bool change, string invId)> Calls = new();
|
||||||
|
public TaskCompletionSource? Block;
|
||||||
|
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
public Exception? Throw;
|
||||||
|
|
||||||
|
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Calls.Enqueue((change, invId));
|
||||||
|
Entered.TrySetResult();
|
||||||
|
if (Block != null) { var b = Block; Block = null; await b.Task; }
|
||||||
|
if (Throw != null) { var ex = Throw; Throw = null; throw ex; }
|
||||||
|
string id = change ? invId : "INV" + Interlocked.Increment(ref _ids);
|
||||||
|
invoice.InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) => Task.FromResult(new Document());
|
||||||
|
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeReminderService : IReminderService
|
||||||
|
{
|
||||||
|
private int _ids;
|
||||||
|
public readonly ConcurrentQueue<(bool change, string remId)> Calls = new();
|
||||||
|
public TaskCompletionSource? Block;
|
||||||
|
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Calls.Enqueue((change, remId));
|
||||||
|
Entered.TrySetResult();
|
||||||
|
if (Block != null) { var b = Block; Block = null; await b.Task; }
|
||||||
|
string id = change ? remId : "REM" + Interlocked.Increment(ref _ids);
|
||||||
|
reminder.ReminderRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
|
||||||
|
return reminder;
|
||||||
|
}
|
||||||
|
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) => new();
|
||||||
|
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (InvoiceDraftEditService svc, FakeInvoiceService inv) NewInvoiceService()
|
||||||
|
{
|
||||||
|
var inv = new FakeInvoiceService();
|
||||||
|
return (new InvoiceDraftEditService(new InvoiceDraftCache(new ConfigurationBuilder().Build()), inv, NullLogger<InvoiceDraftEditService>.Instance), inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ReminderDraftEditService svc, FakeReminderService rem) NewReminderService()
|
||||||
|
{
|
||||||
|
var rem = new FakeReminderService();
|
||||||
|
return (new ReminderDraftEditService(new ReminderDraftCache(new ConfigurationBuilder().Build()), rem, NullLogger<ReminderDraftEditService>.Instance), rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject InvoicePayload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'set','vt':0,'vv':0,'vat':'19%'},
|
||||||
|
{'id':'901','typ':'material','vt':100,'vv':19,'vat':'19%','SetItmId':'900'}],
|
||||||
|
'items':[{'id':'900','type':'set','total_net':0,'vat':'19%'},{'id':'901','type':'material','total_net':100,'vat':'19%'}]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
private static JObject ReminderPayload() => JObject.Parse(@"{
|
||||||
|
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
|
||||||
|
}");
|
||||||
|
|
||||||
|
private static JObject Block(string id, decimal net) => JObject.Parse(
|
||||||
|
"{'Id':'" + id + "','text':'Auftrag " + id + "','itm':[{'id':'i" + id + "','typ':'material','vt':" + net + ",'vv':0,'vat':'19%'}]," +
|
||||||
|
"'items':[{'id':'i" + id + "','type':'material','total_net':" + net + ",'vat':'19%'}]}");
|
||||||
|
|
||||||
|
/// <summary>Runs <paramref name="writers"/> and <paramref name="readers"/> concurrently from a common start gate.</summary>
|
||||||
|
private static async Task RunConcurrently(IEnumerable<Action> writers, IEnumerable<Action> readers)
|
||||||
|
{
|
||||||
|
using var gate = new ManualResetEventSlim(false);
|
||||||
|
var tasks = writers.Concat(readers)
|
||||||
|
.Select(a => Task.Factory.StartNew(() => { gate.Wait(); a(); }, TaskCreationOptions.LongRunning))
|
||||||
|
.ToList();
|
||||||
|
gate.Set();
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WithTimeout(Task t) => t.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
private static Task<T> WithTimeout<T>(Task<T> t) => t.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
// ── payload lock ───────────────────────────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("block.replace")] // inserts new blocks → s.Req.Add (the crash: RecomputeLevels enumerating Req)
|
||||||
|
[InlineData("block.order")] // s.Req.Clear() + re-add while others enumerate
|
||||||
|
public async Task ApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch(string target)
|
||||||
|
{
|
||||||
|
const int patches = 200;
|
||||||
|
var (svc, _) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
if (target == "block.order")
|
||||||
|
for (int i = 0; i < 4; i++) svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "b" + i, Value = Block("b" + i, 10) });
|
||||||
|
int baseVersion = s.Version;
|
||||||
|
|
||||||
|
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
|
||||||
|
{
|
||||||
|
var delta = target == "block.replace"
|
||||||
|
? new InvoiceDraftDelta { Target = target, Ref = "n" + i, Value = Block("n" + i, 1) }
|
||||||
|
: new InvoiceDraftDelta { Target = target, Value = new JArray(i % 2 == 0 ? new[] { "b3", "b2", "1" } : new[] { "1", "b0", "b1" }) };
|
||||||
|
Assert.NotNull(svc.ApplyPatch(s.Token, delta));
|
||||||
|
});
|
||||||
|
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
|
||||||
|
{
|
||||||
|
for (int k = 0; k < 5; k++)
|
||||||
|
{
|
||||||
|
JsonConvert.SerializeObject(svc.BuildState(s)); // what JSONAsync does after BuildState returns
|
||||||
|
_ = svc.GetHistory(s.Token).Count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await RunConcurrently(writers, readers);
|
||||||
|
|
||||||
|
// Every applied patch bumps the version exactly once and leaves one history entry (no lost
|
||||||
|
// updates). A reorder to the order that is already current is a no-op by design, so for
|
||||||
|
// block.order fewer than `patches` may apply.
|
||||||
|
Assert.Equal(s.Version, svc.GetHistory(s.Token).Count);
|
||||||
|
if (target == "block.order")
|
||||||
|
{
|
||||||
|
Assert.InRange(s.Version, baseVersion + 1, baseVersion + patches);
|
||||||
|
Assert.Equal(5, s.Req.Count); // reorders never lose or duplicate a block
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Equal(baseVersion + patches, s.Version);
|
||||||
|
Assert.Equal(1 + patches, s.Req.Count);
|
||||||
|
Assert.Equal(100m + patches, s.Sums.TotalNet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenderPreviewAndFlush_ConcurrentWithPatches_NeverThrowAndCreateExactlyOnce()
|
||||||
|
{
|
||||||
|
var (svc, inv) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
var results = new ConcurrentBag<DraftFlushResult<FdsInvoiceData>>();
|
||||||
|
|
||||||
|
var writers = Enumerable.Range(0, 100).Select<int, Action>(i => () =>
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "n" + i, Value = Block("n" + i, 1) }));
|
||||||
|
var readers = Enumerable.Range(0, 20).Select<int, Action>(i => () =>
|
||||||
|
{
|
||||||
|
Assert.NotNull(svc.RenderPreviewAsync(s.Token).GetAwaiter().GetResult());
|
||||||
|
results.Add(svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()!);
|
||||||
|
});
|
||||||
|
|
||||||
|
await RunConcurrently(writers, readers);
|
||||||
|
|
||||||
|
Assert.Equal(101, s.Req.Count);
|
||||||
|
// 20 overlapping saves of a brand-new draft: exactly one fds__createInvoice, every other save
|
||||||
|
// an update of that row — never a second orphaned invoice.
|
||||||
|
Assert.Equal(20, inv.Calls.Count);
|
||||||
|
Assert.Single(inv.Calls, c => !c.change);
|
||||||
|
Assert.Equal("INV1", s.InvId);
|
||||||
|
Assert.All(inv.Calls.Where(c => c.change), c => Assert.Equal("INV1", c.invId));
|
||||||
|
Assert.All(results, r => Assert.Equal("INV1", r.Data.Id));
|
||||||
|
Assert.Single(results, r => !r.Updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── flush gate ─────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
|
||||||
|
{
|
||||||
|
var (svc, inv) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
inv.Block = block; // the fake clears Block once it has parked on it
|
||||||
|
|
||||||
|
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
await WithTimeout(inv.Entered.Task); // first save is inside the DB registration, InvId still empty
|
||||||
|
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
// While the first registration is in flight the second save must not reach the registration at
|
||||||
|
// all (it would see the empty InvId and create a second row). A false pass here is possible
|
||||||
|
// only under pathological scheduling; a false failure is not.
|
||||||
|
await Task.Delay(150);
|
||||||
|
Assert.False(second.IsCompleted);
|
||||||
|
Assert.Single(inv.Calls);
|
||||||
|
|
||||||
|
block.SetResult();
|
||||||
|
var r1 = (await WithTimeout(first))!;
|
||||||
|
var r2 = (await WithTimeout(second))!;
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (false, ""), (true, "INV1") }, inv.Calls.ToArray());
|
||||||
|
Assert.False(r1.Updated); Assert.Equal("INV1", r1.Data.Id);
|
||||||
|
Assert.True(r2.Updated); Assert.Equal("INV1", r2.Data.Id);
|
||||||
|
Assert.Equal("INV1", s.InvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_SecondSaveCarriesPatchesMadeDuringFirst()
|
||||||
|
{
|
||||||
|
var (svc, inv) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
inv.Block = block; // the fake clears Block once it has parked on it
|
||||||
|
|
||||||
|
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
await WithTimeout(inv.Entered.Task);
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" }); // edits are not blocked by a running save
|
||||||
|
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
block.SetResult();
|
||||||
|
|
||||||
|
var r1 = (await WithTimeout(first))!;
|
||||||
|
var r2 = (await WithTimeout(second))!;
|
||||||
|
|
||||||
|
// The waiting save snapshots after it acquires the gate, so it persists the newer state —
|
||||||
|
// serialising never makes a save write stale data.
|
||||||
|
Assert.Equal("a@b.de", r1.Data.RawInvoiceEmail);
|
||||||
|
Assert.Equal("neu@x.de", r2.Data.RawInvoiceEmail);
|
||||||
|
Assert.True(r2.Updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_RegistrationThrows_ReleasesGateAndNextSaveCreates()
|
||||||
|
{
|
||||||
|
var (svc, inv) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
inv.Throw = new InvalidOperationException("db down");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.FlushToDbAsync(s.Token, "user1", null!));
|
||||||
|
|
||||||
|
// The gate must be released by the failed save; the next one gets through and, since no id
|
||||||
|
// was committed, still runs as the create.
|
||||||
|
var r = (await WithTimeout(svc.FlushToDbAsync(s.Token, "user1", null!)))!;
|
||||||
|
Assert.False(r.Updated);
|
||||||
|
Assert.Equal("INV1", r.Data.Id);
|
||||||
|
Assert.Equal("INV1", s.InvId);
|
||||||
|
Assert.Equal(2, inv.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_UnknownToken_ReturnsNullWithoutRegistering()
|
||||||
|
{
|
||||||
|
var (svc, inv) = NewInvoiceService();
|
||||||
|
Assert.Null(await svc.FlushToDbAsync("ghost", "user1", null!));
|
||||||
|
Assert.Empty(inv.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderFlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewReminderService();
|
||||||
|
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||||
|
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
rem.Block = block; // the fake clears Block once it has parked on it
|
||||||
|
|
||||||
|
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
await WithTimeout(rem.Entered.Task);
|
||||||
|
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
await Task.Delay(150);
|
||||||
|
Assert.False(second.IsCompleted);
|
||||||
|
Assert.Single(rem.Calls);
|
||||||
|
|
||||||
|
block.SetResult();
|
||||||
|
var r1 = (await WithTimeout(first))!;
|
||||||
|
var r2 = (await WithTimeout(second))!;
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (false, ""), (true, "REM1") }, rem.Calls.ToArray());
|
||||||
|
Assert.False(r1.Updated); Assert.Equal("REM1", r1.Data.Id);
|
||||||
|
Assert.True(r2.Updated); Assert.Equal("REM1", r2.Data.Id);
|
||||||
|
Assert.Equal("REM1", s.RemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderFlushToDbAsync_ManyConcurrentSaves_CreateExactlyOnce()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewReminderService();
|
||||||
|
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||||
|
|
||||||
|
await RunConcurrently(
|
||||||
|
Enumerable.Range(0, 20).Select<int, Action>(_ => () => svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()),
|
||||||
|
Enumerable.Empty<Action>());
|
||||||
|
|
||||||
|
Assert.Equal(20, rem.Calls.Count);
|
||||||
|
Assert.Single(rem.Calls, c => !c.change);
|
||||||
|
Assert.Equal("REM1", s.RemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── snapshots ──────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void BuildState_IsSnapshot_LaterPatchDoesNotChangeReturnedPayload()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
string before = JsonConvert.SerializeObject(svc.BuildState(s));
|
||||||
|
var state = svc.BuildState(s);
|
||||||
|
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "x", Value = Block("x", 5) });
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" });
|
||||||
|
|
||||||
|
Assert.Equal(before, JsonConvert.SerializeObject(state));
|
||||||
|
Assert.Contains("neu@x.de", JsonConvert.SerializeObject(svc.BuildState(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHistory_IsSnapshot_LaterPatchDoesNotGrowReturnedList()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewInvoiceService();
|
||||||
|
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "x@y.de" });
|
||||||
|
var history = svc.GetHistory(s.Token);
|
||||||
|
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "z@y.de" });
|
||||||
|
|
||||||
|
Assert.Single(history);
|
||||||
|
Assert.Equal(2, svc.GetHistory(s.Token).Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("unknown-token")]
|
||||||
|
public void GetHistory_UnknownToken_ReturnsEmpty(string token)
|
||||||
|
{
|
||||||
|
Assert.Empty(NewInvoiceService().svc.GetHistory(token));
|
||||||
|
Assert.Empty(NewReminderService().svc.GetHistory(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch()
|
||||||
|
{
|
||||||
|
const int patches = 200;
|
||||||
|
var (svc, _) = NewReminderService();
|
||||||
|
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||||
|
|
||||||
|
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
|
||||||
|
Assert.NotNull(svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = i % 2 == 0 ? "subject" : "amount_payed", Value = i % 2 == 0 ? "S" + i : "1" })));
|
||||||
|
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
|
||||||
|
{
|
||||||
|
for (int k = 0; k < 5; k++)
|
||||||
|
{
|
||||||
|
JsonConvert.SerializeObject(svc.BuildState(s));
|
||||||
|
_ = svc.GetHistory(s.Token).Count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await RunConcurrently(writers, readers);
|
||||||
|
|
||||||
|
Assert.Equal(patches, s.Version);
|
||||||
|
Assert.Equal(patches, svc.GetHistory(s.Token).Count);
|
||||||
|
Assert.Equal(118m, s.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
|
|||||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("INV42", result!.Id);
|
Assert.Equal("INV42", result!.Data.Id);
|
||||||
|
Assert.False(result.Updated);
|
||||||
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);
|
||||||
|
|
||||||
@@ -680,9 +681,10 @@ public class InvoiceDraftServiceTests
|
|||||||
payload["invid"] = "INV7";
|
payload["invid"] = "INV7";
|
||||||
var s = svc.OpenFromPayload(payload, "user1");
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.True(inv.LastChange); // prior InvId → update path
|
Assert.True(inv.LastChange); // prior InvId → update path
|
||||||
|
Assert.True(result!.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stored Leistungsdatum / -zeitraum must reach the invoice PDF's admin block (it was never
|
||||||
|
/// passed to the text blocks, so every invoice printed "Leistungszeitraum: -").
|
||||||
|
/// </summary>
|
||||||
|
public class InvoicePdfProvisionPeriodTests
|
||||||
|
{
|
||||||
|
private sealed class NoHttp : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2026-06-18", "18.06.2026")]
|
||||||
|
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026")]
|
||||||
|
[InlineData("18.06.2026", "18.06.2026")]
|
||||||
|
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026")]
|
||||||
|
[InlineData("R2026-0258", "R2026-0258")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData(null, "")]
|
||||||
|
[InlineData("nach Absprache", "nach Absprache")]
|
||||||
|
public void ProvisionPeriodForDisplay_MixedFormats_ShowsGermanDates(string? input, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, InvoiceService.ProvisionPeriodForDisplay(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
|
||||||
|
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||||
|
[InlineData("2026-06-18", "18.06.2026", "Leistungsdatum")]
|
||||||
|
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||||
|
[InlineData(" 18.06.2026 ", "18.06.2026", "Leistungsdatum")]
|
||||||
|
[InlineData("", "", "Leistungszeitraum")]
|
||||||
|
[InlineData(null, "", "Leistungszeitraum")]
|
||||||
|
public void ApplyProvisionPeriod_StoredValue_SetsGermanValueAndMatchingLabel(string? stored, string expectedValue, string expectedLabel)
|
||||||
|
{
|
||||||
|
var tb = new FuchsPdf.FdsTextBlocks();
|
||||||
|
|
||||||
|
InvoiceService.ApplyProvisionPeriod(tb, stored);
|
||||||
|
|
||||||
|
Assert.Equal(expectedValue, tb.ProvisionPeriod);
|
||||||
|
Assert.Equal(expectedLabel, tb.AdminProvLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
|
||||||
|
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||||
|
[InlineData(null, "", "Leistungszeitraum")]
|
||||||
|
public async Task GenerateInvoicePdfAsync_StoredProvisionPeriod_ReachesAdminBlock(string? stored, string expectedValue, string expectedLabel)
|
||||||
|
{
|
||||||
|
FuchsPdf.FdsTextBlocks? captured = null;
|
||||||
|
var pdf = new Mock<IPdfService>();
|
||||||
|
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||||
|
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => captured = tb)
|
||||||
|
.ReturnsAsync(new Document());
|
||||||
|
var erechnung = new Mock<IERechnungService>();
|
||||||
|
erechnung.SetupGet(e => e.Enabled).Returns(false);
|
||||||
|
var config = new ConfigurationBuilder().Build();
|
||||||
|
var svc = new InvoiceService(new Fuchs_intranet(config), pdf.Object, new Mock<IBlobStorageService>().Object,
|
||||||
|
new Mock<IEventService>().Object, erechnung.Object, new NoHttp(), config, NullLogger<InvoiceService>.Instance);
|
||||||
|
var reg = new Dictionary<string, object?> { ["Id"] = "W7VAL", ["InvoiceId"] = "R2026-0258" };
|
||||||
|
if (stored != null) reg["ProvisionPeriod"] = stored;
|
||||||
|
var invoice = new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(reg) };
|
||||||
|
|
||||||
|
await svc.GenerateInvoicePdfAsync(invoice, draft: true);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(expectedValue, captured!.ProvisionPeriod);
|
||||||
|
Assert.Equal(expectedLabel, captured.AdminProvLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
|
|||||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("REM42", result!.Id);
|
Assert.Equal("REM42", result!.Data.Id);
|
||||||
|
Assert.False(result.Updated);
|
||||||
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||||
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||||
// the email/subject the editor set must reach registration
|
// the email/subject the editor set must reach registration
|
||||||
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
|
|||||||
payload["remid"] = "REM7";
|
payload["remid"] = "REM7";
|
||||||
var s = svc.OpenFromPayload(payload, "user1");
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.True(rem.LastChange); // prior RemId → update path
|
Assert.True(rem.LastChange); // prior RemId → update path
|
||||||
|
Assert.True(result!.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reminder save batch (<see cref="ReminderService.BuildRegisterReminderSql"/>): a new draft is
|
||||||
|
/// created, a re-saved one is updated in place via <c>fds__setReminder</c> — previously the update
|
||||||
|
/// path ran an empty batch, so "Zwischenspeichern" of an already-registered reminder draft silently
|
||||||
|
/// saved nothing. Either proc leaving <c>@Id</c> NULL must surface as an error, not as an empty row.
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderRegisterSqlTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, "[dbo].[fds__createReminder] @InvId,", "[dbo].[fds__setReminder]")]
|
||||||
|
[InlineData(true, "[dbo].[fds__setReminder] @RemId,", "[dbo].[fds__createReminder]")]
|
||||||
|
public void BuildRegisterReminderSql_ExactlyOneProcPerMode(bool change, string expectedProc, string otherProc)
|
||||||
|
{
|
||||||
|
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||||
|
|
||||||
|
Assert.Contains(expectedProc, sql);
|
||||||
|
Assert.DoesNotContain(otherProc, sql);
|
||||||
|
Assert.Equal(change, sql.Contains("@RemId"));
|
||||||
|
Assert.Equal(!change, sql.Contains("@InvId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void BuildRegisterReminderSql_GuardFollowsTheProc(bool change)
|
||||||
|
{
|
||||||
|
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||||
|
|
||||||
|
int proc = sql.IndexOf("EXECUTE [dbo].[fds__", StringComparison.Ordinal);
|
||||||
|
int guard = sql.IndexOf("IF @Id IS NULL THROW 50001", StringComparison.Ordinal);
|
||||||
|
Assert.True(proc >= 0 && guard > proc, sql);
|
||||||
|
Assert.StartsWith("DECLARE @Id varchar(10);", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void BuildRegisterReminderSql_EveryStatementIsSemicolonTerminated(bool change)
|
||||||
|
{
|
||||||
|
// THROW requires the preceding statement to be terminated with ';'.
|
||||||
|
foreach (var line in ReminderService.BuildRegisterReminderSql(change).Split('\n'))
|
||||||
|
Assert.EndsWith(";", line.TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,16 +107,15 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
if (!HasForm("token")) return BadRequest400();
|
||||||
string token = Form("token");
|
string token = Form("token");
|
||||||
var before = _invoiceDrafts.Get(token);
|
// Create vs update is decided inside the flush (flushes of one session are serialised), not
|
||||||
if (before == null) return DraftGone();
|
// from the session state seen here: a save overlapping the first registration is an update.
|
||||||
bool existed = !string.IsNullOrEmpty(before.InvId);
|
var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (flush == null) return DraftGone();
|
||||||
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
var fdInv = flush.Data;
|
||||||
if (fdInv == null) return DraftGone();
|
|
||||||
if (string.IsNullOrEmpty(fdInv.Id))
|
if (string.IsNullOrEmpty(fdInv.Id))
|
||||||
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
|
||||||
var after = _invoiceDrafts.Get(token);
|
var after = _invoiceDrafts.Get(token);
|
||||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||||
|
|||||||
@@ -82,16 +82,14 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
if (!HasForm("token")) return BadRequest400();
|
||||||
string token = Form("token");
|
string token = Form("token");
|
||||||
var before = _reminderDrafts.Get(token);
|
// Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
|
||||||
if (before == null) return DraftGone();
|
var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
bool existed = !string.IsNullOrEmpty(before.RemId);
|
if (flush == null) return DraftGone();
|
||||||
|
var fdRem = flush.Data;
|
||||||
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
|
||||||
if (fdRem == null) return DraftGone();
|
|
||||||
if (string.IsNullOrEmpty(fdRem.Id))
|
if (string.IsNullOrEmpty(fdRem.Id))
|
||||||
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
|
||||||
var after = _reminderDrafts.Get(token);
|
var after = _reminderDrafts.Get(token);
|
||||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: Active
|
status: Active
|
||||||
lastUpdated: 2026-09-09
|
lastUpdated: 2026-10-01
|
||||||
applyTo:
|
applyTo:
|
||||||
- "Fuchs/Services/InvoiceDraft*"
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
- "Fuchs/Services/IInvoiceDraft*"
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
|||||||
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
|
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
|
||||||
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
|
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
|
||||||
change values — only order, membership, levels and therefore position numbers.
|
change values — only order, membership, levels and therefore position numbers.
|
||||||
|
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
|
||||||
|
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
|
||||||
|
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
|
||||||
|
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
|
||||||
|
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
|
||||||
|
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
|
||||||
|
cannot be held across the registration's `await`): the create/update decision in
|
||||||
|
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
|
||||||
|
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
|
||||||
|
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
|
||||||
|
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
|
||||||
|
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
|
||||||
|
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
|
||||||
|
it rather than from the session state it saw before the flush. Patches are never blocked by a
|
||||||
|
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
|
||||||
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||||
@@ -146,9 +161,12 @@ amount pair.
|
|||||||
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||||
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||||
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||||
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
|
||||||
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
later save of the same session runs `fds__setReminder`, which updates the row in place (version
|
||||||
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
|
||||||
|
the reminder is finalised or has a stored file. Flushes are serialised on the session's
|
||||||
|
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
|
||||||
|
create two reminder rows either.
|
||||||
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||||
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||||
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
|||||||
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||||
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||||
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||||
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14
|
||||||
|
and printed in the PDF's admin block (`InvoiceService.ApplyProvisionPeriod`; legacy ISO
|
||||||
|
values are shown as German dates).
|
||||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
|
|||||||
+2
-2
@@ -51,10 +51,10 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<!-- New packages (needed for .NET 10) -->
|
<!-- New packages (needed for .NET 10) -->
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.3" />
|
||||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.0" />
|
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="App_Data\cache\" />
|
<Folder Include="App_Data\cache\" />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
|
||||||
|
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
|
||||||
|
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
|
||||||
|
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
|
||||||
|
/// reports that decision here so the caller publishes the matching domain event instead of guessing
|
||||||
|
/// from the session state it saw before the flush.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
|
||||||
|
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
|
||||||
|
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
|
||||||
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the cached session to the DB via the existing invoice registration path
|
/// Persists the cached session to the DB via the existing invoice registration path
|
||||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
|
||||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
/// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
|
||||||
|
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||||
|
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||||
|
/// ran as an update (for the success event), or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||||
|
|
||||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||||
Task<Document?> RenderPreviewAsync(string token);
|
Task<Document?> RenderPreviewAsync(string token);
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ public interface IReminderDraftService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the cached session to the DB via the existing reminder registration path
|
/// Persists the cached session to the DB via the existing reminder registration path
|
||||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
|
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
|
||||||
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
|
/// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
|
||||||
|
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||||
|
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||||
|
/// ran as an update (for the success event), or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||||
|
|
||||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
/// <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);
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
|
// The editor fires patches (and state/preview fetches) concurrently for one token; without
|
||||||
|
// the lock a block.replace on one request mutates Req while another enumerates it.
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
string oldValue = "", newValue = "";
|
string oldValue = "", newValue = "";
|
||||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||||
if (!mutated)
|
if (!mutated)
|
||||||
@@ -82,6 +86,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
_cache.Set(session);
|
_cache.Set(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||||
@@ -424,18 +429,24 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── View state / history ────────────────────────────────────────────────
|
// ── View state / history ────────────────────────────────────────────────
|
||||||
|
/// <remarks>
|
||||||
|
/// The result is serialised by the caller after this returns, so everything mutable is a
|
||||||
|
/// snapshot taken under the session lock — never a live reference into the session.
|
||||||
|
/// </remarks>
|
||||||
public object BuildState(InvoiceDraftSession session)
|
public object BuildState(InvoiceDraftSession session)
|
||||||
{
|
{
|
||||||
session.Touch();
|
session.Touch();
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
token = session.Token,
|
token = session.Token,
|
||||||
version = session.Version,
|
version = session.Version,
|
||||||
invid = session.InvId,
|
invid = session.InvId,
|
||||||
isDraft = session.IsDraft,
|
isDraft = session.IsDraft,
|
||||||
admin = session.Admin,
|
admin = session.Admin.DeepClone(),
|
||||||
@new = session.New,
|
@new = session.New.DeepClone(),
|
||||||
req = session.Req,
|
req = session.Req.DeepClone(),
|
||||||
sums = new
|
sums = new
|
||||||
{
|
{
|
||||||
total_net = session.Sums.TotalNet,
|
total_net = session.Sums.TotalNet,
|
||||||
@@ -443,15 +454,16 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
total_vat = session.Sums.TotalVat,
|
total_vat = session.Sums.TotalVat,
|
||||||
service_net = session.Sums.ServiceNet,
|
service_net = session.Sums.ServiceNet,
|
||||||
service_vat = session.Sums.ServiceVat,
|
service_vat = session.Sums.ServiceVat,
|
||||||
vat = session.Sums.VatByRate,
|
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
|
||||||
block_net = session.Sums.NetByBlock
|
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
|
||||||
},
|
},
|
||||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||||
historyCount = session.History.Count,
|
historyCount = session.History.Count,
|
||||||
notes = BuildNotes(session),
|
notes = BuildNotes(session).ToList(),
|
||||||
setDisplay = BuildSetDisplay(session)
|
setDisplay = BuildSetDisplay(session)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authoritative per-item set-pricing display flags (ADR 0006/set-pricing): for every block
|
/// Authoritative per-item set-pricing display flags (ADR 0006/set-pricing): for every block
|
||||||
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
.Select(n => new { text = n.Text, style = n.Style });
|
.Select(n => new { text = n.Text, style = n.Style });
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||||
|
lock (session.SyncRoot) return session.History.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||||
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
var fds = BuildFdsData(session);
|
// One flush at a time per session: the create/update decision below is read-then-write across
|
||||||
bool change = !string.IsNullOrEmpty(session.InvId);
|
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
|
||||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
|
||||||
|
// first one created (with its own, newer snapshot).
|
||||||
|
await session.FlushGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FdsInvoiceData fds;
|
||||||
|
string invId;
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||||
|
invId = session.InvId;
|
||||||
|
}
|
||||||
|
bool change = !string.IsNullOrEmpty(invId);
|
||||||
|
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
|
||||||
if (!string.IsNullOrEmpty(reg.Id))
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
{
|
{
|
||||||
session.InvId = reg.Id;
|
lock (session.SyncRoot) session.InvId = reg.Id;
|
||||||
_cache.Set(session);
|
_cache.Set(session);
|
||||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||||
token, reg.Id, change, userAccountId);
|
token, reg.Id, change, userAccountId);
|
||||||
}
|
}
|
||||||
return reg;
|
return new DraftFlushResult<FdsInvoiceData>(reg, change);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.FlushGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Document?> RenderPreviewAsync(string token)
|
public async Task<Document?> RenderPreviewAsync(string token)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
var fds = BuildFdsData(session);
|
FdsInvoiceData fds;
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildFdsData(session);
|
||||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||||
|
}
|
||||||
fds.IsDraft = true;
|
fds.IsDraft = true;
|
||||||
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,22 @@ public class InvoiceService : IInvoiceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts the stored Leistungsdatum / -zeitraum into the PDF admin block: German dates (legacy ISO
|
||||||
|
/// values converted) and the label the editor shows — "Leistungsdatum" for one date,
|
||||||
|
/// "Leistungszeitraum" for a from–to range.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ApplyProvisionPeriod(FuchsPdf.FdsTextBlocks tb, string? provisionPeriod)
|
||||||
|
{
|
||||||
|
tb.ProvisionPeriod = ProvisionPeriodForDisplay(provisionPeriod).Trim();
|
||||||
|
tb.AdminProvLabel = tb.ProvisionPeriod.Contains(" - ") || tb.ProvisionPeriod.Length == 0
|
||||||
|
? "Leistungszeitraum" : "Leistungsdatum";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows ISO dates (yyyy-MM-dd) saved by older editor versions as German dd.MM.yyyy.</summary>
|
||||||
|
internal static string ProvisionPeriodForDisplay(string? period) =>
|
||||||
|
System.Text.RegularExpressions.Regex.Replace(period ?? "", @"\b(\d{4})-(\d{2})-(\d{2})\b", "$3.$2.$1");
|
||||||
|
|
||||||
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
|
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
|
||||||
{
|
{
|
||||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||||
@@ -339,6 +355,7 @@ public class InvoiceService : IInvoiceService
|
|||||||
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
|
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
|
||||||
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
|
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
|
||||||
};
|
};
|
||||||
|
ApplyProvisionPeriod(tb, reg?.getString("ProvisionPeriod"));
|
||||||
var inlineImages = await ResolveInlineImagesAsync(invoice);
|
var inlineImages = await ResolveInlineImagesAsync(invoice);
|
||||||
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
||||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
|
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
|
||||||
|
{
|
||||||
string oldValue = "", newValue = "";
|
string oldValue = "", newValue = "";
|
||||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||||
if (!mutated)
|
if (!mutated)
|
||||||
@@ -81,6 +83,7 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
_cache.Set(session);
|
_cache.Set(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||||
@@ -144,25 +147,28 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
public object BuildState(ReminderDraftSession session)
|
public object BuildState(ReminderDraftSession session)
|
||||||
{
|
{
|
||||||
session.Touch();
|
session.Touch();
|
||||||
|
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
|
||||||
|
{
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
token = session.Token,
|
token = session.Token,
|
||||||
version = session.Version,
|
version = session.Version,
|
||||||
remid = session.RemId,
|
remid = session.RemId,
|
||||||
isDraft = session.IsDraft,
|
isDraft = session.IsDraft,
|
||||||
@new = session.New,
|
@new = session.New.DeepClone(),
|
||||||
rem = session.Rem,
|
rem = session.Rem.DeepClone(),
|
||||||
sums = new
|
sums = new
|
||||||
{
|
{
|
||||||
amount_total = session.Sums.AmountTotal,
|
amount_total = session.Sums.AmountTotal,
|
||||||
amount_payed = session.Sums.AmountPayed,
|
amount_payed = session.Sums.AmountPayed,
|
||||||
amount_open = session.Sums.AmountOpen
|
amount_open = session.Sums.AmountOpen
|
||||||
},
|
},
|
||||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||||
historyCount = session.History.Count,
|
historyCount = session.History.Count,
|
||||||
notes = BuildNotes(session)
|
notes = BuildNotes(session)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the backend-authoritative intro/closing notice paragraphs (see
|
/// Renders the backend-authoritative intro/closing notice paragraphs (see
|
||||||
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
return new { intro, closing };
|
return new { intro, closing };
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||||
|
lock (session.SyncRoot) return session.History.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||||
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
public async Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
var fds = BuildReminderData(session);
|
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
|
||||||
bool change = !string.IsNullOrEmpty(session.RemId);
|
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
|
||||||
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
|
await session.FlushGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FdsReminderData fds;
|
||||||
|
string remId;
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||||
|
remId = session.RemId;
|
||||||
|
}
|
||||||
|
bool change = !string.IsNullOrEmpty(remId);
|
||||||
|
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
|
||||||
if (!string.IsNullOrEmpty(reg.Id))
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
{
|
{
|
||||||
session.RemId = reg.Id;
|
lock (session.SyncRoot) session.RemId = reg.Id;
|
||||||
_cache.Set(session);
|
_cache.Set(session);
|
||||||
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||||
token, reg.Id, change, userAccountId);
|
token, reg.Id, change, userAccountId);
|
||||||
}
|
}
|
||||||
return reg;
|
return new DraftFlushResult<FdsReminderData>(reg, change);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.FlushGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Document? RenderPreview(string token)
|
public Document? RenderPreview(string token)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
var fds = BuildReminderData(session);
|
FdsReminderData fds;
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildReminderData(session);
|
||||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||||
|
}
|
||||||
fds.IsDraft = true;
|
fds.IsDraft = true;
|
||||||
return _reminders.GenerateReminderPdf(fds, draft: true);
|
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
|
|||||||
return rem;
|
return rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
|
||||||
|
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
|
||||||
|
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
|
||||||
|
/// or one with a stored file — so the guard turns that into an error instead of an empty result
|
||||||
|
/// row the caller would read as "saved".
|
||||||
|
/// </summary>
|
||||||
|
internal static string BuildRegisterReminderSql(bool change)
|
||||||
|
{
|
||||||
|
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||||
|
sqlParts.Add(change
|
||||||
|
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
|
||||||
|
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||||
|
sqlParts.Add(change
|
||||||
|
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
|
||||||
|
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
|
||||||
|
return string.Join("\n", sqlParts);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||||
string userAccountId, DatabaseSecurity dbSec)
|
string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
|
|||||||
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
||||||
};
|
};
|
||||||
|
|
||||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
change = change && !string.IsNullOrEmpty(remId);
|
||||||
if (!change || string.IsNullOrEmpty(remId))
|
if (change) pl.Add(SQL_VarChar("RemId", remId));
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
|
||||||
else
|
|
||||||
pl.Add(SQL_VarChar("RemId", remId));
|
|
||||||
|
|
||||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
|
||||||
Conn, pl, tablenames: new[] { "rem" },
|
Conn, pl, tablenames: new[] { "rem" },
|
||||||
Security: dbSec, options: new FIS_SQLOptions());
|
Security: dbSec, options: new FIS_SQLOptions());
|
||||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||||
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
||||||
|
|
||||||
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||||
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
|
_logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
|
||||||
return reminder;
|
return reminder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ public sealed class InvoiceDraftSession
|
|||||||
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
public List<ChangeHistoryEntry> History { get; } = new();
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session lock. The editor fires patches, state fetches and previews concurrently for the
|
||||||
|
/// same token, and the payload (<see cref="Newtonsoft.Json.Linq.JArray"/>/<c>JObject</c>, the
|
||||||
|
/// history list) is not thread-safe — the draft service takes this lock around every read and
|
||||||
|
/// write of the payload, and hands out deep-cloned snapshots for anything serialised later.
|
||||||
|
/// </summary>
|
||||||
|
public object SyncRoot { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="InvId"/>,
|
||||||
|
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
|
||||||
|
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
|
||||||
|
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
|
||||||
|
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
|
||||||
|
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
|
||||||
|
/// </summary>
|
||||||
|
public SemaphoreSlim FlushGate { get; } = new(1, 1);
|
||||||
|
|
||||||
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Fuchs.intranet;
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ public sealed class ReminderDraftSession
|
|||||||
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
public List<ChangeHistoryEntry> History { get; } = new();
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session lock (see <see cref="InvoiceDraftSession.SyncRoot"/>): the draft service takes it
|
||||||
|
/// around every read and write of the non-thread-safe payload, so concurrent patch/state/preview
|
||||||
|
/// requests for the same token cannot interleave.
|
||||||
|
/// </summary>
|
||||||
|
public object SyncRoot { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="RemId"/>,
|
||||||
|
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
|
||||||
|
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
|
||||||
|
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
|
||||||
|
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
|
||||||
|
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
|
||||||
|
/// </summary>
|
||||||
|
public SemaphoreSlim FlushGate { get; } = new(1, 1);
|
||||||
|
|
||||||
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,18 @@ $fis.resetPass = function (id, fds) {
|
|||||||
title: $t.rsp || '',
|
title: $t.rsp || '',
|
||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
|
var values = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((qs.npw || '') !== (qs.npwc || '')) {
|
if ((qs.npw || '') !== (qs.npwc || '')) {
|
||||||
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
|
/* The browser blocks every later submit while npw carries a custom error, so it
|
||||||
|
must be cleared as soon as either password field is edited again. */
|
||||||
|
var npw = c.find('input[name="npw"]:first');
|
||||||
|
npw[0].setCustomValidity($t.pnm);
|
||||||
|
c.find('input[name="npw"], input[name="npwc"]').off('input.pnm').on('input.pnm', function () {
|
||||||
|
npw[0].setCustomValidity('');
|
||||||
|
});
|
||||||
|
c.rejectInvalid();
|
||||||
} else {
|
} else {
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
||||||
|
|||||||
@@ -1114,12 +1114,14 @@ $inv.eAddress = function (ev) {
|
|||||||
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1133,7 +1135,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ let $rep = {
|
|||||||
};
|
};
|
||||||
let scl = function (ev) {
|
let scl = function (ev) {
|
||||||
//console.debug(ev);
|
//console.debug(ev);
|
||||||
let btn = $(this), rx = ev.data;
|
let btn = $(this), rx = ev.data, params = btn.serializeObject();
|
||||||
|
if (params === null) { return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('rep/gct/' + rx.name), data: btn.serializeObject(), success: (response) => {
|
url: $ocms.url('rep/gct/' + rx.name), data: params, success: (response) => {
|
||||||
btn.addClass('selected').siblings().removeClass('selected');
|
btn.addClass('selected').siblings().removeClass('selected');
|
||||||
$('#listframe').rC('fix').aC('hd');
|
$('#listframe').rC('fix').aC('hd');
|
||||||
repfrm.html(response);
|
repfrm.html(response);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ let $spc = {
|
|||||||
let f = c.form[0];
|
let f = c.form[0];
|
||||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||||
|
if (values === null) { c.form.rejectInvalid(); return; }
|
||||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||||
c.result.hide();
|
c.result.hide();
|
||||||
btn.prop('disabled', true).text($spt.working);
|
btn.prop('disabled', true).text($spt.working);
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
||||||
@@ -141,6 +142,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||||
@@ -162,6 +164,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
@@ -79,6 +80,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -647,7 +648,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -720,6 +721,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -734,11 +742,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -747,8 +757,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -781,7 +790,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -840,6 +849,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
|
|||||||
@@ -360,6 +360,8 @@
|
|||||||
};
|
};
|
||||||
if (c.find(':input[required]').length > 0) {
|
if (c.find(':input[required]').length > 0) {
|
||||||
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
||||||
|
}
|
||||||
|
if (isform === true) { /* shown via .invalid whenever serializeObject rejects the form, required fields or not */
|
||||||
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
||||||
}
|
}
|
||||||
if (f('cancel') === true) {
|
if (f('cancel') === true) {
|
||||||
@@ -613,7 +615,9 @@
|
|||||||
} else if (typeof options.success === 'function') {
|
} else if (typeof options.success === 'function') {
|
||||||
cfm = function (e) {
|
cfm = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) }));
|
var values = c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((options.url || '') !== ''){
|
if ((options.url || '') !== ''){
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: options.url, data: qs, success: function (response) {
|
url: options.url, data: qs, success: function (response) {
|
||||||
@@ -657,7 +661,9 @@
|
|||||||
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
||||||
let f_submit = function (e) {
|
let f_submit = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject());
|
var values = c.serializeObject();
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: '/vt/login', data: qs, success: function (response) {
|
url: '/vt/login', data: qs, success: function (response) {
|
||||||
if (((response || {}).login || '') !== '') {
|
if (((response || {}).login || '') !== '') {
|
||||||
|
|||||||
@@ -1696,12 +1696,14 @@ $inv.eAddress = function (ev) {
|
|||||||
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1715,7 +1717,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -725,6 +725,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -884,7 +885,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -957,6 +958,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -971,11 +979,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -984,8 +994,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -1018,7 +1027,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -1077,6 +1086,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
@@ -2139,6 +2149,8 @@ class NumArray extends Array {
|
|||||||
};
|
};
|
||||||
if (c.find(':input[required]').length > 0) {
|
if (c.find(':input[required]').length > 0) {
|
||||||
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
||||||
|
}
|
||||||
|
if (isform === true) { /* shown via .invalid whenever serializeObject rejects the form, required fields or not */
|
||||||
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
||||||
}
|
}
|
||||||
if (f('cancel') === true) {
|
if (f('cancel') === true) {
|
||||||
@@ -2392,7 +2404,9 @@ class NumArray extends Array {
|
|||||||
} else if (typeof options.success === 'function') {
|
} else if (typeof options.success === 'function') {
|
||||||
cfm = function (e) {
|
cfm = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) }));
|
var values = c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((options.url || '') !== ''){
|
if ((options.url || '') !== ''){
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: options.url, data: qs, success: function (response) {
|
url: options.url, data: qs, success: function (response) {
|
||||||
@@ -2436,7 +2450,9 @@ class NumArray extends Array {
|
|||||||
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
||||||
let f_submit = function (e) {
|
let f_submit = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject());
|
var values = c.serializeObject();
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: '/vt/login', data: qs, success: function (response) {
|
url: '/vt/login', data: qs, success: function (response) {
|
||||||
if (((response || {}).login || '') !== '') {
|
if (((response || {}).login || '') !== '') {
|
||||||
@@ -2998,9 +3014,18 @@ $fis.resetPass = function (id, fds) {
|
|||||||
title: $t.rsp || '',
|
title: $t.rsp || '',
|
||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
|
var values = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((qs.npw || '') !== (qs.npwc || '')) {
|
if ((qs.npw || '') !== (qs.npwc || '')) {
|
||||||
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
|
/* The browser blocks every later submit while npw carries a custom error, so it
|
||||||
|
must be cleared as soon as either password field is edited again. */
|
||||||
|
var npw = c.find('input[name="npw"]:first');
|
||||||
|
npw[0].setCustomValidity($t.pnm);
|
||||||
|
c.find('input[name="npw"], input[name="npwc"]').off('input.pnm').on('input.pnm', function () {
|
||||||
|
npw[0].setCustomValidity('');
|
||||||
|
});
|
||||||
|
c.rejectInvalid();
|
||||||
} else {
|
} else {
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -53,9 +53,10 @@ let $rep = {
|
|||||||
};
|
};
|
||||||
let scl = function (ev) {
|
let scl = function (ev) {
|
||||||
//console.debug(ev);
|
//console.debug(ev);
|
||||||
let btn = $(this), rx = ev.data;
|
let btn = $(this), rx = ev.data, params = btn.serializeObject();
|
||||||
|
if (params === null) { return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('rep/gct/' + rx.name), data: btn.serializeObject(), success: (response) => {
|
url: $ocms.url('rep/gct/' + rx.name), data: params, success: (response) => {
|
||||||
btn.addClass('selected').siblings().removeClass('selected');
|
btn.addClass('selected').siblings().removeClass('selected');
|
||||||
$('#listframe').rC('fix').aC('hd');
|
$('#listframe').rC('fix').aC('hd');
|
||||||
repfrm.html(response);
|
repfrm.html(response);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
let $ct={mdl:"Berichte"},$ccol={};function getMonday(e){var t=(e=new Date(e)).getDay(),a=e.getDate()-t+(0==t?-6:1);return new Date(e.setDate(a))}let $rep={init2:function(e,t){e=e||"inv",t=t||{},$ocms.getScript([],(function(){$rep.init3(e,t)}))},init3:async function(e,t){let a=$("#contentframe").empty(),l=($$.dc("cfrm hd").appendTo(a),$$.dc("init_frm",a));$("#topbar").ocmsmenu([]);$("#activemodule").text($ct.mdl);let c=$$.dc("repfrm",l),i=(new Date,[(async()=>{if(await $fis.getAuth("fds_reports")>0){l.IN((function(){}));let e=$("#listframe").empty().aC("fix").rC("hd"),t=function(e){let t=$(this);if((e.params||[]).length>0){let a=$$.dc("ipf",t).click((function(e){e.stopPropagation()}));$.each(e.params,((e,t)=>{let l,c=$$.dc("ig",a).append($$.lbl(t.name));""===(t.valuelist||"")&&(l=$$.i({name:t.name,placeholder:t.placeholder}).appendTo(c),""!==(t.default||"")&&l.val(t.default))}))}},a=function(e){let t=$(this),a=e.data;$ocms.postXT({url:$ocms.url("rep/gct/"+a.name),data:t.serializeObject(),success:e=>{t.addClass("selected").siblings().removeClass("selected"),$("#listframe").rC("fix").aC("hd"),c.html(e)},datatype:"html"})};$ocms.postXT({url:$ocms.url("rep/catalog"),data:{},success:l=>{$.each(l.reports,((l,c)=>{let i=$$.dc("repitm",e).text(c.label).click(c,a);t.call(i,c)}))}})}})(),new Promise(((e,t)=>{}))]);await Promise.all(i)}},$$rep={init2:$rep.init2,auth:{}};export default $$rep;
|
let $ct={mdl:"Berichte"},$ccol={};function getMonday(e){var t=(e=new Date(e)).getDay(),a=e.getDate()-t+(0==t?-6:1);return new Date(e.setDate(a))}let $rep={init2:function(e,t){e=e||"inv",t=t||{},$ocms.getScript([],(function(){$rep.init3(e,t)}))},init3:async function(e,t){let a=$("#contentframe").empty(),l=($$.dc("cfrm hd").appendTo(a),$$.dc("init_frm",a));$("#topbar").ocmsmenu([]);$("#activemodule").text($ct.mdl);let c=$$.dc("repfrm",l),i=(new Date,[(async()=>{if(await $fis.getAuth("fds_reports")>0){l.IN((function(){}));let e=$("#listframe").empty().aC("fix").rC("hd"),t=function(e){let t=$(this);if((e.params||[]).length>0){let a=$$.dc("ipf",t).click((function(e){e.stopPropagation()}));$.each(e.params,((e,t)=>{let l,c=$$.dc("ig",a).append($$.lbl(t.name));""===(t.valuelist||"")&&(l=$$.i({name:t.name,placeholder:t.placeholder}).appendTo(c),""!==(t.default||"")&&l.val(t.default))}))}},a=function(e){let t=$(this),a=e.data,l=t.serializeObject();null!==l&&$ocms.postXT({url:$ocms.url("rep/gct/"+a.name),data:l,success:e=>{t.addClass("selected").siblings().removeClass("selected"),$("#listframe").rC("fix").aC("hd"),c.html(e)},datatype:"html"})};$ocms.postXT({url:$ocms.url("rep/catalog"),data:{},success:l=>{$.each(l.reports,((l,c)=>{let i=$$.dc("repitm",e).text(c.label).click(c,a);t.call(i,c)}))}})}})(),new Promise(((e,t)=>{}))]);await Promise.all(i)}},$$rep={init2:$rep.init2,auth:{}};export default $$rep;
|
||||||
@@ -1677,12 +1677,14 @@ $inv.eAddress = function (ev) {
|
|||||||
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1696,7 +1698,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,6 +82,7 @@ let $spc = {
|
|||||||
let f = c.form[0];
|
let f = c.form[0];
|
||||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||||
|
if (values === null) { c.form.rejectInvalid(); return; }
|
||||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||||
c.result.hide();
|
c.result.hide();
|
||||||
btn.prop('disabled', true).text($spt.working);
|
btn.prop('disabled', true).text($spt.working);
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}})))}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
|
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});null!==c?("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}}))):e.form.rejectInvalid()}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
|
||||||
@@ -152,6 +152,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
||||||
@@ -187,6 +188,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||||
@@ -208,6 +210,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
@@ -99,6 +100,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
let $ugt={mdl:"Gruppenverwaltung",rel:"Neu Laden",col_name:"Name",loadfail:"Die Gruppenliste konnte nicht geladen werden.",empty:"Es sind keine Benutzergruppen vorhanden.",newgroup:"Neue Gruppe",newgroup_btn:"Anlegen",name_field:"Gruppenname",rename:"Gruppe umbenennen",rename_btn:"Speichern",delete:"Gruppe löschen",delete_confirm:'Soll die Gruppe "{0}" wirklich gelöscht werden? Die Mitgliedschaft aller Benutzer in dieser Gruppe geht dabei verloren.',err:{save_failed:"Die Gruppe konnte nicht gespeichert werden.",delete_failed:"Die Gruppe konnte nicht gelöscht werden."},err_generic:"Die Änderung konnte nicht gespeichert werden."},$ugp={init2:function(){$ocms.getScript([],(function(){$ugp.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($ugt.mdl),$ugp.eM(),$ugp.renderLst()},eM:function(e){let n=[{lbl:$ugt.newgroup_btn,fnc:$ugp.createDlg}];return!0===bool(e,!1)&&n.push({lbl:$ugt.rel,fnc:$ugp.renderLst}),$("#topbar").ocmsmenu(n)},iMn:function(e){return $("#topbar").ocmsmenu([{lbl:$ugt.rename,fnc:function(){$ugp.renameDlg(e)}},{lbl:$ugt.delete,fnc:function(){$ugp.deleteGroup(e)}}])},renderLst:function(){let e=$fis.frm_list(!0,!0).ldng(1),n=$$.dc("ugpfrm",e);$ugp.eM(!0),$ocms.postXT({url:$ocms.url("usrgrp/list"),success:function(e){n.empty();let t=$$.dc("ugp_grid",n),r=$$.dc("ugp_row ugp_row_hd",t);$$.dc("ugp_col_name",r).text($ugt.col_name),0!==(e.groups||[]).length?$.each(e.groups||[],(function(e,n){let r=$$.dc("ugp_row",t,{id:"ugp"+n.usergroup_id});$$.dc("ugp_col_name",r).text(n.name||""),r.click((function(){r.toggleClass("selected").siblings().rC("selected"),!0===r.is(".selected")?$ugp.iMn(n):$ugp.eM(!0)}))})):$$.dc("ugp_empty",t).text($ugt.empty)},error:function(){n.empty(),$$.dc("ugp_err",n).text($ugt.loadfail)},complete:function(){e.ldng(0)}})},createDlg:function(){$ocms.dlgform([{name:"name",label:$ugt.name_field,type:"string",required:!0}],{title:$ugt.newgroup,button:$ugt.newgroup_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usrgrp/create"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}})}})},renameDlg:function(e){$ocms.dlgform([{name:"usergroup_id",type:"hidden",value:e.usergroup_id},{name:"name",label:$ugt.name_field,type:"string",required:!0,value:e.name}],{title:$ugt.rename,button:$ugt.rename_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usrgrp/rename"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}})}})},deleteGroup:function(e){confirm(string($ugt.delete_confirm,[e.name||""]))&&$ocms.postXT({url:$ocms.url("usrgrp/delete"),data:{usergroup_id:e.usergroup_id},success:function(){$ugp.eM(!0)},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){$ugp.renderLst()}})}},$$ugp={init2:$ugp.init2,auth:{}};export default $$ugp;
|
let $ugt={mdl:"Gruppenverwaltung",rel:"Neu Laden",col_name:"Name",loadfail:"Die Gruppenliste konnte nicht geladen werden.",empty:"Es sind keine Benutzergruppen vorhanden.",newgroup:"Neue Gruppe",newgroup_btn:"Anlegen",name_field:"Gruppenname",rename:"Gruppe umbenennen",rename_btn:"Speichern",delete:"Gruppe löschen",delete_confirm:'Soll die Gruppe "{0}" wirklich gelöscht werden? Die Mitgliedschaft aller Benutzer in dieser Gruppe geht dabei verloren.',err:{save_failed:"Die Gruppe konnte nicht gespeichert werden.",delete_failed:"Die Gruppe konnte nicht gelöscht werden."},err_generic:"Die Änderung konnte nicht gespeichert werden."},$ugp={init2:function(){$ocms.getScript([],(function(){$ugp.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($ugt.mdl),$ugp.eM(),$ugp.renderLst()},eM:function(e){let n=[{lbl:$ugt.newgroup_btn,fnc:$ugp.createDlg}];return!0===bool(e,!1)&&n.push({lbl:$ugt.rel,fnc:$ugp.renderLst}),$("#topbar").ocmsmenu(n)},iMn:function(e){return $("#topbar").ocmsmenu([{lbl:$ugt.rename,fnc:function(){$ugp.renameDlg(e)}},{lbl:$ugt.delete,fnc:function(){$ugp.deleteGroup(e)}}])},renderLst:function(){let e=$fis.frm_list(!0,!0).ldng(1),n=$$.dc("ugpfrm",e);$ugp.eM(!0),$ocms.postXT({url:$ocms.url("usrgrp/list"),success:function(e){n.empty();let t=$$.dc("ugp_grid",n),r=$$.dc("ugp_row ugp_row_hd",t);$$.dc("ugp_col_name",r).text($ugt.col_name),0!==(e.groups||[]).length?$.each(e.groups||[],(function(e,n){let r=$$.dc("ugp_row",t,{id:"ugp"+n.usergroup_id});$$.dc("ugp_col_name",r).text(n.name||""),r.click((function(){r.toggleClass("selected").siblings().rC("selected"),!0===r.is(".selected")?$ugp.iMn(n):$ugp.eM(!0)}))})):$$.dc("ugp_empty",t).text($ugt.empty)},error:function(){n.empty(),$$.dc("ugp_err",n).text($ugt.loadfail)},complete:function(){e.ldng(0)}})},createDlg:function(){$ocms.dlgform([{name:"name",label:$ugt.name_field,type:"string",required:!0}],{title:$ugt.newgroup,button:$ugt.newgroup_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});null!==t?$ocms.postXT({url:$ocms.url("usrgrp/create"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}}):n.rejectInvalid()}})},renameDlg:function(e){$ocms.dlgform([{name:"usergroup_id",type:"hidden",value:e.usergroup_id},{name:"name",label:$ugt.name_field,type:"string",required:!0,value:e.name}],{title:$ugt.rename,button:$ugt.rename_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});null!==t?$ocms.postXT({url:$ocms.url("usrgrp/rename"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}}):n.rejectInvalid()}})},deleteGroup:function(e){confirm(string($ugt.delete_confirm,[e.name||""]))&&$ocms.postXT({url:$ocms.url("usrgrp/delete"),data:{usergroup_id:e.usergroup_id},success:function(){$ugp.eM(!0)},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){$ugp.renderLst()}})}},$$ugp={init2:$ugp.init2,auth:{}};export default $$ugp;
|
||||||
@@ -542,6 +542,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -701,7 +702,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -774,6 +775,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -788,11 +796,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -801,8 +811,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -835,7 +844,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -894,6 +903,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -358,6 +358,7 @@
|
|||||||
<Build Include="dbo\Stored Procedures\fds__createStorno_simple.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createStorno_simple.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createStorno_copy.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createStorno_copy.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createReminder.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createReminder.sql" />
|
||||||
|
<Build Include="dbo\Stored Procedures\fds__setReminder.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice_ServiceRequest.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice_ServiceRequest.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice_Details.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice_Details.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
CREATE FUNCTION[dbo].[AddBusinessDays](@Date DATE,@n INT)
|
-- INLINE = OFF: scalar UDF inlining of this @@DATEFIRST arithmetic (reached via date_add /
|
||||||
RETURNS DATE AS
|
-- date_addcustom) killed dashboard widget queries with error 596 ("session is in the kill state").
|
||||||
|
CREATE FUNCTION[dbo].[AddBusinessDays](@Date DATE,@n INT)
|
||||||
|
RETURNS DATE
|
||||||
|
WITH INLINE = OFF
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @d INT,@f INT,@DW INT;
|
DECLARE @d INT,@f INT,@DW INT;
|
||||||
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
|
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
-- Author: <Author,,Name>
|
-- Author: <Author,,Name>
|
||||||
-- Create date: <Create Date, ,>
|
-- Create date: <Create Date, ,>
|
||||||
-- Description: <Description, ,>
|
-- Description: <Description, ,>
|
||||||
|
-- INLINE = OFF: scalar UDF inlining of this IF/nested-UDF body (-> AddBusinessDays)
|
||||||
|
-- killed the dashboard widget queries with error 596 ("session is in the kill
|
||||||
|
-- state", class 21). Called with constant arguments, so per-row cost is negligible.
|
||||||
-- =============================================
|
-- =============================================
|
||||||
CREATE FUNCTION [dbo].[date_add]
|
CREATE FUNCTION [dbo].[date_add]
|
||||||
(
|
(
|
||||||
@@ -10,6 +13,7 @@ CREATE FUNCTION [dbo].[date_add]
|
|||||||
,@shift_to_first bit = 1
|
,@shift_to_first bit = 1
|
||||||
)
|
)
|
||||||
RETURNS date
|
RETURNS date
|
||||||
|
WITH INLINE = OFF
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @Ret date = @date;
|
DECLARE @Ret date = @date;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
-- Author: <Author,,Name>
|
-- Author: <Author,,Name>
|
||||||
-- Create date: <Create Date, ,>
|
-- Create date: <Create Date, ,>
|
||||||
-- Description: <Description, ,>
|
-- Description: <Description, ,>
|
||||||
|
-- INLINE = OFF: scalar UDF inlining (nested -> AddBusinessDays) killed the dashboard
|
||||||
|
-- widget queries with error 596 ("session is in the kill state", class 21).
|
||||||
-- =============================================
|
-- =============================================
|
||||||
CREATE FUNCTION [dbo].[date_addcustom]
|
CREATE FUNCTION [dbo].[date_addcustom]
|
||||||
(
|
(
|
||||||
@@ -9,6 +11,7 @@ CREATE FUNCTION [dbo].[date_addcustom]
|
|||||||
,@phrase varchar(10)
|
,@phrase varchar(10)
|
||||||
)
|
)
|
||||||
RETURNS date
|
RETURNS date
|
||||||
|
WITH INLINE = OFF
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @returndate date, @numpart integer = TRY_PARSE(left(@phrase, patindex('%[^0-9]%', @phrase + '.') - 1) as integer), @defpart varchar(10) = LOWER(RIGHT(@phrase, LEN(@phrase) - patindex('%[^0-9]%', @phrase + '.') + 1));
|
DECLARE @returndate date, @numpart integer = TRY_PARSE(left(@phrase, patindex('%[^0-9]%', @phrase + '.') - 1) as integer), @defpart varchar(10) = LOWER(RIGHT(@phrase, LEN(@phrase) - patindex('%[^0-9]%', @phrase + '.') + 1));
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Description: Updates an unfinalised reminder draft in place ("Zwischenspeichern" of a draft
|
||||||
|
-- that fds__createReminder already registered) — the reminder counterpart of
|
||||||
|
-- fds__setInvoice. Only a draft that is not finalised and has no stored file is
|
||||||
|
-- touched; otherwise no row is updated, @Id stays NULL and the caller reports the
|
||||||
|
-- save as failed instead of silently doing nothing. The reminder stays bound to its
|
||||||
|
-- invoice: @InvId is not updatable.
|
||||||
|
-- Returns the same single row shape as fds__createReminder.
|
||||||
|
-- =============================================
|
||||||
|
CREATE PROCEDURE [dbo].[fds__setReminder]
|
||||||
|
@RemId varchar(10)
|
||||||
|
, @type varchar(3)
|
||||||
|
, @amount numeric(10,3)
|
||||||
|
, @amount_payed numeric(10,3)
|
||||||
|
, @SendToAddress [nvarchar](1000)
|
||||||
|
, @SendToEmail [nvarchar](255)
|
||||||
|
, @subject nvarchar(255)
|
||||||
|
, @text nvarchar(2000)
|
||||||
|
, @authuser varchar(25)
|
||||||
|
, @Id varchar(10) OUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF [dbo].[fis_getModuleAuth]('fds_reminder', @authuser) < 2
|
||||||
|
THROW 60000, N'not authorized', 1;
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[fds__admin_activity] ([activity] ,[authuser] ,[info])
|
||||||
|
VALUES ('fds__setReminder' ,@authuser , 'remid: ' + ISNULL(@RemId, ''));
|
||||||
|
|
||||||
|
DECLARE @now datetime = GETUTCDATE();
|
||||||
|
DECLARE @OUT [dbo].[fds__tt__reminder_core];
|
||||||
|
|
||||||
|
UPDATE [dbo].[fds__reminder]
|
||||||
|
SET [Version] = [Version] + 1
|
||||||
|
,[type] = ISNULL(@type, [type])
|
||||||
|
,[amount] = @amount
|
||||||
|
,[amount_payed] = @amount_payed
|
||||||
|
,[SendToAddress] = @SendToAddress
|
||||||
|
,[SendToEmail] = @SendToEmail
|
||||||
|
,[subject] = @subject
|
||||||
|
,[text] = @text
|
||||||
|
,[DateModified] = @now
|
||||||
|
,[UserModified] = @authuser
|
||||||
|
OUTPUT inserted.*
|
||||||
|
INTO @OUT
|
||||||
|
WHERE [Id] = @RemId
|
||||||
|
AND [DateFinalized] IS NULL -- IsFinal = 0
|
||||||
|
AND [file] IS NULL;
|
||||||
|
|
||||||
|
SELECT TOP(1) @Id = [Id] FROM @OUT; -- stays NULL when nothing matched
|
||||||
|
|
||||||
|
With inv as(
|
||||||
|
select TOP(1) * FROM [dbo].[fds__invoices] WHERE [id] = (SELECT TOP(1) [InvId] FROM @OUT)
|
||||||
|
)
|
||||||
|
SELECT TOP(1)
|
||||||
|
o.*
|
||||||
|
, [InvoiceId] = inv.[InvoiceId]
|
||||||
|
, [InvoiceDate] = [dbo].[fds__fn_invoiceDate](inv.[DateFinalized], inv.[DateCreated])
|
||||||
|
, [hasFile] = CAST ( CASE WHEN o.[file] is null THEN 0 ELSE 1 END as bit)
|
||||||
|
, [UserNameFinalized] = [dbo].[fis_admin_getUserName_byID](o.[UserFinalized])
|
||||||
|
, [UserEmailFinalized] = [dbo].[fis_admin_getUserEmail_byID](o.[UserFinalized])
|
||||||
|
from @OUT as o
|
||||||
|
join inv on o.[InvId] = inv.[Id];
|
||||||
|
|
||||||
|
END
|
||||||
+1
-1
Submodule OCORE updated: 728ab395c7...e84f8c23fb
+1
-1
Submodule OCORE_web updated: bc4e534e1b...eb7bb5719f
@@ -0,0 +1,130 @@
|
|||||||
|
// Run with: node --test e2e_tests/form-validation.cjs
|
||||||
|
// Every caller of serializeObject() must treat a null result (invalid form) as "do not submit":
|
||||||
|
// stop the loading indicator, keep the form open and let the browser show the offending field.
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||||
|
|
||||||
|
test('invalid forms are rejected instead of submitted with partial data', async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true,
|
||||||
|
channel: process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined) });
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await page.setContent('<html><body></body></html>');
|
||||||
|
await page.addScriptTag({ content: 'var $fis = {};' });
|
||||||
|
for (const file of [
|
||||||
|
'Fuchs/wwwroot/lib/jquery/jquery.js',
|
||||||
|
'Fuchs/js/intranet/oci_texts_basic_de.js',
|
||||||
|
'Fuchs/js/intranet/oci_basic.js',
|
||||||
|
'Fuchs/js/intranet/oci_mainbase.js',
|
||||||
|
'Fuchs/js/intranet/oci_main.js',
|
||||||
|
'Fuchs/js/intranet/fis_texts_gui_de.js',
|
||||||
|
'Fuchs/js/intranet/fis_main.js'
|
||||||
|
]) {
|
||||||
|
await page.addScriptTag({ content: read(file) });
|
||||||
|
}
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const out = {};
|
||||||
|
const posts = [];
|
||||||
|
const realPostXT = $ocms.postXT;
|
||||||
|
$ocms.postXT = function (o) { posts.push(o); if (o && typeof o.complete === 'function') { o.complete.call(this, { status: 200 }); } };
|
||||||
|
const ajax = [];
|
||||||
|
const realAjax = $.ajax;
|
||||||
|
$.ajax = (o) => { ajax.push(o); return $.Deferred().promise(); };
|
||||||
|
/* A required hidden field is never checked by the browser, so serializeObject is the only guard. */
|
||||||
|
const invalidForm = () => $('<form><input type="hidden" name="h" required value=""><input name="t" value="x"></form>').appendTo('body');
|
||||||
|
|
||||||
|
// rejectInvalid: stops the spinner and asks the browser to report the field
|
||||||
|
let reported = 0;
|
||||||
|
const f1 = invalidForm().ldng(1);
|
||||||
|
f1[0].reportValidity = () => { reported++; return false; };
|
||||||
|
f1.rejectInvalid();
|
||||||
|
out.rejectStopsLoading = !f1.hasClass('loading');
|
||||||
|
out.rejectReported = reported;
|
||||||
|
|
||||||
|
// postXT({ form }) sends nothing for an invalid form
|
||||||
|
realPostXT.call(document, { url: '/x', form: invalidForm() });
|
||||||
|
out.postXTAjaxCalls = ajax.length;
|
||||||
|
$.ajax = realAjax;
|
||||||
|
|
||||||
|
// sendForm: no post for an invalid form; a valid one posts and calls options.complete without throwing
|
||||||
|
posts.length = 0;
|
||||||
|
invalidForm().sendForm('/x');
|
||||||
|
out.sendFormInvalidPosts = posts.length;
|
||||||
|
let completed = null;
|
||||||
|
$('<form><input name="t" value="x"></form>').appendTo('body').sendForm('/x', null, { complete: (x) => { completed = x; } });
|
||||||
|
out.sendFormValidPosts = posts.length;
|
||||||
|
out.sendFormCompleteArg = completed;
|
||||||
|
|
||||||
|
// login.send: no TypeError on an invalid form, nothing posted
|
||||||
|
posts.length = 0;
|
||||||
|
const lf = invalidForm();
|
||||||
|
out.loginSendResult = $ocms.login.send.call(lf[0], { preventDefault() {} });
|
||||||
|
out.loginSendPosts = posts.length;
|
||||||
|
|
||||||
|
// dlg: the "check your input" note exists on every form dialog, even without required fields
|
||||||
|
const d = $ocms.dlg($$.dc('x').append('<input name="a">'), { form: true, confirm: () => {} });
|
||||||
|
out.noteInvalidWithoutRequired = d.ft.find('.note_invalid').length;
|
||||||
|
d.c.trigger('modal_close');
|
||||||
|
|
||||||
|
// dlgform (success mode): invalid form keeps the dialog open and never calls success
|
||||||
|
let succeeded = false;
|
||||||
|
const d2 = $ocms.dlgform([{ name: 'n', type: 'hidden', required: true, value: '' }], { success: () => { succeeded = true; } });
|
||||||
|
const form2 = $('body > .modal:last form');
|
||||||
|
form2.trigger('submit');
|
||||||
|
out.dlgformSucceeded = succeeded;
|
||||||
|
out.dlgformStillOpen = $('body > .modal').length;
|
||||||
|
out.dlgformLoading = form2.hasClass('loading');
|
||||||
|
out.dlgformInvalidNoteShown = form2.hasClass('invalid') && form2.find('.note_invalid').length;
|
||||||
|
form2.trigger('modal_close');
|
||||||
|
|
||||||
|
// password change: mismatch must not leave the spinner on or lock the form for good
|
||||||
|
window.confirm = () => true;
|
||||||
|
posts.length = 0;
|
||||||
|
$fis.resetPass();
|
||||||
|
const pf = $('body > .modal:last form');
|
||||||
|
pf.find('[name=opw]').val('old-password');
|
||||||
|
pf.find('[name=npw]').val('new-password-1');
|
||||||
|
pf.find('[name=npwc]').val('new-password-2');
|
||||||
|
pf.find('[name=code]').val('123456');
|
||||||
|
pf.trigger('submit');
|
||||||
|
const npw = pf.find('[name=npw]')[0];
|
||||||
|
out.mismatchPosts = posts.filter((p) => /changepassword/.test(p.url)).length;
|
||||||
|
out.mismatchLoading = pf.hasClass('loading');
|
||||||
|
out.mismatchMessage = npw.validationMessage;
|
||||||
|
pf.find('[name=npwc]').val('new-password-1').trigger('input');
|
||||||
|
out.afterEditValid = npw.checkValidity();
|
||||||
|
pf.trigger('submit');
|
||||||
|
out.matchPosts = posts.filter((p) => /changepassword/.test(p.url)).length;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.equal(r.rejectStopsLoading, true);
|
||||||
|
assert.equal(r.rejectReported, 1);
|
||||||
|
assert.equal(r.postXTAjaxCalls, 0, 'postXT must not send an invalid form');
|
||||||
|
assert.equal(r.sendFormInvalidPosts, 0, 'sendForm must not send an invalid form');
|
||||||
|
assert.equal(r.sendFormValidPosts, 1);
|
||||||
|
assert.deepEqual(r.sendFormCompleteArg, { status: 200 }, 'sendForm passes the jqXHR to options.complete');
|
||||||
|
assert.equal(r.loginSendResult, false);
|
||||||
|
assert.equal(r.loginSendPosts, 0);
|
||||||
|
assert.equal(r.noteInvalidWithoutRequired, 1);
|
||||||
|
assert.equal(r.dlgformSucceeded, false);
|
||||||
|
assert.equal(r.dlgformStillOpen, 1);
|
||||||
|
assert.equal(r.dlgformLoading, false);
|
||||||
|
assert.ok(r.dlgformInvalidNoteShown);
|
||||||
|
assert.equal(r.mismatchPosts, 0);
|
||||||
|
assert.equal(r.mismatchLoading, false, 'spinner must stop on a password mismatch');
|
||||||
|
assert.equal(r.mismatchMessage, 'Die Passwörter stimmen nicht überein');
|
||||||
|
assert.equal(r.afterEditValid, true, 'editing a password field must clear the mismatch error');
|
||||||
|
assert.equal(r.matchPosts, 1);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Run with: node --test e2e_tests/invoice-provisionperiod.cjs
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||||
|
|
||||||
|
test('service dates and periods survive entry, editing and reopening', async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true,
|
||||||
|
channel: process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined) });
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage({ timezoneId: 'Europe/Berlin' });
|
||||||
|
await page.setContent('<html><body></body></html>');
|
||||||
|
for (const file of [
|
||||||
|
'Fuchs/wwwroot/lib/jquery/jquery.js',
|
||||||
|
'Fuchs/js/intranet/oci_texts_basic_de.js',
|
||||||
|
'Fuchs/js/intranet/oci_basic.js',
|
||||||
|
'Fuchs/js/intranet/oci_mainbase.js',
|
||||||
|
'Fuchs/js/intranet/oci_main.js'
|
||||||
|
]) {
|
||||||
|
await page.addScriptTag({ content: read(file) });
|
||||||
|
}
|
||||||
|
// Use the production form builder/serializer and dialog submit handler;
|
||||||
|
// only the modal shell and server transport are replaced locally.
|
||||||
|
const source = read('Fuchs/js/intranet/modules/fis.inv_shared.js');
|
||||||
|
await page.addScriptTag({ content: 'var $inv = { d: { sync: function(delta) { window.lastDelta = delta; } } };\n'
|
||||||
|
+ source.slice(source.indexOf('$inv.provToIso ='), source.indexOf('/* Main recipient + CC')) });
|
||||||
|
const results = await page.evaluate(() => {
|
||||||
|
let dialog;
|
||||||
|
$ocms.dlg = (content, options) => { dialog = { content, options }; return dialog; };
|
||||||
|
function open(value) {
|
||||||
|
const t = $('<span>').text(value);
|
||||||
|
const changes = [];
|
||||||
|
$inv.eProvisionPeriod({ data: { t, change: (value) => changes.push(value) } });
|
||||||
|
return { t, changes, fields: dialog.content.find('input').map((i, e) => e.value).get() };
|
||||||
|
}
|
||||||
|
const cases = [
|
||||||
|
['', '2026-10-06', '', '06.10.2026'],
|
||||||
|
['', '2026-10-01', '2026-10-06', '01.10.2026 - 06.10.2026'],
|
||||||
|
['18.06.2026', '2026-10-06', '', '06.10.2026'],
|
||||||
|
['01.06.2026 - 30.06.2026', '2026-10-01', '2026-10-06', '01.10.2026 - 06.10.2026'],
|
||||||
|
['18.06.2026', '2026-10-01', '2026-10-06', '01.10.2026 - 06.10.2026'],
|
||||||
|
['01.06.2026 - 30.06.2026', '2026-10-06', '', '06.10.2026'],
|
||||||
|
['2026-06-18', '2026-10-06', '', '06.10.2026'],
|
||||||
|
['2026-06-01 - 2026-06-30', '2026-10-01', '2026-10-06', '01.10.2026 - 06.10.2026'],
|
||||||
|
['18.06.2026', '', '', ''],
|
||||||
|
['', '2026-10-06', '2026-10-06', '06.10.2026']
|
||||||
|
];
|
||||||
|
const dates = cases.map(([initial, von, bis, expected]) => {
|
||||||
|
const entry = open(initial);
|
||||||
|
dialog.content.find('[name=von]').val(von);
|
||||||
|
dialog.content.find('[name=bis]').val(bis);
|
||||||
|
dialog.options.confirm.call(dialog.content[0]);
|
||||||
|
const saved = window.lastDelta;
|
||||||
|
return { initial, von, bis, expected, saved, displayed: entry.t.text(), changes: entry.changes,
|
||||||
|
prefilled: entry.fields, reopened: open(saved.Value).fields };
|
||||||
|
});
|
||||||
|
const optional = $('<div><input name="date" type="date" data-format="date:dd.MM.yyyy;yyyy-MM-dd"></div>');
|
||||||
|
const emptyOptional = optional.serializeObject(true);
|
||||||
|
const emptyOptionalTyped = optional.serializeObject(true, { typedvalues: true });
|
||||||
|
optional.find('input').prop('required', true);
|
||||||
|
const emptyRequired = optional.serializeObject(true);
|
||||||
|
let submitted = false;
|
||||||
|
$ocms.dlgform([{ name: 'date', type: 'date', required: true }], { success: () => { submitted = true; } });
|
||||||
|
dialog.options.confirm.call(dialog.content[0]);
|
||||||
|
return { dates, emptyOptional, emptyOptionalTyped, emptyRequired, submitted };
|
||||||
|
});
|
||||||
|
for (const row of results.dates) {
|
||||||
|
const label = JSON.stringify([row.initial, row.von, row.bis]);
|
||||||
|
assert.deepEqual(row.saved, { Target: 'provisionperiod', Value: row.expected }, label);
|
||||||
|
assert.equal(row.displayed, row.expected, label);
|
||||||
|
assert.deepEqual(row.changes, [row.expected], label);
|
||||||
|
assert.deepEqual(row.reopened, row.von ? [row.von, row.bis === row.von ? '' : row.bis] : ['', ''], label);
|
||||||
|
}
|
||||||
|
assert.deepEqual(results.dates[2].prefilled, ['2026-06-18', '']);
|
||||||
|
assert.deepEqual(results.dates[3].prefilled, ['2026-06-01', '2026-06-30']);
|
||||||
|
assert.deepEqual(results.dates[6].prefilled, ['2026-06-18', '']);
|
||||||
|
assert.deepEqual(results.dates[7].prefilled, ['2026-06-01', '2026-06-30']);
|
||||||
|
assert.deepEqual(results.emptyOptional, { date: '' });
|
||||||
|
assert.deepEqual(results.emptyOptionalTyped, { date: '' });
|
||||||
|
assert.equal(results.emptyRequired, null);
|
||||||
|
assert.equal(results.submitted, false, 'invalid forms must not invoke success and clear existing values');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user