Compare commits

...
7 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
StefanandClaude Opus 5.5 d0002ccdad fix(invoices): send invoice amounts as exact cent-rounded decimals
Playwright Tests / test (push) Canceled after 0s
Stornorechnung saves still failed: the VAT amount was formatted invariantly
("-615.3757999999999") and re-parsed under the server's de-DE culture as
-6153757999999999, overflowing numeric(10,3) in fds__setInvoice.

- FdsInvoiceData.BuildInvoiceParams: amount params are decimal(10,x) via
  AmountParam/ParseAmount (culture-independent, rounded to cents); amounts
  beyond +/-9,999,999.99 throw InvoiceAmountOutOfRangeException.
- InvoiceService.RegisterInvoiceAsync: refuses such an invoice with a logged
  error (EventId InvoiceAmountRejected) instead of storing a wrong amount.
- Invoice editor: vat_val is cent-rounded decimal, no float noise.
- Tests: InvoiceAmountParamTests replaces the float-based Storno test.
- Bump Azure.Storage.Blobs 12.30.0; OCORE/OCORE_web submodules (AngleSharp
  1.8.3, Azure.Storage.Files.Shares 12.28.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:57:41 +02:00
StefanandClaude Opus 4.8 0a8384ab33 fix(invoices): Stornorechnung save failed on negative totals
Customer log: /do/inv/dsave failed with "Error converting data type float to
numeric" followed by "Cannot insert the value NULL into column 'InvId'".

- Bump OCORE: SQL_Float/SQL_Decimal string overloads parsed negatives via the
  de-DE server culture ("-19.95" -> -1995); float noise overflowed
  numeric(10,3), so fds__setInvoice never ran.
- InvoiceService: the header save batch now stops with a clear THROW when
  fds__createInvoice/fds__setInvoice yields no @Id, instead of failing again
  in fds__createInvoice_Details and burying the real error. Batch SQL
  extracted to BuildRegisterInvoiceSql for testing.
- Tests: signed/culture parsing under de-DE, Storno param build against the
  proc numeric limits, and the batch guard ordering.
- hlp: read-only script to find invoice/reminder amounts stored scaled up
  by the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 10:07:13 +02:00
60 changed files with 1709 additions and 214 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);
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
/// </summary>
public class InvoiceAmountParamTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public void Dispose() => CultureInfo.CurrentCulture = _culture;
private static readonly string[] AmountParams =
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
{
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
["sms"] = new JObject
{
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
}
});
private static decimal Val(List<SqlParameter> pl, string name)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
return Assert.IsType<decimal>(p.Value);
}
public static TheoryData<string, object> CulturesAndVat => new()
{
{ "de-DE", -615.38m },
{ "en-US", -615.38m },
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
{ "en-US", -615.3757999999999 },
{ "de-DE", -615.3800000000001 },
{ "", -615.38m }, // invariant
};
[Theory]
[MemberData(nameof(CulturesAndVat))]
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
}
[Fact]
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
{
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
foreach (var name in AmountParams)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(10, p.Precision);
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
}
}
[Theory]
[InlineData(-10_000_000.00)]
[InlineData(10_000_000.00)]
[InlineData(-6153757999999999.0)]
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
{
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
}
[Theory]
[InlineData(9_999_999.99)]
[InlineData(-9_999_999.99)]
[InlineData(0.0)]
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
{
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
}
[Fact]
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
{
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
}
[Theory]
[InlineData("-19.95", "-19.95")]
[InlineData("-19,95", "-19.95")]
[InlineData("-1234.5600000000002", "-1234.56")]
[InlineData("-615.3757999999999", "-615.38")]
[InlineData("1234,565", "1234.57")] // rounded half away from zero
[InlineData("-1234,565", "-1234.57")]
[InlineData("-1.5E-05", "0")]
[InlineData(" 42 ", "42")]
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
{
foreach (var c in new[] { "de-DE", "en-US" })
{
CultureInfo.CurrentCulture = new CultureInfo(c);
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
Assert.Null(FdsInvoiceData.ParseAmount(raw));
[Fact]
public void ParseAmount_NumbersAndJValues_TakenAsIs()
{
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
}
}
+4 -2
View File
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
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.Equal("INV42", svc.Get(s.Token)!.InvId);
@@ -680,9 +681,10 @@ public class InvoiceDraftServiceTests
payload["invid"] = "INV7";
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(result!.Updated);
}
[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);
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The invoice header save batch must stop as soon as fds__createInvoice / fds__setInvoice fails:
/// previously a parameter conversion error left @Id NULL and the batch went on to
/// fds__createInvoice_Details, whose "Cannot insert NULL into InvId" buried the real error.
/// </summary>
public class InvoiceRegisterSqlTests
{
[Theory]
[InlineData(false, "[dbo].[fds__createInvoice] ", "fds__createInvoice did not return")]
[InlineData(true, "[dbo].[fds__setInvoice] ", "fds__setInvoice did not return")]
public void BuildRegisterInvoiceSql_GuardSitsBetweenHeaderProcAndDetails(bool change, string headerProc, string guardText)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: true);
int header = sql.IndexOf(headerProc, StringComparison.Ordinal);
int guard = sql.IndexOf("IF @Id IS NULL THROW", StringComparison.Ordinal);
int details = sql.IndexOf("[dbo].[fds__createInvoice_Details]", StringComparison.Ordinal);
int update = sql.IndexOf("UPDATE [dbo].[fds__invoices]", StringComparison.Ordinal);
Assert.True(header >= 0, "header proc missing");
Assert.True(header < guard && guard < details && details < update, sql);
Assert.Contains(guardText, sql);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterInvoiceSql_ExactlyOneHeaderProcPerMode(bool change)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: false);
Assert.Equal(change, sql.Contains("[dbo].[fds__setInvoice] @InvId,"));
Assert.Equal(!change, sql.Contains("[dbo].[fds__createInvoice] @InvoiceType,"));
Assert.Equal(change, sql.Contains("@InvId"));
}
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
public void BuildRegisterInvoiceSql_ProvisionLocationUpdateOnlyWhenPresent(bool hasProvisionLocation, bool expected)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation);
Assert.Equal(expected, sql.Contains("@ProvisionLocation"));
}
[Fact]
public void BuildRegisterInvoiceSql_EveryStatementIsSemicolonTerminated()
{
// THROW requires the preceding statement to be terminated with ';'.
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation: true);
foreach (var line in sql.Split('\n'))
Assert.EndsWith(";", line.TrimEnd());
}
}
+4 -2
View File
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
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.Equal("REM42", svc.Get(s.Token)!.RemId);
// the email/subject the editor set must reach registration
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
payload["remid"] = "REM7";
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(result!.Updated);
}
[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());
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.SQL.sql;
namespace Fuchs.Tests;
/// <summary>
/// Regression tests for the OCORE string → number SQL parameter helpers. Saving a Stornorechnung
/// failed in production with "Error converting data type float to numeric": the negative totals
/// did not parse in the first (sign-less) attempt and fell back to the server's de-DE culture,
/// where "." is a group separator — "-19.95" became -1995 and JS float noise
/// ("-1234.5600000000002") overflowed numeric(10,3), so fds__setInvoice never ran.
/// Every test runs under de-DE, the production server culture.
/// </summary>
public class SqlNumberParameterTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public SqlNumberParameterTests() => CultureInfo.CurrentCulture = new CultureInfo("de-DE");
public void Dispose() => CultureInfo.CurrentCulture = _culture;
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1234.5600000000002", -1234.56)]
[InlineData("-0.5", -0.5)]
[InlineData("1234.56", 1234.56)]
[InlineData("1234.5600000000002", 1234.56)]
[InlineData("-1,5", -1.5)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("1,234.56", 1234.56)]
[InlineData(" -42 ", -42)]
[InlineData("-1.5E-05", -0.000015)]
[InlineData("0", 0)]
public void SQL_Float_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Float("@v", stringvalue: input);
var value = Assert.IsType<float>(p.Value);
Assert.Equal(expected, value, 0.01);
}
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("-1,5", -1.5)]
public void SQL_Decimal_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Decimal("@v", input, precision: 10, scale: 3);
var value = Assert.IsType<decimal>(p.Value);
Assert.Equal((decimal)expected, value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData("--5")]
public void SQL_Float_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Float("@v", stringvalue: input);
Assert.Equal(DBNull.Value, p.Value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
public void SQL_Decimal_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Decimal("@v", input);
Assert.Equal(DBNull.Value, p.Value);
}
}
@@ -107,16 +107,15 @@ public partial class IntranetController
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _invoiceDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.InvId);
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdInv == null) return DraftGone();
// Create vs update is decided inside the flush (flushes of one session are serialised), not
// from the session state seen here: a save overlapping the first registration is an update.
var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdInv = flush.Data;
if (string.IsNullOrEmpty(fdInv.Id))
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);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, invid = fdInv.Id });
@@ -475,7 +475,9 @@ public partial class IntranetController
{
["Id"] = d["Id"],
["net_val"] = net,
["vat_val"] = net * vat * 0.01,
// Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = d.no("value_service", 0),
["net"] = d.no("value", 0),
@@ -82,16 +82,14 @@ public partial class IntranetController
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _reminderDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.RemId);
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdRem == null) return DraftGone();
// Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdRem = flush.Data;
if (string.IsNullOrEmpty(fdRem.Id))
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);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, remid = fdRem.Id });
+22 -4
View File
@@ -1,6 +1,6 @@
---
status: Active
lastUpdated: 2026-09-09
lastUpdated: 2026-10-01
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "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
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
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
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
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
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
`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
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
later save of the same session runs `fds__setReminder`, which updates the row in place (version
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
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
- **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
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
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.
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
(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" />
<!-- New packages (needed for .NET 10) -->
<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.Drawing.Common" Version="10.0.12" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
</ItemGroup>
<ItemGroup>
<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>
/// Persists the cached session to the DB via the existing invoice registration path
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
/// 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>
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>
Task<Document?> RenderPreviewAsync(string token);
+6 -3
View File
@@ -43,10 +43,13 @@ public interface IReminderDraftService
/// <summary>
/// Persists the cached session to the DB via the existing reminder registration path
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
/// 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>
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>
Document? RenderPreview(string token);
+92 -54
View File
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
// The editor fires patches (and state/preview fetches) concurrently for one token; without
// the lock a block.replace on one request mutates Req while another enumerates it.
lock (session.SyncRoot)
{
_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;
}
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>
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
}
// ── 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)
{
session.Touch();
return new
lock (session.SyncRoot)
{
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
return new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = session.Sums.VatByRate,
block_net = session.Sums.NetByBlock
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count,
notes = BuildNotes(session),
setDisplay = BuildSetDisplay(session)
};
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin.DeepClone(),
@new = session.New.DeepClone(),
req = session.Req.DeepClone(),
sums = new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
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>
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
.Select(n => new { text = n.Text, style = n.Style });
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
{
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── 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);
if (session == null) return null;
var fds = BuildFdsData(session);
bool change = !string.IsNullOrEmpty(session.InvId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
// One flush at a time per session: the create/update decision below is read-then-write across
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
// first one created (with its own, newer snapshot).
await session.FlushGate.WaitAsync();
try
{
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);
FdsInvoiceData fds;
string invId;
lock (session.SyncRoot)
{
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
invId = session.InvId;
}
bool change = !string.IsNullOrEmpty(invId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
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)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
FdsInvoiceData fds;
lock (session.SyncRoot)
{
fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
}
+56 -18
View File
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
private string Conn => _intranet.Intranet__SQLConnectionString;
private static readonly EventId InvoiceAmountRejected = new(0, "InvoiceAmountRejected");
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
@@ -151,6 +153,27 @@ public class InvoiceService : IInvoiceService
return blocks;
}
/// <summary>
/// The header save batch for <see cref="RegisterInvoiceAsync"/>. If the create/set proc fails
/// (e.g. a parameter conversion error) the batch would otherwise carry on with a NULL
/// <c>@Id</c> and fail again in <c>fds__createInvoice_Details</c> ("Cannot insert NULL into
/// InvId"), burying the real error; the guard stops the batch right after the first failure.
/// </summary>
internal static string BuildRegisterInvoiceSql(bool change, bool hasProvisionLocation)
{
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
sqlParts.Add(change
? "EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;"
: "EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add(change
? "IF @Id IS NULL THROW 50001, N'fds__setInvoice did not return an invoice id - invoice header not saved.', 1;"
: "IF @Id IS NULL THROW 50001, N'fds__createInvoice did not return an invoice id - invoice header not saved.', 1;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
if (hasProvisionLocation)
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
return string.Join("\n", sqlParts);
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec)
{
@@ -158,28 +181,26 @@ public class InvoiceService : IInvoiceService
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
try
{
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
}
catch (InvoiceAmountOutOfRangeException ex)
{
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
_logger.LogError(InvoiceAmountRejected, ex,
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
invId, ex.Parameter, ex.Value, userAccountId);
return invoice;
}
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
{
if (change) pl.Add(SQL_VarChar("@InvId", invId));
bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
if (hasProvisionLocation)
pl.Add(SQL_NVarChar("@ProvisionLocation",
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
}
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
var invdset = await getSQLDataSet_async(BuildRegisterInvoiceSql(change, hasProvisionLocation),
Conn, pl, tablenames: new[] { "inv", "det" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception))
@@ -299,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)
{
using var act = FuchsTelemetry.StartActivity("invoice.render");
@@ -318,6 +355,7 @@ public class InvoiceService : IInvoiceService
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
};
ApplyProvisionPeriod(tb, reg?.getString("ProvisionPeriod"));
var inlineImages = await ResolveInlineImagesAsync(invoice);
var doc = await _pdf.WriteLetterAsync(tb, draft);
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
+78 -48
View File
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
{
_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;
}
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>
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
public object BuildState(ReminderDraftSession session)
{
session.Touch();
return new
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
{
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
return 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 }),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New.DeepClone(),
rem = session.Rem.DeepClone(),
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>
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
return new { intro, closing };
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
{
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── 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);
if (session == null) return null;
var fds = BuildReminderData(session);
bool change = !string.IsNullOrEmpty(session.RemId);
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
await session.FlushGate.WaitAsync();
try
{
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);
FdsReminderData fds;
string remId;
lock (session.SyncRoot)
{
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
remId = session.RemId;
}
bool change = !string.IsNullOrEmpty(remId);
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
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)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
FdsReminderData fds;
lock (session.SyncRoot)
{
fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true);
}
+23 -7
View File
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
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,
string userAccountId, DatabaseSecurity dbSec)
{
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
};
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change || string.IsNullOrEmpty(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));
change = change && !string.IsNullOrEmpty(remId);
if (change) 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" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception))
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
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;
}
+64 -5
View File
@@ -173,9 +173,9 @@ public class FdsInvoiceData
{
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
AmountParam("@InvoiceBalance", NewValues is null ? 0m : NewValues.getItem("total_gross"), scale: 3),
AmountParam("@InvoiceBalance_net", NewValues is null ? 0m : NewValues.getItem("total_net"), scale: 3),
AmountParam("@InvoiceVAT_net1", vatNet, scale: 3),
SQL_VarChar("@InvoiceVAT_1", vatRate),
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
@@ -185,12 +185,63 @@ public class FdsInvoiceData
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
AmountParam("@InvoiceService_net", Sms is null ? 0m : Sms.getItem("tscn"), scale: 2),
AmountParam("@InvoiceService_VAT", Sms is null ? 0m : Sms.getItem("tscvat"), scale: 2),
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
};
}
/// <summary>Largest magnitude the <c>numeric(10,x)</c> amount parameters of the invoice procs accept.</summary>
internal const decimal MaxAmount = 9_999_999.99m;
/// <summary>
/// Invoice amounts go to SQL as exact <c>decimal</c> parameters rounded to cents — never through
/// a culture-dependent string → float round trip. That round trip once parsed negative amounts
/// with the server's de-DE culture ("-615.3758" → -6153758, with float noise beyond numeric(10,3)),
/// so Stornorechnungen could not be saved. An amount outside the procs' range is refused here
/// (<see cref="InvoiceAmountOutOfRangeException"/>) instead of being stored wrongly or failing in SQL.
/// </summary>
internal static SqlParameter AmountParam(string name, object? raw, byte scale)
{
decimal? value = ParseAmount(raw);
if (value is { } v && Math.Abs(v) > MaxAmount)
throw new InvoiceAmountOutOfRangeException(name, v);
return new SqlParameter(name, System.Data.SqlDbType.Decimal)
{
Precision = 10,
Scale = scale,
Value = value.HasValue ? value.Value : DBNull.Value
};
}
/// <summary>
/// Reads an amount the draft session or editor supplied: numbers are taken as-is; strings may be
/// German ("-1234,56") or invariant ("-1234.56", "-1.5E-05") — a comma marks German formatting.
/// The result is rounded to cents. Empty / "NaN" / unparseable → null (stored as NULL).
/// </summary>
internal static decimal? ParseAmount(object? raw)
{
if (raw is JValue jv) raw = jv.Value;
decimal d;
switch (raw)
{
case null: return null;
case decimal m: d = m; break;
case double dbl when double.IsFinite(dbl): d = (decimal)dbl; break;
case float flt when float.IsFinite(flt): d = (decimal)flt; break;
case int or long or short or byte: d = Convert.ToDecimal(raw, CultureInfo.InvariantCulture); break;
case double or float: return null;
default:
string s = (Convert.ToString(raw, CultureInfo.InvariantCulture) ?? "").Trim();
var culture = s.Contains(',') ? GermanCulture : CultureInfo.InvariantCulture;
if (!decimal.TryParse(s, NumberStyles.Float, culture, out d)) return null;
break;
}
return Math.Round(d, 2, MidpointRounding.AwayFromZero);
}
private static readonly CultureInfo GermanCulture = new("de-DE");
/// <summary>
/// Builds the InvoiceOptions CSV from the posted admin flags.
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&lt;mode&gt;</c>
@@ -253,6 +304,14 @@ public class FdsInvoiceData
}
}
/// <summary>An invoice amount exceeds what the invoice procs' <c>numeric(10,x)</c> parameters can store.</summary>
public sealed class InvoiceAmountOutOfRangeException(string parameter, decimal value)
: Exception($"Invoice amount {parameter} = {value.ToString(CultureInfo.InvariantCulture)} is outside ±{FdsInvoiceData.MaxAmount.ToString(CultureInfo.InvariantCulture)}.")
{
public string Parameter { get; } = parameter;
public decimal Value { get; } = value;
}
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
public sealed class InvoiceBlock
{
+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>
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;
}
+1 -1
View File
@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
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>
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;
}
+11 -2
View File
@@ -130,9 +130,18 @@ $fis.resetPass = function (id, fds) {
title: $t.rsp || '',
submit: function (e) {
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 || '')) {
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 {
$ocms.postXT({
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
backend maps to the eRechnung service date/period. */
$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})/);
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
};
$inv.eProvisionPeriod = function (ev) {
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 = [
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
@@ -1133,7 +1135,8 @@ $inv.eProvisionPeriod = function (ev) {
title: 'Leistungsdatum / -zeitraum',
addcontent: hint,
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));
if (ev.data && ev.data.t) { ev.data.t.rwText(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) {
//console.debug(ev);
let btn = $(this), rx = ev.data;
let btn = $(this), rx = ev.data, params = btn.serializeObject();
if (params === null) { return; }
$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');
$('#listframe').rC('fix').aC('hd');
repfrm.html(response);
@@ -55,6 +55,7 @@ let $spc = {
let f = c.form[0];
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
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; }
c.result.hide();
btn.prop('disabled', true).text($spt.working);
+3
View File
@@ -106,6 +106,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/create'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
@@ -141,6 +142,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
@@ -162,6 +164,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/email'), data: qs, success: function () {
c.trigger('modal_close');
+2
View File
@@ -58,6 +58,7 @@ let $ugp = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
c.trigger('modal_close');
@@ -79,6 +80,7 @@ let $ugp = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
c.trigger('modal_close');
+15 -5
View File
@@ -488,6 +488,7 @@ $ocms.postXT = function (options) {
}
if (options.form instanceof jQuery) {
options.data = options.form.serializeObject();
if (options.data === null) { options.form.rejectInvalid(); return; }
options.contentType = 'form-data';
} else if (options.lzw instanceof jQuery) {
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
@@ -647,7 +648,7 @@ $ocms.vbl_send = function (ev) {
if (rcheckableType.test(type) === 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 */
if (typeof val === 'boolean') {
val = null;
@@ -720,6 +721,13 @@ $ocms.vbl_send = function (ev) {
c.toggleClass('invalid', !valid);
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) {
var form = $(this);
options = options || {};
@@ -734,11 +742,13 @@ $ocms.vbl_send = function (ev) {
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
$ocms.failure.call(this, jqXHR);
}
}, complete: function () {
}, complete: function (jqXHR) {
form.ldng(0);
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"]');
postoptions.data = new FormData();
if (fle.length > 0) {
@@ -747,8 +757,7 @@ $ocms.vbl_send = function (ev) {
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);
});
form.ldng();
@@ -781,7 +790,7 @@ $ocms.login = {
return false;
} else {
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 || '');
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
alert($t.l16);
@@ -840,6 +849,7 @@ $ocms.login = {
d.find('form').submit(function (e) {
e.preventDefault();
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
if (qs === null) { $(this).rejectInvalid(); return false; }
$ocms.postXT.call(this, {
url: $ocms.url(u), data: qs, complete: function () {
if (s2) {
+8 -2
View File
@@ -360,6 +360,8 @@
};
if (c.find(':input[required]').length > 0) {
$$.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.'));
}
if (f('cancel') === true) {
@@ -613,7 +615,9 @@
} else if (typeof options.success === 'function') {
cfm = function (e) {
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 || '') !== ''){
$ocms.postXT({
url: options.url, data: qs, success: function (response) {
@@ -657,7 +661,9 @@
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
let f_submit = function (e) {
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({
url: '/vt/login', data: qs, success: function (response) {
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
backend maps to the eRechnung service date/period. */
$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})/);
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
};
$inv.eProvisionPeriod = function (ev) {
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 = [
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
@@ -1715,7 +1717,8 @@ $inv.eProvisionPeriod = function (ev) {
title: 'Leistungsdatum / -zeitraum',
addcontent: hint,
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));
if (ev.data && ev.data.t) { ev.data.t.rwText(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) {
options.data = options.form.serializeObject();
if (options.data === null) { options.form.rejectInvalid(); return; }
options.contentType = 'form-data';
} else if (options.lzw instanceof jQuery) {
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
@@ -884,7 +885,7 @@ $ocms.vbl_send = function (ev) {
if (rcheckableType.test(type) === 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 */
if (typeof val === 'boolean') {
val = null;
@@ -957,6 +958,13 @@ $ocms.vbl_send = function (ev) {
c.toggleClass('invalid', !valid);
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) {
var form = $(this);
options = options || {};
@@ -971,11 +979,13 @@ $ocms.vbl_send = function (ev) {
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
$ocms.failure.call(this, jqXHR);
}
}, complete: function () {
}, complete: function (jqXHR) {
form.ldng(0);
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"]');
postoptions.data = new FormData();
if (fle.length > 0) {
@@ -984,8 +994,7 @@ $ocms.vbl_send = function (ev) {
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);
});
form.ldng();
@@ -1018,7 +1027,7 @@ $ocms.login = {
return false;
} else {
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 || '');
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
alert($t.l16);
@@ -1077,6 +1086,7 @@ $ocms.login = {
d.find('form').submit(function (e) {
e.preventDefault();
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
if (qs === null) { $(this).rejectInvalid(); return false; }
$ocms.postXT.call(this, {
url: $ocms.url(u), data: qs, complete: function () {
if (s2) {
@@ -2139,6 +2149,8 @@ class NumArray extends Array {
};
if (c.find(':input[required]').length > 0) {
$$.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.'));
}
if (f('cancel') === true) {
@@ -2392,7 +2404,9 @@ class NumArray extends Array {
} else if (typeof options.success === 'function') {
cfm = function (e) {
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 || '') !== ''){
$ocms.postXT({
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 f_submit = function (e) {
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({
url: '/vt/login', data: qs, success: function (response) {
if (((response || {}).login || '') !== '') {
@@ -2998,9 +3014,18 @@ $fis.resetPass = function (id, fds) {
title: $t.rsp || '',
submit: function (e) {
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 || '')) {
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 {
$ocms.postXT({
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) {
//console.debug(ev);
let btn = $(this), rx = ev.data;
let btn = $(this), rx = ev.data, params = btn.serializeObject();
if (params === null) { return; }
$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');
$('#listframe').rC('fix').aC('hd');
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
backend maps to the eRechnung service date/period. */
$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})/);
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
};
$inv.eProvisionPeriod = function (ev) {
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 = [
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
@@ -1696,7 +1698,8 @@ $inv.eProvisionPeriod = function (ev) {
title: 'Leistungsdatum / -zeitraum',
addcontent: hint,
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));
if (ev.data && ev.data.t) { ev.data.t.rwText(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];
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
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; }
c.result.hide();
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) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/create'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
@@ -187,6 +188,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
@@ -208,6 +210,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/email'), data: qs, success: function () {
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) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
c.trigger('modal_close');
@@ -99,6 +100,7 @@ let $ugp = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
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) {
options.data = options.form.serializeObject();
if (options.data === null) { options.form.rejectInvalid(); return; }
options.contentType = 'form-data';
} else if (options.lzw instanceof jQuery) {
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
@@ -701,7 +702,7 @@ $ocms.vbl_send = function (ev) {
if (rcheckableType.test(type) === 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 */
if (typeof val === 'boolean') {
val = null;
@@ -774,6 +775,13 @@ $ocms.vbl_send = function (ev) {
c.toggleClass('invalid', !valid);
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) {
var form = $(this);
options = options || {};
@@ -788,11 +796,13 @@ $ocms.vbl_send = function (ev) {
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
$ocms.failure.call(this, jqXHR);
}
}, complete: function () {
}, complete: function (jqXHR) {
form.ldng(0);
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"]');
postoptions.data = new FormData();
if (fle.length > 0) {
@@ -801,8 +811,7 @@ $ocms.vbl_send = function (ev) {
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);
});
form.ldng();
@@ -835,7 +844,7 @@ $ocms.login = {
return false;
} else {
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 || '');
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
alert($t.l16);
@@ -894,6 +903,7 @@ $ocms.login = {
d.find('form').submit(function (e) {
e.preventDefault();
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
if (qs === null) { $(this).rejectInvalid(); return false; }
$ocms.postXT.call(this, {
url: $ocms.url(u), data: qs, complete: function () {
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_copy.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_Details.sql" />
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
@@ -1,5 +1,9 @@
CREATE FUNCTION[dbo].[AddBusinessDays](@Date DATE,@n INT)
RETURNS DATE AS
-- INLINE = OFF: scalar UDF inlining of this @@DATEFIRST arithmetic (reached via date_add /
-- 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
DECLARE @d INT,@f INT,@DW INT;
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
@@ -2,6 +2,9 @@
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- 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]
(
@@ -10,6 +13,7 @@ CREATE FUNCTION [dbo].[date_add]
,@shift_to_first bit = 1
)
RETURNS date
WITH INLINE = OFF
AS
BEGIN
DECLARE @Ret date = @date;
@@ -2,6 +2,8 @@
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- 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]
(
@@ -9,6 +11,7 @@ CREATE FUNCTION [dbo].[date_addcustom]
,@phrase varchar(10)
)
RETURNS date
WITH INLINE = OFF
AS
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));
@@ -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: def034e155...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();
}
});
@@ -0,0 +1,22 @@
-- Read-only check for amounts mangled by the SQL_Float culture bug (fixed 2026-10-01).
-- No corrections are to be made from this: issued invoices are not changed retrospectively.
--
-- What was at risk: only the VAT amount of a negative invoice (Storno / Gutschrift / Korrektur).
-- FdsInvoiceData.HighestVat formatted it invariantly ("-615.38"), and the old OCORE SQL_Float parsed a
-- negative value with the server's de-DE culture ("." = thousands separator) -> -61538. When the VAT
-- carried float noise ("-615.3757999999999") the result overflowed numeric(10,3) and the save failed
-- (the reported error); without noise it would have been stored scaled by 10/100/1000.
-- The header totals (InvoiceBalance / _net) and service totals were formatted and parsed in the same
-- culture and were never affected. Negative amounts themselves are legitimate.
--
-- Bug signature: InvoiceVAT_net1 = (InvoiceBalance - InvoiceBalance_net) * 10 / 100 / 1000.
SELECT i.[Id], i.[InvoiceId], i.[InvoiceType], i.[IsFinal], i.[DateCreated], i.[DateModified],
i.[InvoiceBalance], i.[InvoiceBalance_net], i.[InvoiceVAT_net1],
vat_from_totals = i.[InvoiceBalance] - i.[InvoiceBalance_net],
vat_scaled_by_bug = CASE WHEN EXISTS (SELECT 1 FROM (VALUES (10), (100), (1000)) f(n)
WHERE i.[InvoiceBalance] - i.[InvoiceBalance_net] <> 0
AND ABS(i.[InvoiceVAT_net1] - (i.[InvoiceBalance] - i.[InvoiceBalance_net]) * f.n) <= 0.05 * f.n)
THEN 1 ELSE 0 END
FROM [dbo].[fds__invoices] i
WHERE i.[InvoiceBalance] < 0 OR i.[InvoiceBalance_net] < 0 OR i.[InvoiceVAT_net1] < 0
ORDER BY vat_scaled_by_bug DESC, i.[DateModified] DESC;