Compare commits

...
5 Commits
Author SHA1 Message Date
Stefan 24b94b05c2 Add invoice provision-period PDF handling
Playwright Tests / test (push) Canceled after 0s
Normalize invoice provision periods in PDF generation, switch admin labels between single-date and range modes, add end-to-end tests, update package and frontend bundle versions, and note the invoice lifecycle docs update.
2026-10-09 12:03:06 +02:00
StefanandClaude Opus 5.5 4443bba63c fix(forms): never submit an invalid form; unlock password-change dialog
Playwright Tests / test (push) Canceled after 0s
serializeObject() returns null for an invalid form, but most callers sent
the result anyway. Add $.fn.rejectInvalid (stop the spinner, let the
browser report the offending field) and use it at every call site.

Bugs fixed:
- Password change: on a mismatch the spinner kept running, and the custom
  validity on "npw" was never cleared, so the browser blocked every later
  submit even after the passwords matched. It is now cleared as soon as
  either password field is edited.
- sendForm: options.complete referenced an undefined jqXHR (ReferenceError)
  and an invalid form was posted with partial data.
- login.send: an invalid form threw a TypeError on qs.loginaccount.
- postXT({ form }): an invalid form was posted with empty data.
- Form dialogs without required fields had no "Bitte überprüfen Sie Ihre
  Eingaben" note, so a rejected submit showed nothing.

Hardening: null guards for the user, user-group, re-login, SMS password,
admin specialties and report-parameter submits.

Add e2e_tests/form-validation.cjs covering each path against the real
scripts; it fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:11:05 +02:00
StefanandClaude Opus 5.5 4145ab699c fix(invoices): Leistungsdatum editor lost or mis-formatted the service date
Playwright Tests / test (push) Canceled after 0s
- serializeObject: an empty optional date field was run through parseDt,
  became null and invalidated the whole form, so leaving "bis" empty made
  dlgform call success with only loginaccount and wiped the stored period.
  Empty optional date fields are now sent as ''.
- dlgform: an invalid form no longer calls success with partial data; it
  stops the spinner and keeps the dialog open.
- eProvisionPeriod: the date inputs return yyyy-MM-dd, which was saved
  verbatim; it is now stored as German dd.MM.yyyy as documented (PDF,
  eRechnung mapping). ISO values saved by the old editor are accepted when
  reopening, so their fields are prefilled instead of empty.
- Add a Playwright regression test (e2e_tests/invoice-provisionperiod.cjs)
  driving the real form builder, serializer and dialog submit handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:04:03 +02:00
Stefan e2faf52cd2 chore: Update submodule commits for OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-10-06 16:41:08 +02:00
StefanandClaude Fable 5.1 546648533b fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s
Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 13:57:40 +02:00
54 changed files with 1268 additions and 190 deletions
+388
View File
@@ -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);
}
}
+4 -2
View File
@@ -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);
}
}
+4 -2
View File
@@ -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]
+50
View File
@@ -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 });
+22 -4
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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\" />
+13
View File
@@ -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;
+6 -3
View File
@@ -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);
+6 -3
View File
@@ -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);
+92 -54
View File
@@ -60,27 +60,32 @@ 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;
string oldValue = "", newValue = ""; // The editor fires patches (and state/preview fetches) concurrently for one token; without
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue); // the lock a block.replace on one request mutates Req while another enumerates it.
if (!mutated) lock (session.SyncRoot)
{ {
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref); string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session; return session;
} }
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
} }
/// <summary> /// <summary>
@@ -424,33 +429,40 @@ 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();
return new lock (session.SyncRoot)
{ {
token = session.Token, return new
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
{ {
total_net = session.Sums.TotalNet, token = session.Token,
total_gross = session.Sums.TotalGross, version = session.Version,
total_vat = session.Sums.TotalVat, invid = session.InvId,
service_net = session.Sums.ServiceNet, isDraft = session.IsDraft,
service_vat = session.Sums.ServiceVat, admin = session.Admin.DeepClone(),
vat = session.Sums.VatByRate, @new = session.New.DeepClone(),
block_net = session.Sums.NetByBlock req = session.Req.DeepClone(),
}, sums = new
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }), {
historyCount = session.History.Count, total_net = session.Sums.TotalNet,
notes = BuildNotes(session), total_gross = session.Sums.TotalGross,
setDisplay = BuildSetDisplay(session) total_vat = session.Sums.TotalVat,
}; service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session).ToList(),
setDisplay = BuildSetDisplay(session)
};
}
} }
/// <summary> /// <summary>
@@ -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
if (!string.IsNullOrEmpty(reg.Id)) // first one created (with its own, newer snapshot).
await session.FlushGate.WaitAsync();
try
{ {
session.InvId = reg.Id; FdsInvoiceData fds;
_cache.Set(session); string invId;
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})", lock (session.SyncRoot)
token, reg.Id, change, userAccountId); {
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))
{
lock (session.SyncRoot) session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsInvoiceData>(reg, change);
}
finally
{
session.FlushGate.Release();
} }
return reg;
} }
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;
fds.InvoiceRegistration = SynthesizeRegistration(session); lock (session.SyncRoot)
{
fds = BuildFdsData(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);
} }
+17
View File
@@ -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") ?? "";
+78 -48
View File
@@ -59,27 +59,30 @@ 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;
string oldValue = "", newValue = ""; lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{ {
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref); string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session; return session;
} }
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
} }
/// <summary> /// <summary>
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
public object BuildState(ReminderDraftSession session) public object BuildState(ReminderDraftSession session)
{ {
session.Touch(); session.Touch();
return new lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
{ {
token = session.Token, return new
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
{ {
amount_total = session.Sums.AmountTotal, token = session.Token,
amount_payed = session.Sums.AmountPayed, version = session.Version,
amount_open = session.Sums.AmountOpen remid = session.RemId,
}, isDraft = session.IsDraft,
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }), @new = session.New.DeepClone(),
historyCount = session.History.Count, rem = session.Rem.DeepClone(),
notes = BuildNotes(session) sums = new
}; {
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
}
} }
/// <summary> /// <summary>
@@ -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();
if (!string.IsNullOrEmpty(reg.Id)) try
{ {
session.RemId = reg.Id; FdsReminderData fds;
_cache.Set(session); string remId;
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})", lock (session.SyncRoot)
token, reg.Id, change, userAccountId); {
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))
{
lock (session.SyncRoot) session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsReminderData>(reg, change);
}
finally
{
session.FlushGate.Release();
} }
return reg;
} }
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;
fds.ReminderRegistration = SynthesizeRegistration(session); lock (session.SyncRoot)
{
fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true; fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true); return _reminders.GenerateReminderPdf(fds, draft: true);
} }
+23 -7
View File
@@ -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;
} }
+18
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace Fuchs.intranet; namespace Fuchs.intranet;
+17
View File
@@ -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;
} }
+11 -2
View File
@@ -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) {
+5 -2
View File
@@ -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); }
+3 -2
View File
@@ -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);
+3
View File
@@ -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');
+2
View File
@@ -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');
+15 -5
View File
@@ -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) {
+8 -2
View File
@@ -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 || '') !== '') {
+5 -2
View File
@@ -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); }
File diff suppressed because one or more lines are too long
+34 -9
View File
@@ -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) {
+2 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+5 -2
View File
@@ -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); }
File diff suppressed because one or more lines are too long
+1
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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');
File diff suppressed because one or more lines are too long
+2
View File
@@ -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
View File
@@ -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;
+15 -5
View File
@@ -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) {
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -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
+130
View File
@@ -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();
}
});
+89
View File
@@ -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();
}
});