Compare commits

...
8 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
Stefan c741535aec feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s
- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
2026-09-30 16:56:08 +02:00
124 changed files with 4441 additions and 350 deletions
+3
View File
@@ -13,6 +13,7 @@
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -61,6 +62,8 @@
## Database
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
+3
View File
@@ -15,6 +15,7 @@
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -49,6 +50,8 @@
## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
+3
View File
@@ -15,6 +15,7 @@
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -49,6 +50,8 @@
## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -173,6 +173,62 @@ public class AzureBlobStorageServiceTests
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
}
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
[Fact]
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
}
[Fact]
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
{
var (service, _, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("network timeout"));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
Assert.Null(result);
}
[Fact]
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
}
[Theory]
[InlineData(false)] // storage error
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
{
var (service, _, blob) = CreateMockedClientChain();
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
}
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
[Fact]
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
/// </summary>
public class DatevOverviewCommandTests
{
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
private static readonly Regex ParamDecl = new(
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
RegexOptions.Compiled);
private static readonly Regex ExecArg = new(
@"(?<name>@\w+)\s*(?<named>=)?",
RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
private static List<(string Name, bool HasDefault)> ProcParameters()
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
return ParamDecl.Matches(sql[start..end])
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
.ToList();
}
/// <summary>
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
/// does not declare, and required (no default) parameters that are not supplied.
/// </summary>
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
{
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var m = ExecArg.Match(part);
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
string name = m.Groups["name"].Value;
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
problems.Add($"unknown parameter {name}");
supplied.Add(name);
}
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
[Fact]
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
{
var ps = ProcParameters();
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
}
[Fact]
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
[Theory]
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
public void BindingProblems_MisboundCommand_IsReported(string execSql)
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
[Fact]
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
}
+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);
}
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using fds;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
/// batch holding such a row was rejected with the column-less message 8152. Covers the
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
/// </summary>
public class FdsColumnWidthsTests
{
private const long LongNoteServiceObjectId = 38722404355;
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
{
var t = new DataTable("mfr__serviceobjects");
t.Columns.Add("setid", typeof(string));
t.Columns.Add("Id", typeof(long));
t.Columns.Add("Name", typeof(string));
t.Columns.Add("Note", typeof(string));
t.Columns.Add("CustomValues", typeof(string));
t.Columns.Add("Version", typeof(int));
foreach (var (id, note) in rows)
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
return t;
}
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
// ── IsTruncationError ────────────────────────────────────────────────────
[Theory]
[InlineData(8152, true)] // classic "String or binary data would be truncated."
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
[InlineData(2627, false)] // PK violation
[InlineData(515, false)] // NULL into NOT NULL
[InlineData(8672, false)] // MERGE duplicate source row
[InlineData(0, false)]
[InlineData(-2, false)] // client timeout
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
{
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
}
[Fact]
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
{
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
}
// ── WidthsSql ────────────────────────────────────────────────────────────
[Fact]
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
{
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
}
// ── ToWidthMap ───────────────────────────────────────────────────────────
private static DataTable WidthRows(params (object Name, object Max)[] rows)
{
var t = new DataTable();
t.Columns.Add("columnname", typeof(object));
t.Columns.Add("maxchars", typeof(object));
foreach (var (n, m) in rows) t.Rows.Add(n, m);
return t;
}
[Fact]
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
Assert.Equal(255, map["note"]);
Assert.Equal(5, map["SETID"]);
}
[Fact]
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
Assert.Equal(255, Assert.Single(map).Value);
}
[Fact]
public void ToWidthMap_InvalidRows_AreSkipped()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
Assert.Equal(new[] { "Ok" }, map.Keys);
}
[Fact]
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
var wrong = new DataTable();
wrong.Columns.Add("name");
wrong.Rows.Add("Note");
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
}
// ── FindOverflows ────────────────────────────────────────────────────────
[Theory]
[InlineData(0, false)]
[InlineData(254, false)]
[InlineData(255, false)] // exactly the limit fits
[InlineData(256, true)] // one over is rejected by SQL Server
[InlineData(753, true)] // the production ServiceObject note
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
{
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
if (!overflows) { Assert.Empty(result); return; }
var o = Assert.Single(result);
Assert.Equal("Note", o.Column);
Assert.Equal(255, o.MaxAllowed);
Assert.Equal(length, o.LongestLength);
Assert.Equal(1, o.RowsOver);
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
{
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
Assert.Equal(2, o.RowsOver);
Assert.Equal(539, o.LongestLength);
Assert.Equal(3L, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
{
var batch = ServiceObjectBatch((7, new string('n', 300)));
batch.Rows[0]["setid"] = "toolong";
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
}
[Fact]
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
{
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
}
[Fact]
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
{
var batch = ServiceObjectBatch((123456789012, "x"));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
}
[Fact]
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
}
[Fact]
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
{
var t = new DataTable("mfr__*PartnerSet");
t.Columns.Add("Property", typeof(string));
t.Rows.Add(new string('p', 60));
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
Assert.Null(o.LongestRowId);
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
}
// ── Describe ─────────────────────────────────────────────────────────────
[Fact]
public void Describe_NoOverflow_SaysNoneDetected()
{
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
}
[Fact]
public void Describe_Overflows_NameColumnLengthLimitAndId()
{
var text = FdsColumnWidths.Describe(new[]
{
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
});
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
}
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
private static readonly Regex ColumnDecl = new(
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
if (onlyWithin != null)
{
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
}
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in ColumnDecl.Matches(sql))
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
Assert.NotEmpty(map); // the regex still understands the file
return map;
}
public static IEnumerable<object[]> NoteCarriers() => new[]
{
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
new object[] { @"Tables\mfr__companies.sql", null! },
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
};
[Theory]
[MemberData(nameof(NoteCarriers))]
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
{
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
}
[Theory]
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
{
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
}
[Fact]
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
{
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
widths["Note"] = 255;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
}
}
+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()));
}
}
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
/// </summary>
public class InvoiceDatesTests
{
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
{
["DateFinalized"] = finalized,
["DateCreated"] = created
});
[Fact]
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
=> Assert.Equal(new DateOnly(2026, 9, 30),
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
=> Assert.Equal(new DateOnly(2026, 9, 2),
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
=> Assert.Equal(new DateOnly(2026, 3, 1),
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
[Theory]
// UTC late evening is already the next German day (summer +2h, winter +1h)
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
// backdated invoices are stored at 12:00 UTC and stay on their day
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
=> Assert.Equal(DateOnly.Parse(expected),
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
[Theory]
[InlineData("2026-09-30", 2026, 9, 30)]
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NULL")]
[InlineData("30.09.2026")]
[InlineData("2026-02-30")]
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
[InlineData("TRUE", true)]
[InlineData("on", true)]
[InlineData("0", false)]
[InlineData("false", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("yes please", false)]
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
[Theory]
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
[Fact]
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
// the number alone never arrives without a message; a row with no exception is a success
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
[Theory]
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
}
+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]
+32
View File
@@ -0,0 +1,32 @@
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
/// </summary>
public class InvoiceItemReferenceTests
{
[Theory]
[InlineData("XH6EH", "XH6EH")]
[InlineData(" 6BOY0 ", "6BOY0")]
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("NULL")]
[InlineData("123456789012345678901")] // longer than the column
[InlineData("AB'; DROP")]
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
}
@@ -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);
}
}
+39
View File
@@ -0,0 +1,39 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
/// </summary>
public class InvoicePgetIdTests
{
[Theory]
[InlineData("12345", 12345L)]
[InlineData(" 987654321 ", 987654321L)]
[InlineData("9223372036854775807", long.MaxValue)]
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
{
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData("XH6EH")] // Intranet invoice id
[InlineData("6BOY0")] // alphanumeric, starts with a digit
[InlineData("00000")] // numeric but not a valid (positive) mfr id
[InlineData("0")]
[InlineData("-5")]
[InlineData("1.5")]
[InlineData("1e3")]
[InlineData("9223372036854775808")] // overflow
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
{
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
}
}
+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());
}
}
+298
View File
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
/// function call must pass the declared number of arguments. None of this is visible to the
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
/// </summary>
public class SqlContractTests
{
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
private const char JoinedBreak = '\u0001';
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
private static readonly Regex Declaration = new(
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParameterDeclaration = new(
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex Reference = new(
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
private static string RepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
return dir.FullName;
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
return "";
}
internal static string StripSqlComments(string sql) =>
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
internal static SqlObject? ParseObject(string sql)
{
sql = StripSqlComments(sql);
var m = Declaration.Match(sql);
if (!m.Success) return null;
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
string header = "";
string rest = sql[(m.Index + m.Length)..];
if (kind == "FUNCTION")
{
int open = rest.IndexOf('('), depth = 0;
for (int i = open; open >= 0 && i < rest.Length; i++)
{
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
if (depth == 0) { header = rest[(open + 1)..i]; break; }
}
}
else if (kind == "PROCEDURE")
{
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
}
var ps = ParameterDeclaration.Matches(header)
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
.ToList();
return new SqlObject(m.Groups["name"].Value, kind, ps);
}
private static Dictionary<string, SqlObject> LoadProject()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
if (ParseObject(File.ReadAllText(file)) is { } o)
objects[o.Name] = o;
return objects;
}
/// <summary>
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
/// comments name objects too) and adjacent concatenated literals are joined, so an
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
/// </summary>
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
{
var lines = source.Split('\n');
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
// reported line number but does not end the statement's argument list.
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
foreach (Match m in Reference.Matches(code))
{
string name = m.Groups["name"].Value;
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
continue;
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
string tail = code[(m.Index + m.Length)..];
string args;
if (m.Groups["call"].Success)
{
int depth = 1, i = 0;
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
args = tail[..Math.Max(0, i - 1)];
}
else
{
int end = tail.IndexOfAny([';', '"', '\n']);
args = end < 0 ? tail : tail[..end];
}
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
}
}
private static List<SqlReference> ScanRepository()
{
string root = RepoRoot();
return ScannedProjects
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
.ToList();
}
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool sawNamed = false;
int position = 0;
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var named = Regex.Match(arg, @"^(@\w+)\s*=");
if (named.Success)
{
sawNamed = true;
string n = named.Groups[1].Value.ToLowerInvariant();
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
supplied.Add(n);
continue;
}
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
supplied.Add(ps[position++].Name);
}
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
}
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
{
var problems = new List<string>();
foreach (var r in refs)
{
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
: [];
problems.AddRange(found.Select(p => $"{at}: {p}"));
}
return problems;
}
// ── The contract, against the real repository ─────────────────────────────────────────────
[Fact]
public void ScanRepository_RealSources_FindsTheKnownCallSites()
{
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
var refs = ScanRepository();
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
{
var problems = Problems(ScanRepository(), LoadProject());
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
}
[Fact]
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
{
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
Assert.NotEmpty(updt);
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
}
[Fact]
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
[Theory]
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
[InlineData("@tgtdate, @mode, @files, @authuser")]
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
=> Assert.Empty(ExecuteProblems(args, Datev));
[Theory]
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
[InlineData("", "required parameter @tgtdate")]
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
[Theory]
[InlineData("'fds_reports', @authuser", 2, true)]
[InlineData("LEFT(@a, 3), @b", 2, true)]
[InlineData("", 0, true)]
[InlineData("@authuser", 2, false)]
[InlineData("@a, @b, @c", 2, false)]
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
{
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
}
[Fact]
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
{
const string src = """
var a = "EXECUTE [dbo].[proc_a] @x, " +
"@y;";
// "EXECUTE [dbo].[proc_commented] @z;"
/// <c>[dbo].[proc_doc]</c>
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
""";
var refs = ScanSource("x.cs", src).ToList();
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
Assert.Equal("@x, @y", refs[0].Args);
Assert.Equal(1, refs[0].Line);
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
Assert.Equal(5, refs[1].Line);
}
[Fact]
public void Problems_ObjectMissingFromProject_Reported()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
}
[Theory]
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
{
var o = ParseObject(sql)!;
Assert.Equal(kind, o.Kind);
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
}
}
+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);
}
}
@@ -3,6 +3,7 @@ using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
// only signals (ADR 0007).
public partial class IntranetController
{
/// <summary>
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
/// null when not allowed — fds_inv &gt; 2 and no invoice finalized after that date yet. The SQL function
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
/// </summary>
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<string>(
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
_intranet.Intranet__SQLConnectionString, "",
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
return InvoiceDates.ParseBackdate(val.Result);
}
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
@@ -71,7 +86,8 @@ public partial class IntranetController
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
// POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
@@ -81,7 +97,8 @@ public partial class IntranetController
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
});
}
@@ -90,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 });
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
// Partial class: Invoice sub-handlers
public partial class IntranetController
{
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
{
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
// storno window empty. Legacy parity: skip the mfr reset and report success.
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
return await JSONAsync(new { ok = true });
}
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = _mfrFactory.Create())
@@ -263,6 +275,16 @@ public partial class IntranetController
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
}
/// <summary>
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
/// </summary>
internal const string DatevOverviewSql =
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
{
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
@@ -274,10 +296,10 @@ public partial class IntranetController
string mode = Form("mode").ne("m");
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
DatevOverviewSql,
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
tablenames: new[] { "files", "invoices", "debits" },
tablenames: DatevOverviewTables,
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
@@ -453,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),
@@ -465,7 +489,10 @@ public partial class IntranetController
["NameOrNumber"] = "",
["htmltext"] = d["Text"],
["position"] = d["Position"],
["SortOrder"] = d["SortOrder"]
["SortOrder"] = d["SortOrder"],
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
});
}
sdic["items"] = itm;
@@ -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 });
@@ -1,5 +1,6 @@
using System.Globalization;
using Fuchs.intranet;
using Fuchs.Observability;
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
@@ -302,14 +303,41 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
// Admin option: date the invoice to the last working day of the previous month (ADR 0032).
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
_logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
// final/sent (double submit) — it must not be reported as "create it again without backdating".
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
if (backdate)
{
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
if (outcome == InvoiceFinalizeOutcome.Finalized)
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
}
switch (outcome)
{
case InvoiceFinalizeOutcome.BackdateNotAllowed:
return await InvoiceIssueResult(
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
Form("id"));
case InvoiceFinalizeOutcome.AlreadyFinal:
return await InvoiceIssueResult(
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
Form("id"));
case InvoiceFinalizeOutcome.Error:
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
}
var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
+1
View File
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
+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
@@ -0,0 +1,68 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceBackdate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ERechnungMapper.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
supersededBy: ""
---
# 0032 — Invoices may be backdated to the last working day of the previous month (admins only)
## Context
Work done at the end of a month is often invoiced in the first days of the next month. The customer
wants those invoices dated to the previous month. The invoice date was always the moment of
finalisation (`fds__setInvoiceFinal`: `DateFinalized = GETUTCDATE()`), and the invoice number is
drawn from the year of that date (`fds__newInvoiceId(Year(...))`).
Two invoice dates also existed side by side: the PDF printed `DateCreated` (draft creation) as
"Rechnungsdatum", while the embedded ZUGFeRD/XRechnung issue date (BT-2) used `DateFinalized` — a
visible date and an e-invoice date that could disagree.
## Decision
- **One invoice date.** PDF "Rechnungsdatum" and e-invoice issue date both come from
`InvoiceDates.IssueDate`: `DateFinalized` (converted from the stored UTC to the German calendar
day), falling back to `DateCreated` for drafts.
- **Backdating option.** When finalising, a user with `fds_inv > 2` may date the invoice to the
**last working day (Mon–Fri) of the previous month** — a fixed date, not a free choice. Public
holidays are not excluded (possible later extension).
- **Only while no invoice exists after that date.** Invoice numbers must ascend with invoice dates,
so the option is offered and accepted only while no invoice has been finalized after the backdate
day (a 2 h margin covers the UTC storage).
- **Single source of truth:** `[dbo].[fds__fn_invoiceBackdate](@authuser)` returns the allowed date or
NULL (authorisation + eligibility). `inv/dpreview` returns it so the preview dialog shows the
per-invoice checkbox; `req/sconf` passes `@backdate = 1`, and `fds__setInvoiceFinal` re-evaluates
the function and `THROW`s (60001) when it is no longer allowed — nothing is finalised then.
- **Serialized finalisation.** `fds__setInvoiceFinal` runs check + numbering `UPDATE` in one
transaction under an exclusive, transaction-owned app lock (`sp_getapplock`, resource
`fds__setInvoiceFinal`) taken by every finalisation, so a concurrent normal finalisation cannot
slip between the eligibility check and the backdated write and break the date/number order.
- `req/sconf` maps only SQL error 60001 to the "no longer possible, create without backdating"
message. A 0-row result (invoice already final/sent, e.g. a double submit) is reported as
"already finalised" — never as a backdate rejection, which would invite a duplicate invoice.
- A backdated invoice is stored with `DateFinalized = <day> 12:00` (UTC, stays on the same German
day), its number comes from that day's year, and `DueDate` is recomputed from the payment term
anchored on that day.
## Consequences
- The PDF of every newly finalised invoice now shows the finalisation date instead of the draft
creation date (intended: it now matches the e-invoice and the number sequence).
- In January the option yields a December date and therefore a number from the previous year's range.
- Lists, reports, DATEV and reminders follow the same invoice date — see ADR 0035.
- Telemetry: `fuchs.invoices.backdated` (outcome ok / rejected / already_final / error); successful
backdating logs EventId `InvoiceBackdated`.
## Alternatives considered
- **Free date picker for admins** — rejected: breaks the date/number order too easily; the need is
exactly "previous month's last working day".
- **Global admin switch "backdate everything until the first invoice of the month"** — rejected by
the customer in favour of an explicit per-invoice checkbox.
@@ -0,0 +1,52 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Tables/mfr__*.sql"
- "Fuchs_Database/dbo/User Defined Types/mfr__tt__*.sql"
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
- "Fuchs_DataService/FdsMfrClient.cs"
- "Fuchs_DataService/FdsColumnWidths.cs"
supersededBy: ""
---
# 0033 — mfr free-text columns are NVARCHAR(MAX); table, table type and copies stay aligned
## Context
mfr publishes no length limits for its string properties (`MFR_RESTClient/Docs/mfr_interface_description.md`).
`[mfr__serviceobjects].[Note]` and `[mfr__companies].[Note]` were `NVARCHAR(255)`, as were their
merge types `mfr__tt__serviceobjects` / `mfr__tt__companies`. From 2026-09-25, users typed longer
notes in mfr (ServiceObject up to 753 chars, Company up to 723). The `mfr__updt__*` merge proc copies
the staging table into the table type, so SQL Server rejected every batch holding such a row
(msg 8152, which names no column). That included every Company/Contact/ServiceRequest sync that
`$expand`s the row. The result was about 1000 errors in five days, plus rows the short sync never
re-fetched, because it only reads rows newer than the local `DateModified` watermark. Merge failures
were silent until 8bcec40 began logging them, so earlier occurrences went unnoticed.
## Decision
- Free-text mfr properties (`Note`, `Description`, `Comment`, …) are stored as `NVARCHAR(MAX)`.
Bounded widths are kept only for values mfr structurally keeps short (ids, codes, names), and
those are revisited when data proves otherwise.
- A width change is made in the target table, its `mfr__tt__*` type **and** every table variable
or type that copies the row with `SELECT *` (e.g. `@company` in `fds__getRequest_details`,
`fds__prepInvoice`, `fds__prepStorno_recreate`), all in the same change. Otherwise the truncation
just moves to the next copy.
- Truncating at the mapping layer was rejected. The data service does **not** truncate
silently. Instead, when a merge fails with 8152/2628, `FdsColumnWidths` compares the staged
batch against the narrower of the table and type widths. The error log then names column, longest
length, limit and an entity Id (`over-long columns: Note: 753 > 255 chars (1 row, e.g. Id=…)`).
This lookup runs only on the failure path.
## Consequences
- Changing a `mfr__tt__*` type means DROP + CREATE (types cannot be ALTERed). The procs that
DECLARE the type must be dropped first and recreated. See
`Fuchs_Database/Scripts/2026-09-30_mfr_truncation_fix.sql` for the pattern, including grants.
- After fixing a rejected merge, queue the affected Ids in `fds__mfr_updaterequests`. The short
sync will not revisit them, and the nightly full reset is disabled in `fds__getUpdateableTables`.
- `NVARCHAR(MAX)` columns cannot be index key columns. None of the widened columns is indexed.
## Alternatives considered
- **Truncate to the column width in `SetRowValue`**: this would keep syncing, but it loses user text
silently. For keys such as `ExternalId` (DATEV customer number) it would corrupt data. Rejected.
- **Widen to a larger fixed size (e.g. 1000, like `mfr__contacts.Note`)**: this just moves the
cliff. mfr enforces no limit. Rejected for free text.
@@ -0,0 +1,64 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_IntermediateIsAllocatedToOther.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepInvoice.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepStorno_recreate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__createStorno_*.sql"
- "Fuchs_Database/Scripts/2026-09-30_backfill_intermediate_invoice_allocations.sql"
supersededBy: ""
---
# 0034 — An Abschlagsrechnung credit follows the live invoice
## Context
A Schlussrechnung credits the Abschlagsrechnungen (intermediate invoices, `InvoiceType = 'i'`)
already paid for the same service requests via `IntInvoiceAllocation` lines. Finalising it records
the claim in `fds__invoice_details.AllocatedTo_InvId` of each credited intermediate so that
`fds__prepInvoice` does not offer the same Abschlagsrechnung again.
Until 2026-09-30 that claim was never actually written (the editor dropped `reference_InvId`, the
MERGE aborted on the NULL). The moment it is written, the question "what happens to the claim when
the crediting invoice is cancelled?" becomes real: a Storno (simple/copy) or a Storno + recreate
leaves `AllocatedTo_InvId` pointing at the cancelled invoice, and the next invoice for the same
service requests would silently bill the customer again for money already paid.
## Decision
- **A claim counts only while the crediting invoice is alive.** An `AllocatedTo_InvId` that points
at a cancelled invoice (`IsCanceled = 1`), at a non-existent invoice, or at the invoice that the
one being finalised replaces (`CanceledInvoice` line → `@Replaces`) is void: the Abschlagsrechnung
is offered again by `fds__prepInvoice` / `fds__prepStorno_recreate` and may be taken over by the
next finalisation. The single source of truth for "already credited elsewhere" is
`[dbo].[fds__fn_IntermediateIsAllocatedToOther]`; `fds__setInvoiceFinal`'s `existing` CTE is its
set-based twin and must apply the same rule.
- **The link is not cleared on cancellation.** Cancellation has several paths (storno simple/copy,
storno + recreate, the retired `fds__setInvoiceCancelled`); rather than releasing the link in each,
the readers ignore dead links. The last claimant therefore stays visible for auditing.
- **A Stornorechnung (`InvoiceType = 'c'`) never claims.** `fds__createStorno_copy` mirrors the
cancelled invoice's `IntInvoiceAllocation` lines (with their references) so the storno document
shows them, but finalising the storno must not move the allocation onto it.
- **Storno + recreate carries the references.** `fds__prepStorno_recreate` emits its credit lines
with the same type as `fds__prepInvoice` (`IntInvoiceAllocation`, not `PartialInvoice`) and the
credited invoice in `reference_InvId`, and its `CanceledInvoice` line references the replaced
invoice — otherwise the replacement neither takes over the credit nor records what it replaces.
## Consequences
- The recreate flow now produces `Replaces_InvId` / `DateCancelled` on the replaced invoice via the
regular `fds__setInvoiceFinal` bookkeeping (it did not before: no reference reached the proc).
- A second recreate (cancel the replacement, recreate again) keeps the credit because the claim on
the replaced invoice is treated as void during the replacement's finalisation.
- The one-off backfill script records the *earliest* live claimant; it is consistent with this rule.
- An intermediate that is itself cancelled is not offered (unchanged).
## Alternatives considered
- **Release `AllocatedTo_InvId` when the crediting invoice is cancelled** — rejected: four write
paths to keep in sync, and the audit trail of who last credited the Abschlagsrechnung is lost.
- **Keep `PartialInvoice` for recreated credit lines and teach `fds__setInvoiceFinal` both types** —
rejected: one line type per meaning; `PartialInvoice` remains the Abschlagsbetrag charge line of an
intermediate invoice.
@@ -0,0 +1,63 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceDate.sql"
- "Fuchs_Database/dbo/Functions/fds__r_getBalanceThisMonth.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getDatevExports.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getInvoices_list*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getFDSDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getRequest_details.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__r_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__*Reminder*.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ReminderService.cs"
- "Fuchs/Services/*DraftEditService.cs"
supersededBy: ""
---
# 0035 — The invoice date is `DateFinalized` everywhere
## Context
ADR 0032 made `DateFinalized` the invoice date for the PDF, the e-invoice (BT-2) and the number
sequence, and introduced backdating (which only moves `DateFinalized`). Everything else — invoice
lists, revenue reports and charts, the DATEV export, document lookups, the request details — still
dated and grouped invoices by `DateCreated`, the moment the *draft* was created. A backdated invoice
therefore showed September on the document and October in every list, report and in DATEV, and any
invoice drafted in one month and finalised in the next had the same split.
## Decision
- **One invoice date, everywhere:** the German calendar day of `DateFinalized`, falling back to
`DateCreated` only for drafts. Both columns are stored in UTC.
- **One implementation per side:** `[dbo].[fds__fn_invoiceDate](DateFinalized, DateCreated)` in SQL and
`InvoiceDates.IssueDate` in C#. No proc converts or picks the date itself.
- Applies to: the DATEV export (period filter, `Belegdatum`, `Belegfeld 2`), the invoice lists
(`fds__getInvoices_list`, `list2`, `list_vario`: period filter, `Belegdatum`, `DateOfCreation`,
ordering, overdue fallback), the revenue reports (`fds__r_*`: year/month filters and grouping,
`fds__r_getBalanceThisMonth`), document lookups (`fds__getFDSDocument`, `fds__getReportDocument`),
`fds__getRequest_details` (new column `InvoiceDate`, shown in the request's invoice list), and the
invoice date shown in reminders (`fds__prepReminder`, `fds__createReminder`, `fds__getReminder`).
- **Reminders use the same rule for their own date:** the reminder PDF's date is the reminder's
`DateFinalized` (German day), draft: `DateCreated`.
- **No transition safeguard.** The switch applies to all periods, past and future; already exported
DATEV periods are not reconciled (confirmed by the business owner, 2026-09-30).
- `DateCreated` keeps its meaning — the creation timestamp of the row — and is still returned where
callers read it as such. Draft previews stamp it with `DateTime.UtcNow` to match the stored column.
## Consequences
- Reports and exports for a past period can differ from what they showed before this change: an
invoice drafted in month M and finalised in M+1 now counts in M+1.
- Filters on the invoice date are not SARGable (they were not before either: `CAST(DateCreated as date)`).
Acceptable at the current table size; an indexed computed column is the follow-up if it matters.
- Any new list, report or export that shows or groups invoices by date must use
`fds__fn_invoiceDate` — never `DateCreated` or a raw `DateFinalized`.
## Alternatives considered
- **Keep `DateCreated` in lists/reports, `DateFinalized` only on documents** — rejected: the same
invoice would carry two dates, and backdating would never reach the books.
- **Transition safeguard for already exported DATEV periods** — rejected by the business owner.
@@ -0,0 +1,85 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/**"
supersededBy: ""
---
# 0036 — The database project build is the T-SQL gate
## Context
A rewrite of `fds__getDatevExports` added a `JOIN @inv as i` next to `fds__invoices as iv`
and left an unqualified `[file_guid]` in the select list. SQL Server rejects that statement
with Msg 209 (ambiguous column name) — but only when it runs, and the working-tree SQL runs
nowhere: the schema lives in the `Fuchs_Database` SSDT project, the dev database only gets
what is explicitly published to it, and the xUnit suite cannot bind T-SQL. The defect reached
code review as the only check.
The SSDT build would have caught it: it reported the line as `SQL71502` ("unresolved reference
… or ambiguous"). Nobody saw that because
- the project did not build at all — the two `CREATE USER … FOR LOGIN` files referenced logins
that were not in the model, which SSDT treats as error `SQL71501`;
- the genuine warning sat among ~160 identical `SQL71502` warnings caused by references to
`sys.*`, `INFORMATION_SCHEMA`, `sp_executesql`, `sp_getapplock` (no `master` reference) and
to `ocms_fn_generatePassword`, an OCMS helper the project never contained;
- a warning does not fail anything.
The same sweep found that `ocms_fn_generatePassword` (and its dependency `ocms_fn_randbetween`)
is missing on the dev database as well, so every id-generating function (`fds__fn_invoice_id`,
`fds__fn_reminder_id`, `fis_fn_id`, …) fails there with Msg 4121.
## Decision
1. `FuchsDatabase.sqlproj` references the `master` system dacpac
(`$(DacPacRootPath)\…\SqlServer\170\SQLSchemas\master.dacpac`), so system objects bind.
2. Everything the SQL references is in the project. The OCMS helpers `ocms_fn_generatePassword`
and `ocms_fn_randbetween` are vendored copies of the OCMSsharp `ocms_mt_database` files.
`Security/Logins.sql` scripts the two application logins with a placeholder,
`MUST_CHANGE` password so the users bind; real passwords stay in Key Vault and SSDT never
compares passwords, so existing logins are untouched on publish.
3. `TreatTSqlWarningsAsErrors` is on. Suppressed: `71501` (dynamic SQL / cross-database
references) and `71558` (identifier differs only by case — harmless under the `CI` model
collation). Every other T-SQL warning, in particular `SQL71502`, fails the build.
4. Building the database project is part of the workflow whenever `Fuchs_Database/**` changes
(`msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug`, from the Visual
Studio MSBuild — `dotnet build` cannot build the legacy SSDT project format). A red database
build is a failure like a red C# build.
5. The C# side of the contract is a test: `Fuchs.Tests/SqlContractTests` scans every
`[dbo].[…]` named in C# strings (`Fuchs`, `Fuchs_DataService`) and checks it against the
SSDT files — the object exists, every `EXECUTE` binds (no unknown names, no surplus
positional arguments, every parameter without a default supplied), every function call
passes the declared argument count, and every `mfr__updt__*` proc declares the
`@tblname, @referencetable, @tgtid` signature `FdsMfrClient` calls dynamically.
## Consequences
- An unqualified column that becomes ambiguous, a typo in a column name, or a call to a
function that is not in the project is a build error before review, not a Msg 209/4121 at
a customer.
- `SELECT *` inside `EXISTS (…)` over joined tables trips `SQL71502` in SSDT even though SQL
Server accepts it; write `SELECT 1` there (done for `fds__fn_IntermediateIsAllocatedToOther`
and `fis_admin_testGroupMembership`).
- New procedures that use `sys.*`/`sp_*` need no suppression any more. A new reference that is
genuinely outside the model (another database, dynamic SQL) is suppressed per statement or
by adding the object to the project — never by widening `SuppressTSqlWarnings` for `71502`.
- A proc signature change that breaks a C# caller fails `dotnet test`, not the first request.
Only literal SQL is covered: dynamically built names (`FuchsVisualization`'s report procs,
resolved from `sys.parameters` at runtime) and result-set column names are not.
- The build proves binding, not behaviour: publishing to the dev database (`SqlPackage
/Action:Publish` with `ExcludeObjectTypes=Logins;Users;Permissions;RoleMembership`, or the
schema compare) and exercising the code path remains the runtime check.
- The dev database needs the two `ocms_*` helpers published before any id-generating path
works there.
## Alternatives considered
- **Run the changed procedures against the dev database before review.** Needs a publish of
every working-tree change first, and does not cover code paths the reviewer does not think
to exercise; the compile-time check is free and total.
- **Suppress `SQL71502` and keep the build warning-free that way.** Removes exactly the signal
that catches this class of bug.
- **A C#-side test that parses the SQL for unqualified columns.** A parser-level heuristic
without a schema cannot decide ambiguity; the SSDT model can.
+34 -2
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
@@ -181,6 +183,36 @@ Confirming the preview modal posts to **`req/sconf`**
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
(the draft only ever had the internal numeric `Id`). `Version` is bumped
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
the last working day (Mon–Fri) of the previous month, as long as no
invoice has been finalised after that day. `req/sconf` then passes
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
`DueDate` follow the backdated day. The proc serializes all finalisations
(transaction + `sp_getapplock`) so the check and the numbering cannot
interleave with a concurrent finalisation. The controller maps 60001 to
the "create again without backdating" message; a 0-row result (already
final/sent, e.g. double submit) is reported as "already finalised".
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
the DATEV export (period + `Belegdatum`), request details and reminders
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
appears in the previous month everywhere, including DATEV. The reminder
PDF's own date follows the same rule (reminder `DateFinalized`).
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
credited intermediate invoice in `reference_InvId` (seeded by
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
editor as item `ref`); finalisation records it in
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
not offered again. The claim only counts while the crediting invoice is
alive: once it is cancelled (storno, or replaced via storno + recreate)
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
free again and the next finalisation takes the allocation over. A
Stornorechnung (`'c'`) never claims an allocation, even though a storno
copy mirrors the credit lines.
2. On success (`IsFinal == true` returned), the controller:
- Reloads the invoice via `LoadInvoiceAsync`.
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
@@ -283,7 +315,7 @@ Once an invoice is `IsFinal`, the request/invoice list menus
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
+5
View File
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
### Erstellen / Bearbeiten
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
+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\" />
+3
View File
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
public static readonly Counter<long> BlobBackupCopiesFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
"Number of blob backup-copy attempts that failed.");
public static readonly Counter<long> InvoicesBackdated =
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
public static readonly Counter<long> InvoiceFileBackups =
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
+11
View File
@@ -135,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
@@ -270,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
category, documentId, containerName, blobName);
return blobClient.Uri;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
throw;
}
catch (Exception ex)
{
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
+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;
+1 -7
View File
@@ -259,13 +259,7 @@ public static class ERechnungMapper
}
// ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
+4 -2
View File
@@ -12,7 +12,8 @@ public interface IBlobStorageService
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow.
/// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
/// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
@@ -50,7 +51,8 @@ public interface IBlobStorageService
/// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
/// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
+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);
+56 -17
View File
@@ -60,6 +60,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token);
if (session == null) return null;
// The editor fires patches (and state/preview fetches) concurrently for one token; without
// the lock a block.replace on one request mutates Req while another enumerates it.
lock (session.SyncRoot)
{
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
@@ -82,6 +86,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
_cache.Set(session);
return session;
}
}
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
@@ -424,18 +429,24 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
}
// ── View state / history ────────────────────────────────────────────────
/// <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();
lock (session.SyncRoot)
{
return new
{
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
admin = session.Admin.DeepClone(),
@new = session.New.DeepClone(),
req = session.Req.DeepClone(),
sums = new
{
total_net = session.Sums.TotalNet,
@@ -443,15 +454,16 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = session.Sums.VatByRate,
block_net = session.Sums.NetByBlock
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 }),
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session),
notes = BuildNotes(session).ToList(),
setDisplay = BuildSetDisplay(session)
};
}
}
/// <summary>
/// Authoritative per-item set-pricing display flags (ADR 0006/set-pricing): for every block
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
.Select(n => new { text = n.Text, style = n.Style });
}
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);
// 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
{
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))
{
session.InvId = 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 reg;
return new DraftFlushResult<FdsInvoiceData>(reg, change);
}
finally
{
session.FlushGate.Release();
}
}
public async Task<Document?> RenderPreviewAsync(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
FdsInvoiceData fds;
lock (session.SyncRoot)
{
fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
}
@@ -616,7 +654,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
int idx = 0;
+70 -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) };
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))
@@ -205,6 +226,17 @@ public class InvoiceService : IInvoiceService
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
/// </summary>
/// <summary>
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
/// </summary>
internal static string? ItemReferenceInvId(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
}
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
{
string invoiceId = invoice.Id;
@@ -269,6 +301,7 @@ public class InvoiceService : IInvoiceService
float vs = 0;
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
row["det"] = itmDic.no("det", false) is true;
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
itemsTable.Rows.Add(row);
}
@@ -287,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");
@@ -302,8 +351,11 @@ public class InvoiceService : IInvoiceService
: Array.Empty<string>(),
AdminUser = reg?.getString("UserNameFinalized") ?? "",
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
// Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
// 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") ?? "";
+44 -13
View File
@@ -59,6 +59,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
var session = _cache.Get(token);
if (session == null) return null;
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
{
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
@@ -81,6 +83,7 @@ public sealed class ReminderDraftEditService : IReminderDraftService
_cache.Set(session);
return session;
}
}
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
@@ -144,25 +147,28 @@ public sealed class ReminderDraftEditService : IReminderDraftService
public object BuildState(ReminderDraftSession session)
{
session.Touch();
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
{
return new
{
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
@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 }),
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
}
}
/// <summary>
/// Renders the backend-authoritative intro/closing notice paragraphs (see
@@ -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);
// 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
{
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))
{
session.RemId = 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 reg;
return new DraftFlushResult<FdsReminderData>(reg, change);
}
finally
{
session.FlushGate.Release();
}
}
public Document? RenderPreview(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
FdsReminderData fds;
lock (session.SyncRoot)
{
fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true);
}
@@ -271,7 +301,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
return new GenericObjectDictionary(d);
}
+25 -8
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;
}
@@ -111,7 +127,8 @@ public class ReminderService : IReminderService
Address = reminder.InvoiceAddress,
AdminUser = reminder.UserNameFinalized,
AdminUserEmail = reminder.UserEmailFinalized,
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
// Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
};
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
{
+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
{
-1
View File
@@ -32,7 +32,6 @@ public class FdsReminderData
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
// -- Computed props from registration -------------------------------------
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
public string ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? "";
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
+86
View File
@@ -0,0 +1,86 @@
using static OCORE.OCORE_dictionaries;
namespace Fuchs.intranet;
/// <summary>Result of a <c>fds__setInvoiceFinal</c> call as seen by <c>req/sconf</c>.</summary>
public enum InvoiceFinalizeOutcome
{
/// <summary>The draft was finalized (one row returned).</summary>
Finalized,
/// <summary>SQL error 60001: no longer allowed to backdate; nothing was finalized.</summary>
BackdateNotAllowed,
/// <summary>No error but no row: the invoice is already final/sent (or unknown); nothing was changed.</summary>
AlreadyFinal,
/// <summary>Any other SQL error (authorization 60000, constraint, timeout ...).</summary>
Error
}
/// <summary>
/// The invoice date ("Rechnungsdatum") printed on the PDF and carried as the e-invoice issue date
/// (BT-2). One source for both so they can never disagree (ADR 0032):
/// <c>DateFinalized</c> (the finalisation — or, for a backdated invoice, the backdated — date),
/// falling back to <c>DateCreated</c> for drafts. Both columns are written with <c>GETUTCDATE()</c>,
/// so the value is converted to the German calendar day.
/// </summary>
public static class InvoiceDates
{
private static readonly TimeZoneInfo German = ResolveGermanZone();
public static DateOnly IssueDate(GenericObjectDictionary? registration, DateTime? utcNow = null)
{
DateTime? stored = registration?.getDate_null("DateFinalized") ?? registration?.getDate_null("DateCreated");
DateTime utc = stored ?? utcNow ?? DateTime.UtcNow;
return DateOnly.FromDateTime(ToGermanTime(utc));
}
/// <summary>Parses the ISO date returned by <c>fds__fn_invoiceBackdate</c>; null when empty/invalid.</summary>
public static DateOnly? ParseBackdate(string? iso)
=> DateOnly.TryParseExact((iso ?? "").Trim(), "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d) ? d : null;
/// <summary>True when the finalise request asked for backdating (checkbox value "1"/"true").</summary>
public static bool IsBackdateRequested(string? formValue)
=> (formValue ?? "").Trim().ToLowerInvariant() is "1" or "true" or "on";
/// <summary>SQL error number of <c>THROW 60001, 'backdating not allowed'</c> in <c>fds__setInvoiceFinal</c>.</summary>
public const int BackdateNotAllowedErrorNumber = 60001;
/// <summary>
/// Classifies what <c>fds__setInvoiceFinal</c> did, from the OCORE result: an exception (with the SQL
/// error number), or the number of rows the guarded <c>UPDATE ... WHERE isFinal = 0 AND isSent = 0</c>
/// returned. Only the dedicated 60001 means "backdating no longer allowed"; a 0-row result means the
/// invoice was already final/sent (e.g. a double submit) — reporting that as a backdate rejection
/// would invite the user to create a duplicate.
/// </summary>
public static InvoiceFinalizeOutcome ClassifyFinalizeResult(string? sqlException, int? sqlErrorNumber, int rowCount)
{
if (!string.IsNullOrEmpty(sqlException))
return sqlErrorNumber == BackdateNotAllowedErrorNumber
? InvoiceFinalizeOutcome.BackdateNotAllowed
: InvoiceFinalizeOutcome.Error;
return rowCount > 0 ? InvoiceFinalizeOutcome.Finalized : InvoiceFinalizeOutcome.AlreadyFinal;
}
/// <summary>Tag value for the <c>fuchs.invoices.backdated</c> counter's <c>outcome</c> dimension.</summary>
public static string OutcomeTag(InvoiceFinalizeOutcome outcome) => outcome switch
{
InvoiceFinalizeOutcome.Finalized => "ok",
InvoiceFinalizeOutcome.BackdateNotAllowed => "rejected",
InvoiceFinalizeOutcome.AlreadyFinal => "already_final",
_ => "error"
};
internal static DateTime ToGermanTime(DateTime utc)
=> TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), German);
private static TimeZoneInfo ResolveGermanZone()
{
foreach (var id in new[] { "Europe/Berlin", "W. Europe Standard Time" })
{
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
}
return TimeZoneInfo.Local;
}
}
+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;
}
+15
View File
@@ -247,6 +247,21 @@ div.modal {
}
.modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
> .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
> input {
margin-right: 0.5rem;
}
}
> .choicefrm {
padding: 1rem;
+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) {
+24 -7
View File
@@ -394,6 +394,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -403,7 +411,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -703,7 +711,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -878,13 +886,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1011,13 +1022,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1100,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] || '') }
@@ -1119,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); }
@@ -1331,7 +1348,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
@@ -36,6 +36,8 @@
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
+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 || '') !== '') {
+15
View File
@@ -1309,6 +1309,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
border-top: 1px solid #e5e5e5;
}
div.modal > .modal-dialog > .modal-content .modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
margin-right: 0.5rem;
}
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
padding: 1rem;
}
+26 -7
View File
@@ -160,6 +160,8 @@ let $ict = {
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
@@ -974,6 +976,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -983,7 +993,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -1283,7 +1293,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -1458,13 +1468,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1591,13 +1604,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1680,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] || '') }
@@ -1699,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); }
@@ -1911,7 +1930,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
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) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+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;
+26 -7
View File
@@ -160,6 +160,8 @@ let $ict = {
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
@@ -955,6 +957,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -964,7 +974,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -1264,7 +1274,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -1439,13 +1449,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1572,13 +1585,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1661,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] || '') }
@@ -1680,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); }
@@ -1892,7 +1911,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
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
View File
@@ -243,6 +243,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
border-top: 1px solid #e5e5e5;
}
div.modal > .modal-dialog > .modal-content .modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
margin-right: 0.5rem;
}
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
padding: 1rem;
}
+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
+1 -1
View File
File diff suppressed because one or more lines are too long
+101
View File
@@ -0,0 +1,101 @@
using System.Data;
using System.Globalization;
namespace fds;
/// <summary>
/// Explains an mfr merge that failed with "String or binary data would be truncated": SQL Server
/// (message 8152, or 2628 below compatibility level 150 / without VERBOSE_TRUNCATION_WARNINGS)
/// does not name the column, and the merge proc copies the staging table into both the
/// <c>mfr__tt__*</c> table type and the <c>mfr__*</c> target. After such a failure the staged
/// <see cref="DataTable"/> is compared against the narrower of the two declared widths, so the
/// error log names the column, the longest value and an entity Id.
/// Runs only on the failure path — no cost for a successful merge.
/// </summary>
internal static class FdsColumnWidths
{
/// <summary>One column whose staged values exceed the declared width.</summary>
internal sealed record Overflow(string Column, int MaxAllowed, int LongestLength, int RowsOver, object? LongestRowId)
{
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture,
$"{Column}: {LongestLength} > {MaxAllowed} chars ({RowsOver} row{(RowsOver == 1 ? "" : "s")}{(LongestRowId is null ? "" : $", e.g. Id={LongestRowId}")})");
}
/// <summary>8152 = classic truncation message; 2628 = verbose variant (compat level 150+).</summary>
internal static bool IsTruncationError(int sqlErrorNumber) => sqlErrorNumber is 8152 or 2628;
internal static bool IsTruncationError(Exception? exc) =>
exc is Microsoft.Data.SqlClient.SqlException sx && IsTruncationError(sx.Number);
/// <summary>
/// Char width of every bounded string column of the target table and of its
/// <c>mfr__tt__</c> table type (same naming rule as the <c>mfr__updt__</c> merge proc);
/// where both declare the column the narrower wins. MAX columns (max_length = -1) are omitted.
/// Expects parameter <c>@tablename</c>.
/// </summary>
internal const string WidthsSql = """
SELECT [columnname] = c.[name],
[maxchars] = MIN(CASE WHEN c.[system_type_id] IN (231, 239) THEN c.[max_length] / 2 ELSE c.[max_length] END)
FROM sys.columns AS c
WHERE c.[system_type_id] IN (167, 175, 231, 239)
AND c.[max_length] > 0
AND (c.[object_id] = OBJECT_ID(N'[dbo].' + QUOTENAME(@tablename))
OR c.[object_id] IN (SELECT tt.[type_table_object_id] FROM sys.table_types AS tt
WHERE tt.[schema_id] = SCHEMA_ID(N'dbo')
AND tt.[name] = REPLACE(@tablename, N'__', N'__tt__')))
GROUP BY c.[name];
""";
/// <summary>Rows of <see cref="WidthsSql"/> → column name → max chars (case-insensitive).</summary>
internal static Dictionary<string, int> ToWidthMap(DataTable? widthRows)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (widthRows == null || !widthRows.Columns.Contains("columnname") || !widthRows.Columns.Contains("maxchars"))
return map;
foreach (DataRow row in widthRows.Rows)
{
if (row["columnname"] is not string name || string.IsNullOrWhiteSpace(name)) continue;
if (row["maxchars"] is DBNull) continue;
int width = Convert.ToInt32(row["maxchars"], CultureInfo.InvariantCulture);
if (width <= 0) continue;
map[name] = map.TryGetValue(name, out int existing) ? Math.Min(existing, width) : width;
}
return map;
}
/// <summary>
/// String columns of <paramref name="batch"/> holding at least one value longer than the
/// declared width, one entry per column with the longest value's length and row Id.
/// Columns without a known width (MAX or not declared) are never reported.
/// </summary>
internal static IReadOnlyList<Overflow> FindOverflows(DataTable batch, IReadOnlyDictionary<string, int> widths)
{
var result = new List<Overflow>();
if (batch.Rows.Count == 0 || widths.Count == 0) return result;
DataColumn? idColumn = batch.Columns.Contains("Id") ? batch.Columns["Id"] : null;
foreach (DataColumn col in batch.Columns)
{
if (col.DataType != typeof(string) || !widths.TryGetValue(col.ColumnName, out int maxAllowed)) continue;
int longest = 0, rowsOver = 0;
object? longestId = null;
foreach (DataRow row in batch.Rows)
{
if (row.RowState == DataRowState.Deleted || row[col] is not string s || s.Length <= maxAllowed) continue;
rowsOver++;
if (s.Length > longest)
{
longest = s.Length;
longestId = idColumn != null && row[idColumn] is not DBNull ? row[idColumn] : null;
}
}
if (rowsOver > 0) result.Add(new Overflow(col.ColumnName, maxAllowed, longest, rowsOver, longestId));
}
return result;
}
/// <summary>Log-friendly summary; "none detected" when the batch fits the declared widths.</summary>
internal static string Describe(IReadOnlyList<Overflow> overflows) =>
overflows.Count == 0 ? "none detected" : string.Join("; ", overflows);
}
+33 -4
View File
@@ -294,10 +294,10 @@ public class FdsMfrClient : IDisposable
dtwa.CommandAfterError = new SqlCommand(
$"DROP TABLE IF EXISTS [{fallbackTable}]; SELECT * INTO [{fallbackTable}] FROM {dtwa.DestinationTableName};")
{ CommandTimeout = MergeCommandTimeoutSeconds };
dtwa.OnCommandAfterError += (sender, caex) =>
_logger.LogError(caex,
"Update__entitytable: merge into {Table} failed ({Entity}, setid={SetId}, rows={Rows}) — staged batch kept in {FallbackTable}",
tbl.TableName, thisEntityName, setId, trc, fallbackTable);
// Logged after DoSubmit returns so a truncation can be explained
// (column + length + Id) with an awaited width lookup.
Exception? mergeError = null;
dtwa.OnCommandAfterError += (sender, caex) => mergeError = caex;
dtwa.OnError += (sender, exc, message) =>
_logger.LogError(exc,
"Update__entitytable: DatatableWriterAsync submission failed for {Table} ({Entity}, setid={SetId}) — {Message}",
@@ -319,6 +319,15 @@ public class FdsMfrClient : IDisposable
new("@tgtid", entityId.Length > 0 ? entityId[0].ToString() : (object)DBNull.Value)
});
dtwa.DoSubmit();
if (mergeError != null)
{
string overlong = FdsColumnWidths.IsTruncationError(mergeError)
? await DescribeOverlongColumnsAsync(tbl)
: "n/a";
_logger.LogError(mergeError,
"Update__entitytable: merge into {Table} failed ({Entity}, setid={SetId}, rows={Rows}) — staged batch kept in {FallbackTable}; over-long columns: {OverlongColumns}",
tbl.TableName, thisEntityName, setId, trc, fallbackTable, overlong);
}
}
catch (Exception dsex)
{
@@ -341,6 +350,26 @@ public class FdsMfrClient : IDisposable
return true;
}
/// <summary>
/// Failure path only: names the staged columns wider than the target table / table type allow.
/// Never throws — a failed lookup is reported in the returned text instead.
/// </summary>
private async Task<string> DescribeOverlongColumnsAsync(DataTable batch)
{
try
{
var widths = await getSQLDatatable_async(FdsColumnWidths.WidthsSql, FdsShared.FDSConnectionString(),
SqlParameterList: new List<SqlParameter> { new("@tablename", batch.TableName) },
options: new FdsSqlOptions(logger: _logger));
if (!string.IsNullOrEmpty(widths.Exception)) return "column widths unavailable: " + widths.Exception;
return FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, FdsColumnWidths.ToWidthMap(widths.DataTable)));
}
catch (Exception ex)
{
return "column widths unavailable: " + ex.Message;
}
}
private static void SetRowValue(DataRow row, string key, object value)
{
if (!row.Table.Columns.Contains(key)) return;
+47
View File
@@ -4,6 +4,47 @@
<name>Fuchs_DataService</name>
</assembly>
<members>
<member name="T:fds.FdsColumnWidths">
<summary>
Explains an mfr merge that failed with "String or binary data would be truncated": SQL Server
(message 8152, or 2628 below compatibility level 150 / without VERBOSE_TRUNCATION_WARNINGS)
does not name the column, and the merge proc copies the staging table into both the
<c>mfr__tt__*</c> table type and the <c>mfr__*</c> target. After such a failure the staged
<see cref="T:System.Data.DataTable"/> is compared against the narrower of the two declared widths, so the
error log names the column, the longest value and an entity Id.
Runs only on the failure path — no cost for a successful merge.
</summary>
</member>
<member name="T:fds.FdsColumnWidths.Overflow">
<summary>One column whose staged values exceed the declared width.</summary>
</member>
<member name="M:fds.FdsColumnWidths.Overflow.#ctor(System.String,System.Int32,System.Int32,System.Int32,System.Object)">
<summary>One column whose staged values exceed the declared width.</summary>
</member>
<member name="M:fds.FdsColumnWidths.IsTruncationError(System.Int32)">
<summary>8152 = classic truncation message; 2628 = verbose variant (compat level 150+).</summary>
</member>
<member name="F:fds.FdsColumnWidths.WidthsSql">
<summary>
Char width of every bounded string column of the target table and of its
<c>mfr__tt__</c> table type (same naming rule as the <c>mfr__updt__</c> merge proc);
where both declare the column the narrower wins. MAX columns (max_length = -1) are omitted.
Expects parameter <c>@tablename</c>.
</summary>
</member>
<member name="M:fds.FdsColumnWidths.ToWidthMap(System.Data.DataTable)">
<summary>Rows of <see cref="F:fds.FdsColumnWidths.WidthsSql"/> → column name → max chars (case-insensitive).</summary>
</member>
<member name="M:fds.FdsColumnWidths.FindOverflows(System.Data.DataTable,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Int32})">
<summary>
String columns of <paramref name="batch"/> holding at least one value longer than the
declared width, one entry per column with the longest value's length and row Id.
Columns without a known width (MAX or not declared) are never reported.
</summary>
</member>
<member name="M:fds.FdsColumnWidths.Describe(System.Collections.Generic.IReadOnlyList{fds.FdsColumnWidths.Overflow})">
<summary>Log-friendly summary; "none detected" when the batch fits the declared widths.</summary>
</member>
<member name="T:fds.FdsSqlOptions">
<summary>
Logs every SQL error through the worker's structured <see cref="T:Microsoft.Extensions.Logging.ILogger"/> alone — which, when
@@ -29,6 +70,12 @@
(empty) shape is loaded. Empty when the entity table itself is unknown.
</summary>
</member>
<member name="M:fds.FdsMfrClient.DescribeOverlongColumnsAsync(System.Data.DataTable)">
<summary>
Failure path only: names the staged columns wider than the target table / table type allow.
Never throws — a failed lookup is reported in the returned text instead.
</summary>
</member>
<member name="T:fds.FdsConfig">
<summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
+22 -1
View File
@@ -22,7 +22,11 @@
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
<SuppressTSqlWarnings>71501</SuppressTSqlWarnings>
<!-- 71501: unresolved reference (dynamic SQL / cross-database objects); 71558: identifier differs only by case,
harmless under the CI model collation. Every other T-SQL warning is a build error (ADR 0036) so an
ambiguous or unresolved column never reaches a database. -->
<SuppressTSqlWarnings>71501;71558</SuppressTSqlWarnings>
<TreatTSqlWarningsAsErrors>True</TreatTSqlWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
@@ -148,6 +152,7 @@
<Build Include="Security\fds_rwe.sql" />
<Build Include="Security\fuchs_web.sql" />
<Build Include="Security\fuchs_fds.sql" />
<Build Include="Security\Logins.sql" />
<Build Include="dbo\Functions\vat_val.sql" />
<Build Include="dbo\Functions\strings_replaceSpecialCharsInHTML.sql" />
<Build Include="dbo\Functions\strings_removeLeading0_255.sql" />
@@ -205,6 +210,10 @@
<Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_id.sql" />
<Build Include="dbo\Functions\fds__fn_invoiceBackdate.sql" />
<Build Include="dbo\Functions\fds__fn_invoiceDate.sql" />
<Build Include="dbo\Functions\ocms_fn_generatePassword.sql" />
<Build Include="dbo\Functions\ocms_fn_randbetween.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_customerid.sql" />
<Build Include="dbo\Functions\fds__fn_IntermediateIsAllocatedToOther.sql" />
<Build Include="dbo\Functions\fds__fn_DocumentName.sql" />
@@ -349,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" />
@@ -411,5 +421,16 @@
</ItemGroup>
<ItemGroup>
<None Include="Scripts\2026-09-16_fis_widgets_set_based_payment_amounts.sql" />
<None Include="Scripts\2026-09-30_backfill_intermediate_invoice_allocations.sql" />
<None Include="Scripts\2026-09-30_mfr_truncation_fix.sql" />
</ItemGroup>
<ItemGroup>
<!-- System catalog (sys.*, INFORMATION_SCHEMA, sp_executesql, sp_getapplock, ...) so the model binds
cleanly and every remaining SQL71502 is a genuine unresolved/ambiguous reference (ADR 0036). -->
<ArtifactReference Include="$(DacPacRootPath)\Extensions\Microsoft\SQLDB\Extensions\SqlServer\170\SQLSchemas\master.dacpac">
<HintPath>$(DacPacRootPath)\Extensions\Microsoft\SQLDB\Extensions\SqlServer\170\SQLSchemas\master.dacpac</HintPath>
<SuppressMissingDependenciesErrors>False</SuppressMissingDependenciesErrors>
<DatabaseVariableLiteralValue>master</DatabaseVariableLiteralValue>
</ArtifactReference>
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
-- =============================================
-- One-off data backfill — run once per environment AFTER deploying the schema that carries
-- reference_InvId through fds__prepInvoice / the editor and fixes the allocation MERGE in
-- fds__setInvoiceFinal (2026-09-30).
--
-- Until then fds__invoice_items.reference_InvId was never written for 'IntInvoiceAllocation' lines
-- ("Anrechnung der Abschlagsrechnung R2025-0244 …"), so
-- * fds__setInvoiceFinal's allocation MERGE failed with 515 (NULL into fds__invoice_details.InvId)
-- for every Schlussrechnung — logged, invoice finalized anyway, allocation never recorded;
-- * fds__invoice_details.AllocatedTo_InvId stayed NULL for every Abschlagsrechnung, so
-- fds__prepInvoice would offer an already credited Abschlagsrechnung again.
-- This script
-- 1. restores reference_InvId from the invoice number in the line text (only unique matches), and
-- 2. records AllocatedTo_InvId for each Abschlagsrechnung that is credited on a finalized,
-- non-cancelled invoice (earliest such invoice wins; nothing already allocated is overwritten).
-- Default is a dry run (@apply = 0) that only reports; set @apply = 1 to write.
-- Idempotent: only rows with an empty reference / allocation are touched.
-- =============================================
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @apply bit = 0; -- 1 = write changes
IF OBJECT_ID('tempdb..#alloc') IS NOT NULL DROP TABLE #alloc;
WITH lines AS (
SELECT it.[Id], it.[InvId],
[num] = SUBSTRING(it.[Text], PATINDEX('%R[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]%', it.[Text]), 10)
FROM [dbo].[fds__invoice_items] AS it
WHERE it.[Type] = 'IntInvoiceAllocation'
AND ISNULL(it.[reference_InvId], '') = ''
AND PATINDEX('%R[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]%', it.[Text]) > 0
), matched AS (
SELECT l.[Id], l.[InvId], l.[num],
[RefInvId] = MIN(i.[Id]), [cand] = COUNT(i.[Id])
FROM lines AS l
LEFT JOIN [dbo].[fds__invoices] AS i ON i.[InvoiceId] = l.[num] AND i.[InvoiceType] = 'i'
GROUP BY l.[Id], l.[InvId], l.[num]
)
SELECT [ItemId] = [Id], [TgtInvId] = [InvId], [num], [RefInvId], [cand]
INTO #alloc
FROM matched;
SELECT [lines_without_reference] = COUNT(*),
[unique_match] = SUM(IIF([cand] = 1, 1, 0)),
[no_match] = SUM(IIF([cand] = 0, 1, 0)),
[ambiguous] = SUM(IIF([cand] > 1, 1, 0))
FROM #alloc;
-- Abschlagsrechnungen credited on more than one finalized, non-cancelled invoice (review manually)
SELECT a.[num], a.[RefInvId], [credited_on] = t.[InvoiceId], t.[DateFinalized], t.[InvoiceType]
FROM #alloc AS a
JOIN [dbo].[fds__invoices] AS t ON t.[Id] = a.[TgtInvId] AND t.[isFinal] = 1 AND ISNULL(t.[IsCanceled], 0) = 0
WHERE a.[cand] = 1
AND a.[RefInvId] IN (SELECT a2.[RefInvId] FROM #alloc AS a2
JOIN [dbo].[fds__invoices] AS t2 ON t2.[Id] = a2.[TgtInvId] AND t2.[isFinal] = 1 AND ISNULL(t2.[IsCanceled], 0) = 0
WHERE a2.[cand] = 1
GROUP BY a2.[RefInvId] HAVING COUNT(DISTINCT a2.[TgtInvId]) > 1)
ORDER BY a.[num], t.[DateFinalized];
IF @apply = 1
BEGIN
BEGIN TRANSACTION;
UPDATE it
SET it.[reference_InvId] = a.[RefInvId]
FROM [dbo].[fds__invoice_items] AS it
JOIN #alloc AS a ON a.[ItemId] = it.[Id] AND a.[cand] = 1
WHERE ISNULL(it.[reference_InvId], '') = '';
PRINT CONCAT('invoice item references restored: ', @@ROWCOUNT);
WITH firstTarget AS (
SELECT a.[RefInvId], a.[TgtInvId],
[rn] = ROW_NUMBER() OVER (PARTITION BY a.[RefInvId] ORDER BY t.[DateFinalized], t.[Id])
FROM #alloc AS a
JOIN [dbo].[fds__invoices] AS t ON t.[Id] = a.[TgtInvId] AND t.[isFinal] = 1 AND ISNULL(t.[IsCanceled], 0) = 0
WHERE a.[cand] = 1
)
MERGE [dbo].[fds__invoice_details] AS TARGET
USING (SELECT [RefInvId], [TgtInvId] FROM firstTarget WHERE [rn] = 1) AS src ON TARGET.[InvId] = src.[RefInvId]
WHEN MATCHED AND ISNULL(TARGET.[AllocatedTo_InvId], '') = '' THEN
UPDATE SET [AllocatedTo_InvId] = src.[TgtInvId]
WHEN NOT MATCHED BY TARGET THEN
INSERT ([InvId], [AllocatedTo_InvId]) VALUES (src.[RefInvId], src.[TgtInvId]);
PRINT CONCAT('Abschlagsrechnung allocations recorded: ', @@ROWCOUNT);
COMMIT TRANSACTION;
END
ELSE
PRINT 'Dry run only — set @apply = 1 to write.';
DROP TABLE #alloc;
@@ -0,0 +1,504 @@
-- =============================================
-- 2026-09-30 mfr sync: "String or binary data would be truncated" (msg 8152)
--
-- Root cause: mfr's free-text [Note] on ServiceObject and Company has no length limit
-- (live mfr data on 2026-09-30: ServiceObject.Note up to 753 chars, Company.Note up to 723),
-- but [mfr__serviceobjects].[Note], [mfr__companies].[Note] and their table types
-- [mfr__tt__serviceobjects] / [mfr__tt__companies] were NVARCHAR(255). The merge procs
-- [mfr__updt__serviceobjects] / [mfr__updt__companies] copy the staging table into the type,
-- so every batch containing such a row (the entity's own sync and every Company/Contact/
-- ServiceRequest/ServiceObject sync that $expands it) was rejected as a whole.
--
-- This script (idempotent, safe to re-run; one transaction, all-or-nothing):
-- 1. patches the [@company] table variable in fds__getRequest_details, fds__prepInvoice
-- and fds__prepStorno_recreate (they INSERT ... SELECT cy.* from [mfr__companies]):
-- [Note] -> nvarchar(max) (without this, widening the table would move the truncation
-- into invoice/request preparation for customers with a long note), and
-- [CustomValues] / [MainContactId] -> NULL (both nullable in [mfr__companies]; the
-- NOT NULL declaration raised 515 for a company without main contact). Patched IN
-- PLACE on the deployed definition (only the @company block changes), so other edits
-- to those procs already deployed on this server are kept. Same result as the SSDT
-- versions of these procs.
-- 2. ALTERs [mfr__serviceobjects].[Note] and [mfr__companies].[Note] to NVARCHAR(MAX).
-- 3. Drops/recreates the table types [mfr__tt__serviceobjects] / [mfr__tt__companies]
-- (a table type cannot be ALTERed). The procs that DECLARE them,
-- [mfr__updt__serviceobjects] / [mfr__updt__companies], are dropped first and recreated
-- from the SSDT source; explicit GRANT/DENY on proc and type are captured and re-applied.
-- 4. Queues a re-sync (fds__mfr_updaterequests) for the rows that were rejected, because
-- the short sync only fetches rows newer than the local watermark and would skip them.
--
-- Recommended: stop the Fuchs_DataService worker while the script runs.
-- Matches SSDT (Fuchs_Database) as of 2026-09-30.
-- =============================================
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
DECLARE @type_so nvarchar(max) = N'CREATE TYPE [dbo].[mfr__tt__serviceobjects] AS TABLE (
[setid] VARCHAR (5) NULL,
[Id] BIGINT NOT NULL,
[Location#ID] BIGINT NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[CustomValues] NVARCHAR (MAX) NOT NULL,
[DateModified] DATETIME NOT NULL,
[ExternalId] NVARCHAR (255) NULL,
[MappingId] NVARCHAR (255) NULL,
[QuickSearch] NVARCHAR (255) NULL,
[Version] INT NOT NULL,
[CreateGeoLocation] BIT NOT NULL,
[IsWarehouse] BIT NOT NULL,
[ParentServiceObjectId] BIGINT NOT NULL,
[CompanyId] BIGINT NOT NULL,
[ProductId] BIGINT NOT NULL);';
DECLARE @proc_so nvarchar(max) = N'-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE PROCEDURE [dbo].[mfr__updt__serviceobjects]
@tblname as nvarchar(50)
, @referencetable nvarchar(50)
, @tgtid bigint
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
DECLARE @now datetime = GETUTCDATE();
DECLARE @tmp [dbo].[mfr__tt__serviceobjects];
DECLARE @tmp_cmd nvarchar(1000) = N''SELECT DISTINCT * FROM '' + @tblname
INSERT INTO @tmp EXECUTE [sp_executesql] @tmp_cmd;
BEGIN TRANSACTION;
IF @referencetable = ''mfr__serviceobjects''
BEGIN
DELETE p
--OUTput deleted.*, @now INTO [dbo].[mfr__d_*PartnerSet]
FROM [dbo].[mfr__*PartnerSet] as p
WHERE p.Property like ''ServiceObject[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = p.[EntityId]);
DELETE cv
--OUTput deleted.*, @now INTO [dbo].[mfr__d_#customvalues]
FROM [dbo].[mfr__#customvalues] as cv
WHERE cv.Property like ''ServiceObject[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = cv.[EntityId]);
DELETE lc
--OUTPUT deleted.*, @now INTO [dbo].[mfr__d_#locations]
FROM [dbo].[mfr__#locations] as lc
WHERE lc.Property like ''ServiceObject[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = lc.[EntityId]);
DELETE itm
--OUTPUT deleted.*, @now INTO [dbo].[mfr__d_items]
FROM [dbo].[mfr__items] as itm
WHERE EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = itm.[ServiceObjectId] and ISNULL(itm.[ServiceRequestId],0) = 0);
END
MERGE [dbo].[mfr__serviceobjects] as TARGET
USING @tmp as SOURCE ON TARGET.[Id] = SOURCE.[Id]
WHEN MATCHED AND (TARGET.[Version] <= SOURCE.[Version] OR TARGET.[DateModified] <= SOURCE.[DateModified]) THEN
UPDATE
SET [Id] = SOURCE.[Id]
,[Location#ID] = SOURCE.[Location#ID]
,[Name] = SOURCE.[Name]
,[Note] = SOURCE.[Note]
,[CustomValues] = SOURCE.[CustomValues]
,[DateModified] = SOURCE.[DateModified]
,[ExternalId] = SOURCE.[ExternalId]
,[MappingId] = SOURCE.[MappingId]
,[QuickSearch] = SOURCE.[QuickSearch]
,[Version] = SOURCE.[Version]
,[CreateGeoLocation] = SOURCE.[CreateGeoLocation]
,[IsWarehouse] = SOURCE.[IsWarehouse]
,[ParentServiceObjectId] = SOURCE.[ParentServiceObjectId]
,[CompanyId] = SOURCE.[CompanyId]
,[ProductId] = SOURCE.[ProductId]
WHEN NOT MATCHED BY TARGET THEN
INSERT
( [Id]
,[Location#ID]
,[Name]
,[Note]
,[CustomValues]
,[DateModified]
,[ExternalId]
,[MappingId]
,[QuickSearch]
,[Version]
,[CreateGeoLocation]
,[IsWarehouse]
,[ParentServiceObjectId]
,[CompanyId]
,[ProductId])
VALUES
(SOURCE.[Id]
,SOURCE.[Location#ID]
,SOURCE.[Name]
,SOURCE.[Note]
,SOURCE.[CustomValues]
,SOURCE.[DateModified]
,SOURCE.[ExternalId]
,SOURCE.[MappingId]
,SOURCE.[QuickSearch]
,SOURCE.[Version]
,SOURCE.[CreateGeoLocation]
,SOURCE.[IsWarehouse]
,SOURCE.[ParentServiceObjectId]
,SOURCE.[CompanyId]
,SOURCE.[ProductId]);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
INSERT INTO dbo.[fds__admin_dberrors] ([UserName], [ErrorNumber], [ErrorState], [ErrorSeverity], [ErrorLine], [ErrorProcedure], [ErrorMessage], [ErrorDateTime])
VALUES (SUSER_SNAME(), ERROR_NUMBER(), ERROR_STATE(), ERROR_SEVERITY(), ERROR_LINE(), ERROR_PROCEDURE(), ERROR_MESSAGE(), GETDATE());
THROW;
END CATCH;
END';
DECLARE @type_co nvarchar(max) = N'CREATE TYPE [dbo].[mfr__tt__companies] AS TABLE (
[setid] VARCHAR (5) NULL,
[Id] BIGINT NOT NULL,
[Location#ID] BIGINT NULL,
[Version] INT NOT NULL,
[IsPhysicalPerson] BIT NOT NULL,
[IsOwner] BIT NOT NULL,
[IsEmailInvoicingActive] BIT NOT NULL,
[IsSupplier] BIT NOT NULL,
[MappingId] NVARCHAR (255) NULL,
[ExternalId] NVARCHAR (255) NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[SupportTelephone] NVARCHAR (255) NULL,
[SupportFax] NVARCHAR (255) NULL,
[SupportMail] NVARCHAR (255) NULL,
[QuickSearch] NVARCHAR (255) NULL,
[DateModified] DATETIME NOT NULL,
[CustomValues] NVARCHAR (MAX) NULL,
[MainContactId] BIGINT NULL);';
DECLARE @proc_co nvarchar(max) = N'-- =============================================
-- Description: mfr sync — merges one staged batch of Company entities into [mfr__companies].
-- @tblname is the bulk-copied staging table (same layout as [mfr__tt__companies]);
-- [setid] is the batch marker. A full entity reset (@referencetable = ''mfr__companies'')
-- first drops the company''s dependent partner/custom-value/location/comment rows so
-- they are rebuilt from the same batch. Duplicate Ids within one batch (paged reads
-- overlapping a concurrent mfr edit) are collapsed to the highest Version before the
-- MERGE, otherwise MERGE raises 8672.
-- =============================================
CREATE PROCEDURE [dbo].[mfr__updt__companies]
@tblname as nvarchar(50)
, @referencetable nvarchar(50)
, @tgtid bigint
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
DECLARE @tmp [dbo].[mfr__tt__companies];
DECLARE @tmp_cmd nvarchar(1000) = N''SELECT DISTINCT * FROM '' + @tblname
INSERT INTO @tmp EXECUTE [sp_executesql] @tmp_cmd;
-- Wrapping the DELETEs + MERGE below in one transaction gives all-or-nothing atomicity for
-- this entity''s sync batch (same pattern in every mfr__updt__* proc). During a large full
-- reset this can hold locks on the affected tables for as long as the batch takes to run —
-- an accepted, documented risk, not an oversight. See
-- Docs/Decisions/0022-mfr-sync-transaction-blocking-accepted-risk.md before changing this.
BEGIN TRANSACTION;
IF @referencetable = ''mfr__companies''
BEGIN
DELETE p
--OUTput deleted.*, @now INTO [dbo].[mfr__d_*PartnerSet]
FROM [dbo].[mfr__*PartnerSet] as p
WHERE p.Property like ''Company[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = p.[EntityId]);
DELETE cv
--OUTput deleted.*, @now INTO [dbo].[mfr__d_#customvalues]
FROM [dbo].[mfr__#customvalues] as cv
WHERE cv.Property like ''Company[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = cv.[EntityId]);
DELETE lc
--OUTPUT deleted.*, @now INTO [dbo].[mfr__d_#locations]
FROM [dbo].[mfr__#locations] as lc
WHERE lc.Property like ''Company[:]%'' AND EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = lc.[EntityId]);
DELETE cm
--OUTPUT deleted.*, @now INTO [dbo].[mfr__d_comments]
FROM [dbo].[mfr__comments] as cm
WHERE EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = cm.[CompanyId] AND ISNULL(cm.[ServiceRequestId],0) = 0);
END;
WITH tmp as (
SELECT *, ROW_NUMBER() OVER (PARTITION BY [Id] ORDER BY [Version] DESC, [DateModified] DESC) as ''#'' FROM @tmp
), SOURCE as (
SELECT * FROM tmp WHERE [#] = 1
)
MERGE [dbo].[mfr__companies] as TARGET
USING SOURCE ON TARGET.[Id] = SOURCE.[Id]
WHEN MATCHED AND (TARGET.[Version] <= SOURCE.[Version] OR TARGET.[DateModified] <= SOURCE.[DateModified]) THEN
UPDATE
SET [Id] = SOURCE.[Id]
,[Location#ID] = SOURCE.[Location#ID]
,[Version] = SOURCE.[Version]
,[IsPhysicalPerson] = SOURCE.[IsPhysicalPerson]
,[IsOwner] = SOURCE.[IsOwner]
,[IsEmailInvoicingActive] = SOURCE.[IsEmailInvoicingActive]
,[IsSupplier] = SOURCE.[IsSupplier]
,[MappingId] = SOURCE.[MappingId]
,[ExternalId] = SOURCE.[ExternalId]
,[Name] = SOURCE.[Name]
,[Note] = SOURCE.[Note]
,[SupportTelephone] = SOURCE.[SupportTelephone]
,[SupportFax] = SOURCE.[SupportFax]
,[SupportMail] = SOURCE.[SupportMail]
,[QuickSearch] = SOURCE.[QuickSearch]
,[DateModified] = SOURCE.[DateModified]
,[CustomValues] = SOURCE.[CustomValues]
,[MainContactId] = SOURCE.[MainContactId]
WHEN NOT MATCHED BY TARGET THEN
INSERT
( [Id]
,[Location#ID]
,[Version]
,[IsPhysicalPerson]
,[IsOwner]
,[IsEmailInvoicingActive]
,[IsSupplier]
,[MappingId]
,[ExternalId]
,[Name]
,[Note]
,[SupportTelephone]
,[SupportFax]
,[SupportMail]
,[QuickSearch]
,[DateModified]
,[CustomValues]
,[MainContactId])
VALUES
(SOURCE.[Id]
,SOURCE.[Location#ID]
,SOURCE.[Version]
,SOURCE.[IsPhysicalPerson]
,SOURCE.[IsOwner]
,SOURCE.[IsEmailInvoicingActive]
,SOURCE.[IsSupplier]
,SOURCE.[MappingId]
,SOURCE.[ExternalId]
,SOURCE.[Name]
,SOURCE.[Note]
,SOURCE.[SupportTelephone]
,SOURCE.[SupportFax]
,SOURCE.[SupportMail]
,SOURCE.[QuickSearch]
,SOURCE.[DateModified]
,SOURCE.[CustomValues]
,SOURCE.[MainContactId]);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
INSERT INTO dbo.[fds__admin_dberrors] ([UserName], [ErrorNumber], [ErrorState], [ErrorSeverity], [ErrorLine], [ErrorProcedure], [ErrorMessage], [ErrorDateTime])
VALUES (SUSER_SNAME(), ERROR_NUMBER(), ERROR_STATE(), ERROR_SEVERITY(), ERROR_LINE(), ERROR_PROCEDURE(), ERROR_MESSAGE(), GETDATE());
THROW;
END CATCH;
END';
DECLARE @perms TABLE ([stmt] nvarchar(max) NOT NULL);
DECLARE @stmt nvarchar(max);
BEGIN TRY
BEGIN TRANSACTION;
-- ---- 1. the [@company] table variable in the invoice/request preparation procs
-- Three column changes, matching the SSDT versions of these procs:
-- [Note] nvarchar(255) -> nvarchar(max) (this fix)
-- [CustomValues] NOT NULL -> NULL (nullable in [mfr__companies]; NOT NULL here
-- [MainContactId] NOT NULL -> NULL raised 515 on INSERT ... SELECT cy.* for a
-- company without main contact / custom values)
-- Only the "DECLARE @company TABLE ( ... );" block is edited: the same column names occur in other
-- table variables of these procs (e.g. @SReq.[CustomValues] NOT NULL), which must stay as they are.
DECLARE @procname sysname, @def nvarchar(max), @pos int, @i int, @blkStart int, @blkEnd int, @blk nvarchar(max), @blkNew nvarchar(max);
DECLARE @blkMarker nvarchar(50) = N'DECLARE @company TABLE';
DECLARE proc_cur CURSOR LOCAL FAST_FORWARD FOR
SELECT v.[name] FROM (VALUES (N'fds__getRequest_details'), (N'fds__prepInvoice'), (N'fds__prepStorno_recreate')) AS v([name]);
OPEN proc_cur;
FETCH NEXT FROM proc_cur INTO @procname;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @def = OBJECT_DEFINITION(OBJECT_ID(N'[dbo].' + QUOTENAME(@procname)));
-- Locate the CREATE keyword of "CREATE <whitespace> PROCEDURE" (deployed headers vary in spacing).
SET @pos = CHARINDEX(N'PROCEDURE', ISNULL(@def, N''));
SET @i = @pos - 1;
WHILE @i > 0 AND SUBSTRING(@def, @i, 1) IN (N' ', NCHAR(9), NCHAR(10), NCHAR(13)) SET @i -= 1;
SET @pos = CASE WHEN @pos > 0 AND @i >= 6 AND SUBSTRING(@def, @i - 5, 6) = N'CREATE' THEN @i - 5 ELSE 0 END;
-- Locate the single "DECLARE @company TABLE ( ... );" block (ends at the first ');' after the marker).
SET @blkStart = CHARINDEX(@blkMarker, ISNULL(@def, N''));
SET @blkEnd = CASE WHEN @blkStart > 0 THEN CHARINDEX(N');', @def, @blkStart) ELSE 0 END;
IF @def IS NULL
RAISERROR(N'WARNING: [dbo].[%s] not found - skipped.', 10, 1, @procname) WITH NOWAIT;
ELSE IF @pos = 0 OR @blkStart = 0 OR @blkEnd = 0 OR CHARINDEX(@blkMarker, @def, @blkStart + 1) > 0
RAISERROR(N'WARNING: [dbo].[%s] could not be patched automatically (no CREATE PROCEDURE header, or the @company TABLE block is missing/ambiguous) - edit its @company declaration by hand: [Note] nvarchar(max), [CustomValues] NULL, [MainContactId] NULL.', 10, 1, @procname) WITH NOWAIT;
ELSE
BEGIN
SET @blk = SUBSTRING(@def, @blkStart, @blkEnd - @blkStart + 2);
SET @blkNew = REPLACE(@blk, N'[Note] [nvarchar](255) NULL', N'[Note] [nvarchar](max) NULL');
SET @blkNew = REPLACE(@blkNew, N'[CustomValues] [nvarchar](max) NOT NULL', N'[CustomValues] [nvarchar](max) NULL');
SET @blkNew = REPLACE(@blkNew, N'[MainContactId] [bigint] NOT NULL', N'[MainContactId] [bigint] NULL');
IF @blkNew = @blk
PRINT CONCAT(N'[', @procname, N'] @company block already matches SSDT - skipped.');
ELSE
BEGIN
-- block first (it lies after the header), then CREATE -> ALTER (changes the length before it)
SET @def = STUFF(@def, @blkStart, DATALENGTH(@blk) / 2, @blkNew);
SET @def = STUFF(@def, @pos, LEN(N'CREATE'), N'ALTER');
EXEC (@def);
PRINT CONCAT(N'[', @procname, N'] @company block patched: [Note] nvarchar(max), [CustomValues] NULL, [MainContactId] NULL.');
END
END
FETCH NEXT FROM proc_cur INTO @procname;
END
CLOSE proc_cur;
DEALLOCATE proc_cur;
-- ---- 2. target tables
IF EXISTS (SELECT * FROM sys.columns WHERE [object_id] = OBJECT_ID(N'[dbo].[mfr__serviceobjects]') AND [name] = N'Note' AND [max_length] <> -1)
BEGIN
ALTER TABLE [dbo].[mfr__serviceobjects] ALTER COLUMN [Note] NVARCHAR (MAX) NULL;
PRINT N'[mfr__serviceobjects].[Note] widened to NVARCHAR(MAX).';
END
ELSE PRINT N'[mfr__serviceobjects].[Note] already NVARCHAR(MAX) - skipped.';
IF EXISTS (SELECT * FROM sys.columns WHERE [object_id] = OBJECT_ID(N'[dbo].[mfr__companies]') AND [name] = N'Note' AND [max_length] <> -1)
BEGIN
ALTER TABLE [dbo].[mfr__companies] ALTER COLUMN [Note] NVARCHAR (MAX) NULL;
PRINT N'[mfr__companies].[Note] widened to NVARCHAR(MAX).';
END
ELSE PRINT N'[mfr__companies].[Note] already NVARCHAR(MAX) - skipped.';
-- ---- 3. table types (+ the merge procs that declare them)
-- ---- [mfr__tt__serviceobjects] + [mfr__updt__serviceobjects]
IF EXISTS (SELECT * FROM sys.table_types AS tt JOIN sys.columns AS c ON c.[object_id] = tt.[type_table_object_id]
WHERE tt.[schema_id] = SCHEMA_ID(N'dbo') AND tt.[name] = N'mfr__tt__serviceobjects' AND c.[name] = N'Note' AND c.[max_length] <> -1)
BEGIN
-- A table type cannot be ALTERed. [mfr__updt__serviceobjects] DECLAREs it, so the proc is dropped
-- first and recreated from the SSDT source afterwards; explicit grants are re-applied.
INSERT INTO @perms ([stmt])
SELECT CONCAT(CASE p.[state] WHEN 'D' THEN N'DENY ' ELSE N'GRANT ' END, p.[permission_name],
CASE p.[class] WHEN 1 THEN N' ON OBJECT::[dbo].' + QUOTENAME(o.[name]) ELSE N' ON TYPE::[dbo].' + QUOTENAME(t.[name]) END,
N' TO ', QUOTENAME(USER_NAME(p.[grantee_principal_id])),
CASE p.[state] WHEN 'W' THEN N' WITH GRANT OPTION' ELSE N'' END, N';')
FROM sys.database_permissions AS p
LEFT JOIN sys.objects AS o ON p.[class] = 1 AND o.[object_id] = p.[major_id]
LEFT JOIN sys.types AS t ON p.[class] = 6 AND t.[user_type_id] = p.[major_id]
WHERE (p.[class] = 1 AND o.[object_id] = OBJECT_ID(N'[dbo].[mfr__updt__serviceobjects]'))
OR (p.[class] = 6 AND t.[name] = N'mfr__tt__serviceobjects' AND t.[schema_id] = SCHEMA_ID(N'dbo'));
IF OBJECT_ID(N'[dbo].[mfr__updt__serviceobjects]', N'P') IS NOT NULL DROP PROCEDURE [dbo].[mfr__updt__serviceobjects];
DROP TYPE [dbo].[mfr__tt__serviceobjects];
EXEC (@type_so);
EXEC (@proc_so);
PRINT N'[mfr__tt__serviceobjects] recreated with [Note] NVARCHAR(MAX); [mfr__updt__serviceobjects] recreated.';
END
ELSE PRINT N'[mfr__tt__serviceobjects].[Note] already NVARCHAR(MAX) - skipped.';
-- ---- [mfr__tt__companies] + [mfr__updt__companies]
IF EXISTS (SELECT * FROM sys.table_types AS tt JOIN sys.columns AS c ON c.[object_id] = tt.[type_table_object_id]
WHERE tt.[schema_id] = SCHEMA_ID(N'dbo') AND tt.[name] = N'mfr__tt__companies' AND c.[name] = N'Note' AND c.[max_length] <> -1)
BEGIN
-- A table type cannot be ALTERed. [mfr__updt__companies] DECLAREs it, so the proc is dropped
-- first and recreated from the SSDT source afterwards; explicit grants are re-applied.
INSERT INTO @perms ([stmt])
SELECT CONCAT(CASE p.[state] WHEN 'D' THEN N'DENY ' ELSE N'GRANT ' END, p.[permission_name],
CASE p.[class] WHEN 1 THEN N' ON OBJECT::[dbo].' + QUOTENAME(o.[name]) ELSE N' ON TYPE::[dbo].' + QUOTENAME(t.[name]) END,
N' TO ', QUOTENAME(USER_NAME(p.[grantee_principal_id])),
CASE p.[state] WHEN 'W' THEN N' WITH GRANT OPTION' ELSE N'' END, N';')
FROM sys.database_permissions AS p
LEFT JOIN sys.objects AS o ON p.[class] = 1 AND o.[object_id] = p.[major_id]
LEFT JOIN sys.types AS t ON p.[class] = 6 AND t.[user_type_id] = p.[major_id]
WHERE (p.[class] = 1 AND o.[object_id] = OBJECT_ID(N'[dbo].[mfr__updt__companies]'))
OR (p.[class] = 6 AND t.[name] = N'mfr__tt__companies' AND t.[schema_id] = SCHEMA_ID(N'dbo'));
IF OBJECT_ID(N'[dbo].[mfr__updt__companies]', N'P') IS NOT NULL DROP PROCEDURE [dbo].[mfr__updt__companies];
DROP TYPE [dbo].[mfr__tt__companies];
EXEC (@type_co);
EXEC (@proc_co);
PRINT N'[mfr__tt__companies] recreated with [Note] NVARCHAR(MAX); [mfr__updt__companies] recreated.';
END
ELSE PRINT N'[mfr__tt__companies].[Note] already NVARCHAR(MAX) - skipped.';
DECLARE perm_cur CURSOR LOCAL FAST_FORWARD FOR SELECT [stmt] FROM @perms;
OPEN perm_cur;
FETCH NEXT FROM perm_cur INTO @stmt;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC (@stmt);
PRINT N'Re-applied: ' + @stmt;
FETCH NEXT FROM perm_cur INTO @stmt;
END
CLOSE perm_cur;
DEALLOCATE perm_cur;
-- ---- 4. queue a re-sync of the rejected rows
-- Rows with a >255-char Note in mfr on 2026-09-30 (read-only scan of the mfr OData API).
DECLARE @resync TABLE ([entity_name] varchar(255) NOT NULL, [Id] bigint NOT NULL);
INSERT INTO @resync ([entity_name], [Id]) VALUES
('ServiceObject', 24296783880), ('ServiceObject', 34080915468), ('ServiceObject', 38722404355)
,('ServiceObject', 105289089024), ('ServiceObject', 105292464132), ('ServiceObject', 105292464134)
,('Company', 104430043136), ('Company', 105288990720);
-- Plus every row of the last rejected batch kept by the data service (if present).
IF OBJECT_ID(N'[dbo].[t_err_mfr__serviceobjects]', N'U') IS NOT NULL
INSERT INTO @resync EXEC (N'SELECT DISTINCT ''ServiceObject'', CAST([Id] AS bigint) FROM [dbo].[t_err_mfr__serviceobjects] WHERE [Id] IS NOT NULL;');
IF OBJECT_ID(N'[dbo].[t_err_mfr__companies]', N'U') IS NOT NULL
INSERT INTO @resync EXEC (N'SELECT DISTINCT ''Company'', CAST([Id] AS bigint) FROM [dbo].[t_err_mfr__companies] WHERE [Id] IS NOT NULL;');
INSERT INTO [dbo].[fds__mfr_updaterequests] ([entity_name], [Id])
SELECT DISTINCT r.[entity_name], r.[Id] FROM @resync AS r
WHERE NOT EXISTS (SELECT * FROM [dbo].[fds__mfr_updaterequests] AS q
WHERE q.[entity_name] = r.[entity_name] AND q.[Id] = r.[Id]);
PRINT CONCAT(N'Re-sync requests queued: ', @@ROWCOUNT);
COMMIT TRANSACTION;
PRINT N'mfr truncation fix applied.';
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO
-- ---- verification
-- Expect max_length = -1 on all four rows.
SELECT [object] = o.[name], [column] = c.[name], c.[max_length]
FROM sys.columns AS c JOIN sys.objects AS o ON o.[object_id] = c.[object_id]
WHERE c.[name] = N'Note' AND o.[name] IN (N'mfr__serviceobjects', N'mfr__companies')
UNION ALL
SELECT tt.[name], c.[name], c.[max_length]
FROM sys.table_types AS tt JOIN sys.columns AS c ON c.[object_id] = tt.[type_table_object_id]
WHERE c.[name] = N'Note' AND tt.[name] IN (N'mfr__tt__serviceobjects', N'mfr__tt__companies');
-- Expect no row (every @company block widened and made nullable; other table variables are not inspected).
SELECT [still_unpatched] = o.[name], [company_block] = b.[blk]
FROM sys.sql_modules AS m JOIN sys.objects AS o ON o.[object_id] = m.[object_id]
CROSS APPLY (SELECT [s] = CHARINDEX(N'DECLARE @company TABLE', m.[definition])) AS p
CROSS APPLY (SELECT [blk] = CASE WHEN p.[s] > 0 THEN SUBSTRING(m.[definition], p.[s], CHARINDEX(N');', m.[definition], p.[s]) - p.[s] + 2) ELSE N'<no @company block>' END) AS b
WHERE o.[name] IN (N'fds__getRequest_details', N'fds__prepInvoice', N'fds__prepStorno_recreate')
AND (b.[blk] LIKE N'%[[]Note] [[]nvarchar](255)%'
OR b.[blk] LIKE N'%[[]CustomValues] [[]nvarchar](max) NOT NULL%'
OR b.[blk] LIKE N'%[[]MainContactId] [[]bigint] NOT NULL%'
OR p.[s] = 0);
-- Once the worker has run, the queue drains and the counts approach mfr's
-- (2026-09-30: 5531 ServiceObjects, 4625 Companies).
SELECT [queued] = COUNT(*) FROM [dbo].[fds__mfr_updaterequests] WHERE [entity_name] IN ('ServiceObject', 'Company');
SELECT [serviceobjects] = (SELECT COUNT(*) FROM [dbo].[mfr__serviceobjects]), [companies] = (SELECT COUNT(*) FROM [dbo].[mfr__companies]);
+12
View File
@@ -0,0 +1,12 @@
-- Application logins (SQL authentication). They already exist on every environment and their real
-- passwords live in Azure Key Vault; nothing here is a secret.
--
-- The file exists so the users in this folder (fuchs_fds, fuchs_web) bind to a login when the project is
-- built: SSDT reports a user whose login is not in the model as error SQL71501, which is what kept the
-- database project from building at all (ADR 0036). On an existing server the deployment sees the login
-- as present and leaves it alone (SSDT never compares passwords); on a fresh server it is created with
-- an expired placeholder that must be changed before the login can be used.
CREATE LOGIN [fuchs_fds] WITH PASSWORD = N'Change-Me-Before-First-Use-1!' MUST_CHANGE, CHECK_EXPIRATION = ON, CHECK_POLICY = ON;
GO
CREATE LOGIN [fuchs_web] WITH PASSWORD = N'Change-Me-Before-First-Use-1!' MUST_CHANGE, CHECK_EXPIRATION = ON, CHECK_POLICY = ON;
GO
@@ -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));
@@ -12,9 +12,13 @@ CREATE FUNCTION [dbo].[fds__fn_IntermediateIsAllocatedToOther]
RETURNS bit
AS
BEGIN
DECLARE @IsA bit = CASE WHEN EXISTS (SELECT * FROM [dbo].[fds__invoices] as intermediates JOIN [dbo].[fds__invoice_details] as id on intermediates.[Id] = id.[InvId] and id.[AllocatedTo_InvId] is not null
-- An allocation only counts while the crediting invoice is alive: once that invoice is cancelled
-- (storno) its credit line is void and the Abschlagsrechnung must be offered again (ADR 0034).
DECLARE @IsA bit = CASE WHEN EXISTS (SELECT 1 FROM [dbo].[fds__invoices] as intermediates JOIN [dbo].[fds__invoice_details] as id on intermediates.[Id] = id.[InvId] and id.[AllocatedTo_InvId] is not null
JOIN [dbo].[fds__invoices] as non_intermediates ON id.[AllocatedTo_InvId] = non_intermediates.[Id] and id.[AllocatedTo_InvId] is not null
WHERE intermediates.[Id] = @Intermediate_InvID and (@Target_InvID is null or non_intermediates.[Id] <> @Target_InvID))
WHERE intermediates.[Id] = @Intermediate_InvID
and ISNULL(non_intermediates.[IsCanceled], 0) = 0
and (@Target_InvID is null or non_intermediates.[Id] <> @Target_InvID))
THEN 1 ELSE 0 END;
RETURN @isA;
@@ -0,0 +1,33 @@
-- =============================================
-- Description: Invoice backdating (ADR 0032). Returns the date an invoice may be backdated to
-- (the last working day Mon-Fri of the previous month, Europe/Berlin), or NULL when
-- backdating is not allowed:
-- * @authuser needs fds_inv > 2, and
-- * no invoice may have been finalized after that date yet (invoice numbers are
-- issued in date order; DateFinalized is stored in UTC, hence the 2h margin).
-- Single source for fds__setInvoiceFinal (@backdate = 1) and the preview dialog.
-- The check is only race-free inside fds__setInvoiceFinal, which serializes all
-- finalizations with a transaction-owned app lock; the preview call is advisory.
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_invoiceBackdate]
(
@authuser varchar(25)
)
RETURNS date
AS
BEGIN
IF [dbo].[fis_getModuleAuth]('fds_inv', @authuser) <= 2
RETURN NULL;
DECLARE @today date = CAST(SYSDATETIMEOFFSET() AT TIME ZONE 'W. Europe Standard Time' AS date);
DECLARE @eom date = EOMONTH(@today, -1);
-- DATEFIRST-independent weekday: 0 = Monday ... 5 = Saturday, 6 = Sunday
DECLARE @bd date = DATEADD(day, CASE DATEDIFF(day, '19000101', @eom) % 7 WHEN 5 THEN -1 WHEN 6 THEN -2 ELSE 0 END, @eom);
IF EXISTS (SELECT 0 FROM [dbo].[fds__invoices]
WHERE [DateFinalized] IS NOT NULL
AND [DateFinalized] >= DATEADD(HOUR, -2, CAST(DATEADD(DAY, 1, @bd) AS datetime)))
RETURN NULL;
RETURN @bd;
END
@@ -0,0 +1,18 @@
-- =============================================
-- Description: The invoice date ("Rechnungsdatum", ADR 0032) as a German calendar day:
-- DateFinalized (the finalisation - or, when backdated, the backdated - moment),
-- falling back to DateCreated for drafts. Both columns are stored in UTC.
-- SQL twin of Fuchs/code/InvoiceDates.IssueDate. Every list, report, export and
-- reminder that shows or groups by an invoice date uses this function (ADR 0035).
-- Also used for reminders (fds__reminder has the same DateFinalized/DateCreated pair).
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_invoiceDate]
(
@DateFinalized datetime,
@DateCreated datetime
)
RETURNS date
AS
BEGIN
RETURN CAST(ISNULL(@DateFinalized, @DateCreated) AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time' AS date);
END
@@ -20,11 +20,11 @@ BEGIN
WITH inv as (
SELECT
[Dateofcreation] = i.[DateFinalized]
[Dateofcreation] = [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])
,i.[InvoiceBalance]
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
FROM [dbo].[fds__invoices] as i
WHERE i.[isFinal] = 1 AND [dbo].[date_monthfirst](i.[DateFinalized]) = @thismonthfirst
WHERE i.[isFinal] = 1 AND [dbo].[date_monthfirst]([dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])) = @thismonthfirst
)
SELECT TOP(1) @ret = FORMAT( SUM( ISNULL(inv.[InvoiceBalanceNetto],0) ) * 0.001, '#,0 k€', 'de') FROM inv;
END
@@ -14,7 +14,7 @@ BEGIN
DECLARE @groups TABLE([usergroup_id] varchar(5));
INSERT INTO @groups SELECT * from STRING_SPLIT(@usergroup_id,',');
DECLARE @isMember bit = CASE WHEN EXISTS (SELECT * FROM [dbo].[fis_usergroups] as g
DECLARE @isMember bit = CASE WHEN EXISTS (SELECT 1 FROM [dbo].[fis_usergroups] as g
JOIN @groups as groupstotest On groupstotest.[usergroup_id] = g.[usergroup_id]
JOIN [dbo].[fis_usergroups_members] as gm on g.[usergroup_id] = gm.[usergroup_id] and gm.[DateDeleted] is null AND g.[DateDeleted] is null
JOIN [dbo].[fis_useraccounts] as u on gm.[useraccount_id] = u.[useraccount_id]
@@ -0,0 +1,88 @@

CREATE FUNCTION [dbo].[ocms_fn_generatePassword] (
@length int
, @specialChars bit = 1
, @nums bit = 1
, @lowerCase bit = 1
)
RETURNS varchar(255)
AS
BEGIN
DECLARE @randInt int;
DECLARE @NewCharacter varchar(1);
DECLARE @NewPassword TABLE([typ] char(1), [char] char(1), [order] int);
SET @length = CASE WHEN @length > 255 THEN 255 ELSE @length END;
DECLARE @cnt int = 0, @position as int;
--random characters
WHILE (@cnt < @length)
BEGIN
SELECT @randInt = [dbo].[ocms_fn_randbetween](33,122)
IF (CASE WHEN (@randInt>=33 AND @randInt<=47) AND @specialChars = 1 THEN 1
WHEN (@randInt>=48 AND @randInt<=57) AND @nums = 1 THEN 1
WHEN (@randInt>=58 AND @randInt<=64) AND @specialChars = 1 THEN 1
WHEN (@randInt>=65 AND @randInt<=90) THEN 1
WHEN (@randInt>=91 AND @randInt<=93) AND @specialChars = 1 THEN 1
WHEN (@randInt>=97 AND @randInt<=122) AND @lowerCase = 1 THEN 1
ELSE 0
END) = 1
BEGIN
SET @cnt = @cnt + 1;
INSERT INTO @NewPassword
VALUES (CASE WHEN (@randInt>=33 AND @randInt<=47) THEN 's'
WHEN (@randInt>=48 AND @randInt<=57) THEN 'n'
WHEN (@randInt>=58 AND @randInt<=64) THEN 's'
WHEN (@randInt>=65 AND @randInt<=90) THEN 'u'
WHEN (@randInt>=91 AND @randInt<=93) THEN 's'
WHEN (@randInt>=97 AND @randInt<=122) THEN 'l'
END,
CHAR(@randInt), @cnt);
END
END
DECLARE @min_n int = CASE WHEN @length > 4 AND @length < 11 THEN 1 ELSE FLOOR(@length * 0.1) END;
DECLARE @min_s int = FLOOR(@length * 0.1);
DECLARE @min_l int = FLOOR(@length * 0.4);
DECLARE @min_u int = FLOOR(@length * 0.4);
WHILE (SELECT COUNT(*) FROM @NewPassword WHERE [typ] = 'n') < @min_n AND @length > 0 AND @nums = 1
BEGIN
SET @position = [dbo].[ocms_fn_randbetween](1,@length);
UPDATE @NewPassword SET [char] = CHAR([dbo].[ocms_fn_randbetween](48,57)), [typ] = 'n' WHERE [order] = @position;
END
WHILE (SELECT COUNT(*) FROM @NewPassword WHERE [typ] = 'l') < @min_l AND @length > 0 AND @lowerCase = 1
BEGIN
SET @position = [dbo].[ocms_fn_randbetween](1,@length);
UPDATE @NewPassword SET [char] = CHAR([dbo].[ocms_fn_randbetween](97,122)), [typ] = 'l' WHERE [order] = @position;
END
WHILE (SELECT COUNT(*) FROM @NewPassword WHERE [typ] = 'u') < @min_n AND @length > 0
BEGIN
SET @position = [dbo].[ocms_fn_randbetween](1,@length);
UPDATE @NewPassword SET [char] = CHAR([dbo].[ocms_fn_randbetween](65,90)), [typ] = 'u' WHERE [order] = @position;
END
WHILE (SELECT COUNT(*) FROM @NewPassword WHERE [typ] = 's') < @min_s AND @length > 0 AND @specialChars = 1
BEGIN
SET @position = [dbo].[ocms_fn_randbetween](1,@length);
DECLARE @nc char(1), @nt char(1);
select @randInt=[dbo].[ocms_fn_randbetween](33,122)
UPDATE @NewPassword SET [char] = CHAR(@randint), [typ] = 's' WHERE [order] = @position AND (CASE WHEN (@randInt>=33 AND @randInt<=47) THEN 's'
WHEN (@randInt>=48 AND @randInt<=57) THEN 'n'
WHEN (@randInt>=58 AND @randInt<=64) THEN 's'
WHEN (@randInt>=65 AND @randInt<=90) THEN 'u'
WHEN (@randInt>=91 AND @randInt<=93) THEN 's'
WHEN (@randInt>=97 AND @randInt<=122) THEN 'l'
END) = 's';
END
RETURN (Cast((SELECT [CHAR] as [text()] FROM @NewPassword FOR XML PATH(''),TYPE).value('.','NVARCHAR(MAX)') as varchar(255)));
END;
@@ -0,0 +1,8 @@

CREATE FUNCTION [dbo].[ocms_fn_randbetween](@bottom integer, @top integer)
RETURNS integer
AS
BEGIN
RETURN (select cast(round((@top-@bottom) * [rn] +@bottom,0) as integer) from [dbo].[ocms_fn_rand])
END
@@ -96,7 +96,7 @@ BEGIN
SELECT TOP(1)
o.*
, [InvoiceId] = inv.[InvoiceId]
, [InvoiceDate] = inv.[DateFinalized]
, [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])
@@ -43,11 +43,16 @@ BEGIN
;
-- The invoice date (fds__fn_invoiceDate: DateFinalized as German day, ADR 0035) drives both the export
-- period and the Belegdatum; DateCreated is only the draft's creation and may lie in a later month for
-- a backdated invoice.
-- [DateOfCreation] keeps its name for the callers (HandleDatev / FdsMfr.GetDatevZip) but carries that day.
DECLARE @inv TABLE([Id] varchar(20), [InvoiceID] varchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit, [file_guid] varchar(36));
INSERT INTO @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0), [file_guid]
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = d.[InvoiceDate],[fds] = IIF([isExternal] = 0,1,0), [file_guid]
FROM [dbo].[fds__invoices]
where CAST([DateCreated] as date) between @startdate AND @enddate and [isfinal] = 1;
CROSS APPLY (SELECT [InvoiceDate] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])) as d
where [isfinal] = 1 and d.[InvoiceDate] between @startdate AND @enddate;
SELECT
inv.[Id],inv.[InvoiceID],inv.[DocumentName],inv.[fds]
@@ -78,15 +83,15 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
,[customer] = (SELECT TOP(1) [ExternalId] FROM [dbo].[mfr__companies] as c WHERE c.[Id] = iv.[CustomerId])
,[contra_account] = ca.[account]
,[Belegdatum] = iv.[DateCreated]
,[Belegdatum] = i.[DateOfCreation] -- the invoice date (DateFinalized as German day), see @inv
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[file_guid]
,[DateOfCreation] = i.[DateOfCreation]
,[file_guid] = iv.[file_guid]
from [dbo].[fds__invoices] as iv
JOIN @inv as i on iv.[Id] = i.[Id]
LEFT JOIN [dbo].[fds__invoice_details] as d on iv.[Id] = d.[InvId]
LEFT JOIN sc on iv.[id] = sc.[invoice.Id]
LEFT JOIN @contraaccounts as ca ON (CASE WHEN d.InvoiceOptions like '%§13b%' and RIGHT(ca.[name], 3) = '13b' then 1 WHEN d.InvoiceOptions like '%§13b%' THEN 0 when ISNULL(iv.[InvoiceVAT_1], 19.0) = ca.[vat] THEN 1 ELSE 0 END) = 1
where iv.Id in (SELECT [Id] FROM @inv)
)
select
[Umsatz (ohne Soll/Haben-Kz)] = ABS([balance])--CAST(FORMAT(ABS([balance]), '0.00', 'de') as varchar(13))
@@ -16,15 +16,15 @@ BEGIN
IF @type = 'invoice'
BEGIN
SELECT TOP(1)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [InvoiceID],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateCreated] ASC)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [InvoiceID],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized] ASC)
FROM [dbo].[fds__invoices] WHERE [id] = @reportid AND [isFinal] = 1;
END
ELSE IF @type = 'reminder'
BEGIN
SELECT TOP(1)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [ReminderID] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [DateCreated],[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateCreated] ASC)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [ReminderID] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized] ASC)
FROM [dbo].[fds__reminder] WHERE [id] = @reportid AND [isFinal] = 1;
END
@@ -46,9 +46,9 @@ BEGIN
DECLARE @inv TABLE([Id] varchar(15), [InvoiceID] varchar(255), [URI] nvarchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit);
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
where CAST([DateCreated] as date) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
where [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
DECLARE @SReq TABLE ([id] bigint, [DateOfCreation] datetime, [name] nvarchar(255), [invoice.Id] varchar(15), [customer.ExternalId] bigint, [customer.Id] bigint, [ExternalId] nvarchar(255), [fds] bit);
INSERT INTO @SReq
@@ -87,9 +87,9 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
,[customer] = (SELECT TOP(1) [customer.ExternalId] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id] and [customer.ExternalId] is not null ORDER BY SReq.DateOfCreation ASC)
,[contra_account] = ca.[account]
,[Belegdatum] = FORMAT(iv.[DateCreated], 'dd.MM.yy')
,[Belegdatum] = FORMAT([dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated]), 'dd.MM.yy')
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[DateOfCreation] = [dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated])
,[DueDate]
,[PaymentTerm] = ISNULL([PaymentTerm], @stage1)
,[PaymentStatus]

Some files were not shown because too many files have changed in this diff Show More