Compare commits
10
Commits
a36f0d859d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b94b05c2 | ||
|
|
4443bba63c | ||
|
|
4145ab699c | ||
|
|
e2faf52cd2 | ||
|
|
546648533b | ||
|
|
d0002ccdad | ||
|
|
0a8384ab33 | ||
|
|
c741535aec | ||
|
|
7357f60120 | ||
|
|
63b3683725 |
@@ -13,6 +13,7 @@
|
|||||||
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
|
- **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`.
|
- 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 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.
|
- 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).
|
- 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.
|
- 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
|
## 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).
|
- 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`.
|
- 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)
|
## 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).
|
- 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).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
## Build & Test (workflow)
|
## Build & Test (workflow)
|
||||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
- 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.
|
- 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).
|
- 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.
|
- 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
|
## 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).
|
- 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`.
|
- 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)
|
## Bank statement parsing (MT940 + CAMT)
|
||||||
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
## Build & Test (workflow)
|
## Build & Test (workflow)
|
||||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
- 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.
|
- 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).
|
- 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.
|
- 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
|
## 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).
|
- 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`.
|
- 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)
|
## Bank statement parsing (MT940 + CAMT)
|
||||||
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
- `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.");
|
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 ────────────────────────────────────────────
|
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.21.0" />
|
||||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
<PackageReference Include="coverlet.collector" Version="10.1.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
|
|||||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("INV42", result!.Id);
|
Assert.Equal("INV42", result!.Data.Id);
|
||||||
|
Assert.False(result.Updated);
|
||||||
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||||
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
||||||
|
|
||||||
@@ -680,9 +681,10 @@ public class InvoiceDraftServiceTests
|
|||||||
payload["invid"] = "INV7";
|
payload["invid"] = "INV7";
|
||||||
var s = svc.OpenFromPayload(payload, "user1");
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.True(inv.LastChange); // prior InvId → update path
|
Assert.True(inv.LastChange); // prior InvId → update path
|
||||||
|
Assert.True(result!.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
|
|||||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("REM42", result!.Id);
|
Assert.Equal("REM42", result!.Data.Id);
|
||||||
|
Assert.False(result.Updated);
|
||||||
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||||
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||||
// the email/subject the editor set must reach registration
|
// the email/subject the editor set must reach registration
|
||||||
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
|
|||||||
payload["remid"] = "REM7";
|
payload["remid"] = "REM7";
|
||||||
var s = svc.OpenFromPayload(payload, "user1");
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
Assert.True(rem.LastChange); // prior RemId → update path
|
Assert.True(rem.LastChange); // prior RemId → update path
|
||||||
|
Assert.True(result!.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reminder save batch (<see cref="ReminderService.BuildRegisterReminderSql"/>): a new draft is
|
||||||
|
/// created, a re-saved one is updated in place via <c>fds__setReminder</c> — previously the update
|
||||||
|
/// path ran an empty batch, so "Zwischenspeichern" of an already-registered reminder draft silently
|
||||||
|
/// saved nothing. Either proc leaving <c>@Id</c> NULL must surface as an error, not as an empty row.
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderRegisterSqlTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, "[dbo].[fds__createReminder] @InvId,", "[dbo].[fds__setReminder]")]
|
||||||
|
[InlineData(true, "[dbo].[fds__setReminder] @RemId,", "[dbo].[fds__createReminder]")]
|
||||||
|
public void BuildRegisterReminderSql_ExactlyOneProcPerMode(bool change, string expectedProc, string otherProc)
|
||||||
|
{
|
||||||
|
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||||
|
|
||||||
|
Assert.Contains(expectedProc, sql);
|
||||||
|
Assert.DoesNotContain(otherProc, sql);
|
||||||
|
Assert.Equal(change, sql.Contains("@RemId"));
|
||||||
|
Assert.Equal(!change, sql.Contains("@InvId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void BuildRegisterReminderSql_GuardFollowsTheProc(bool change)
|
||||||
|
{
|
||||||
|
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||||
|
|
||||||
|
int proc = sql.IndexOf("EXECUTE [dbo].[fds__", StringComparison.Ordinal);
|
||||||
|
int guard = sql.IndexOf("IF @Id IS NULL THROW 50001", StringComparison.Ordinal);
|
||||||
|
Assert.True(proc >= 0 && guard > proc, sql);
|
||||||
|
Assert.StartsWith("DECLARE @Id varchar(10);", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void BuildRegisterReminderSql_EveryStatementIsSemicolonTerminated(bool change)
|
||||||
|
{
|
||||||
|
// THROW requires the preceding statement to be terminated with ';'.
|
||||||
|
foreach (var line in ReminderService.BuildRegisterReminderSql(change).Split('\n'))
|
||||||
|
Assert.EndsWith(";", line.TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static OCORE.SQL.sql;
|
||||||
using static OCORE.web.mvc_helper_async;
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
namespace Fuchs.Controllers;
|
namespace Fuchs.Controllers;
|
||||||
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
|
|||||||
// only signals (ADR 0007).
|
// only signals (ADR 0007).
|
||||||
public partial class IntranetController
|
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 > 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>
|
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||||
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||||
|
|
||||||
@@ -71,7 +86,8 @@ public partial class IntranetController
|
|||||||
return await JSONAsync(new { ok = true, version = session.Version });
|
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)
|
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
if (!HasForm("token")) return BadRequest400();
|
||||||
@@ -81,7 +97,8 @@ public partial class IntranetController
|
|||||||
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
|
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
|
||||||
if (doc == null) return DraftGone();
|
if (doc == null) return DraftGone();
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
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();
|
if (!HasForm("token")) return BadRequest400();
|
||||||
string token = Form("token");
|
string token = Form("token");
|
||||||
var before = _invoiceDrafts.Get(token);
|
// Create vs update is decided inside the flush (flushes of one session are serialised), not
|
||||||
if (before == null) return DraftGone();
|
// from the session state seen here: a save overlapping the first registration is an update.
|
||||||
bool existed = !string.IsNullOrEmpty(before.InvId);
|
var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (flush == null) return DraftGone();
|
||||||
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
var fdInv = flush.Data;
|
||||||
if (fdInv == null) return DraftGone();
|
|
||||||
if (string.IsNullOrEmpty(fdInv.Id))
|
if (string.IsNullOrEmpty(fdInv.Id))
|
||||||
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
|
||||||
var after = _invoiceDrafts.Get(token);
|
var after = _invoiceDrafts.Get(token);
|
||||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||||
|
|||||||
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
|
|||||||
// Partial class: Invoice sub-handlers
|
// Partial class: Invoice sub-handlers
|
||||||
public partial class IntranetController
|
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)
|
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 (!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);
|
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
|
||||||
|
|
||||||
using (var mfr = _mfrFactory.Create())
|
using (var mfr = _mfrFactory.Create())
|
||||||
@@ -263,6 +275,16 @@ public partial class IntranetController
|
|||||||
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
|
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)
|
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||||
@@ -274,10 +296,10 @@ public partial class IntranetController
|
|||||||
string mode = Form("mode").ne("m");
|
string mode = Form("mode").ne("m");
|
||||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
||||||
var dset = await getSQLDataSet_async(
|
var dset = await getSQLDataSet_async(
|
||||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
|
DatevOverviewSql,
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
|
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
|
||||||
tablenames: new[] { "files", "invoices", "debits" },
|
tablenames: DatevOverviewTables,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
|
_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);
|
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"],
|
["Id"] = d["Id"],
|
||||||
["net_val"] = net,
|
["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) + "%",
|
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
|
||||||
["svcnet_val"] = d.no("value_service", 0),
|
["svcnet_val"] = d.no("value_service", 0),
|
||||||
["net"] = d.no("value", 0),
|
["net"] = d.no("value", 0),
|
||||||
@@ -465,7 +489,10 @@ public partial class IntranetController
|
|||||||
["NameOrNumber"] = "",
|
["NameOrNumber"] = "",
|
||||||
["htmltext"] = d["Text"],
|
["htmltext"] = d["Text"],
|
||||||
["position"] = d["Position"],
|
["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;
|
sdic["items"] = itm;
|
||||||
|
|||||||
@@ -82,16 +82,14 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
if (!HasForm("token")) return BadRequest400();
|
||||||
string token = Form("token");
|
string token = Form("token");
|
||||||
var before = _reminderDrafts.Get(token);
|
// Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
|
||||||
if (before == null) return DraftGone();
|
var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
bool existed = !string.IsNullOrEmpty(before.RemId);
|
if (flush == null) return DraftGone();
|
||||||
|
var fdRem = flush.Data;
|
||||||
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
|
||||||
if (fdRem == null) return DraftGone();
|
|
||||||
if (string.IsNullOrEmpty(fdRem.Id))
|
if (string.IsNullOrEmpty(fdRem.Id))
|
||||||
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
|
||||||
var after = _reminderDrafts.Get(token);
|
var after = _reminderDrafts.Get(token);
|
||||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Observability;
|
||||||
using Fuchs.Services;
|
using Fuchs.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
@@ -302,14 +303,41 @@ public partial class IntranetController
|
|||||||
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
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(); }
|
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(
|
var dt = await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_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));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
if (!string.IsNullOrEmpty(dt.Exception))
|
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();
|
var frdic = dt.FirstRow.toObjectDictionary();
|
||||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
- 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`.
|
- 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: Active
|
status: Active
|
||||||
lastUpdated: 2026-09-09
|
lastUpdated: 2026-10-01
|
||||||
applyTo:
|
applyTo:
|
||||||
- "Fuchs/Services/InvoiceDraft*"
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
- "Fuchs/Services/IInvoiceDraft*"
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
|||||||
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
|
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
|
||||||
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
|
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
|
||||||
change values — only order, membership, levels and therefore position numbers.
|
change values — only order, membership, levels and therefore position numbers.
|
||||||
|
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
|
||||||
|
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
|
||||||
|
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
|
||||||
|
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
|
||||||
|
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
|
||||||
|
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
|
||||||
|
cannot be held across the registration's `await`): the create/update decision in
|
||||||
|
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
|
||||||
|
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
|
||||||
|
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
|
||||||
|
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
|
||||||
|
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
|
||||||
|
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
|
||||||
|
it rather than from the session state it saw before the flush. Patches are never blocked by a
|
||||||
|
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
|
||||||
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||||
@@ -146,9 +161,12 @@ amount pair.
|
|||||||
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||||
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||||
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||||
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
|
||||||
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
later save of the same session runs `fds__setReminder`, which updates the row in place (version
|
||||||
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
|
||||||
|
the reminder is finalised or has a stored file. Flushes are serialised on the session's
|
||||||
|
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
|
||||||
|
create two reminder rows either.
|
||||||
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||||
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||||
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -97,7 +97,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
|||||||
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||||
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||||
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||||
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14
|
||||||
|
and printed in the PDF's admin block (`InvoiceService.ApplyProvisionPeriod`; legacy ISO
|
||||||
|
values are shown as German dates).
|
||||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
@@ -181,6 +183,36 @@ Confirming the preview modal posts to **`req/sconf`**
|
|||||||
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
|
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
|
||||||
(the draft only ever had the internal numeric `Id`). `Version` is bumped
|
(the draft only ever had the internal numeric `Id`). `Version` is bumped
|
||||||
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
|
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:
|
2. On success (`IsFinal == true` returned), the controller:
|
||||||
- Reloads the invoice via `LoadInvoiceAsync`.
|
- Reloads the invoice via `LoadInvoiceAsync`.
|
||||||
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
|
- 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) |
|
| **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 |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
|
|||||||
### Erstellen / Bearbeiten
|
### Erstellen / Bearbeiten
|
||||||
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
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)
|
### Sets (Positionen zusammenfassen)
|
||||||
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
|
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
|
||||||
|
|||||||
+10
-10
@@ -32,14 +32,14 @@
|
|||||||
<!-- Compatible packages (kept) -->
|
<!-- Compatible packages (kept) -->
|
||||||
<PackageReference Include="BouncyCastle" Version="1.8.9" />
|
<PackageReference Include="BouncyCastle" Version="1.8.9" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
|
||||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
<PackageReference Include="MailKit" Version="4.18.1" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.18.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.19.0" />
|
||||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
<PackageReference Include="MimeKit" Version="4.18.1" />
|
<PackageReference Include="MimeKit" Version="4.18.1" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<!-- New packages (needed for .NET 10) -->
|
<!-- New packages (needed for .NET 10) -->
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.0" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.3" />
|
||||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" />
|
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="App_Data\cache\" />
|
<Folder Include="App_Data\cache\" />
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
|
|||||||
public static readonly Counter<long> BlobBackupCopiesFailed =
|
public static readonly Counter<long> BlobBackupCopiesFailed =
|
||||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
|
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
|
||||||
"Number of blob backup-copy attempts that failed.");
|
"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 =
|
public static readonly Counter<long> InvoiceFileBackups =
|
||||||
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
|
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
|
||||||
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
|
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
|
|||||||
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
|
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
|
||||||
return response.Value;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
|
_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);
|
category, documentId, containerName, blobName);
|
||||||
return blobClient.Uri;
|
return blobClient.Uri;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -259,13 +259,7 @@ public static class ERechnungMapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
|
/// <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)
|
internal static CountryCode NormalizeCountry(string? raw)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public interface IBlobStorageService
|
|||||||
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
|
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
|
||||||
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
|
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
|
||||||
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
|
/// 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>
|
/// </summary>
|
||||||
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
||||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||||
@@ -50,7 +51,8 @@ public interface IBlobStorageService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the cached session to the DB via the existing invoice registration path
|
/// Persists the cached session to the DB via the existing invoice registration path
|
||||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
|
||||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
/// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
|
||||||
|
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||||
|
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||||
|
/// ran as an update (for the success event), or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||||
|
|
||||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||||
Task<Document?> RenderPreviewAsync(string token);
|
Task<Document?> RenderPreviewAsync(string token);
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ public interface IReminderDraftService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the cached session to the DB via the existing reminder registration path
|
/// Persists the cached session to the DB via the existing reminder registration path
|
||||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
|
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
|
||||||
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
|
/// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
|
||||||
|
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||||
|
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||||
|
/// ran as an update (for the success event), or null if the token is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||||
|
|
||||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||||
Document? RenderPreview(string token);
|
Document? RenderPreview(string token);
|
||||||
|
|||||||
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
string oldValue = "", newValue = "";
|
// The editor fires patches (and state/preview fetches) concurrently for one token; without
|
||||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
// the lock a block.replace on one request mutates Req while another enumerates it.
|
||||||
if (!mutated)
|
lock (session.SyncRoot)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
string oldValue = "", newValue = "";
|
||||||
|
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||||
|
if (!mutated)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh(session);
|
||||||
|
session.Version++;
|
||||||
|
session.History.Add(new ChangeHistoryEntry
|
||||||
|
{
|
||||||
|
UserAccountId = session.UserAccountId,
|
||||||
|
Target = delta.Target,
|
||||||
|
Ref = delta.Ref,
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
Version = session.Version
|
||||||
|
});
|
||||||
|
_cache.Set(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
Refresh(session);
|
|
||||||
session.Version++;
|
|
||||||
session.History.Add(new ChangeHistoryEntry
|
|
||||||
{
|
|
||||||
UserAccountId = session.UserAccountId,
|
|
||||||
Target = delta.Target,
|
|
||||||
Ref = delta.Ref,
|
|
||||||
OldValue = oldValue,
|
|
||||||
NewValue = newValue,
|
|
||||||
Version = session.Version
|
|
||||||
});
|
|
||||||
_cache.Set(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── View state / history ────────────────────────────────────────────────
|
// ── View state / history ────────────────────────────────────────────────
|
||||||
|
/// <remarks>
|
||||||
|
/// The result is serialised by the caller after this returns, so everything mutable is a
|
||||||
|
/// snapshot taken under the session lock — never a live reference into the session.
|
||||||
|
/// </remarks>
|
||||||
public object BuildState(InvoiceDraftSession session)
|
public object BuildState(InvoiceDraftSession session)
|
||||||
{
|
{
|
||||||
session.Touch();
|
session.Touch();
|
||||||
return new
|
lock (session.SyncRoot)
|
||||||
{
|
{
|
||||||
token = session.Token,
|
return new
|
||||||
version = session.Version,
|
|
||||||
invid = session.InvId,
|
|
||||||
isDraft = session.IsDraft,
|
|
||||||
admin = session.Admin,
|
|
||||||
@new = session.New,
|
|
||||||
req = session.Req,
|
|
||||||
sums = new
|
|
||||||
{
|
{
|
||||||
total_net = session.Sums.TotalNet,
|
token = session.Token,
|
||||||
total_gross = session.Sums.TotalGross,
|
version = session.Version,
|
||||||
total_vat = session.Sums.TotalVat,
|
invid = session.InvId,
|
||||||
service_net = session.Sums.ServiceNet,
|
isDraft = session.IsDraft,
|
||||||
service_vat = session.Sums.ServiceVat,
|
admin = session.Admin.DeepClone(),
|
||||||
vat = session.Sums.VatByRate,
|
@new = session.New.DeepClone(),
|
||||||
block_net = session.Sums.NetByBlock
|
req = session.Req.DeepClone(),
|
||||||
},
|
sums = new
|
||||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
{
|
||||||
historyCount = session.History.Count,
|
total_net = session.Sums.TotalNet,
|
||||||
notes = BuildNotes(session),
|
total_gross = session.Sums.TotalGross,
|
||||||
setDisplay = BuildSetDisplay(session)
|
total_vat = session.Sums.TotalVat,
|
||||||
};
|
service_net = session.Sums.ServiceNet,
|
||||||
|
service_vat = session.Sums.ServiceVat,
|
||||||
|
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
|
||||||
|
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
|
||||||
|
},
|
||||||
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||||
|
historyCount = session.History.Count,
|
||||||
|
notes = BuildNotes(session).ToList(),
|
||||||
|
setDisplay = BuildSetDisplay(session)
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
.Select(n => new { text = n.Text, style = n.Style });
|
.Select(n => new { text = n.Text, style = n.Style });
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||||
|
lock (session.SyncRoot) return session.History.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||||
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
var fds = BuildFdsData(session);
|
// One flush at a time per session: the create/update decision below is read-then-write across
|
||||||
bool change = !string.IsNullOrEmpty(session.InvId);
|
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
|
||||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
|
||||||
if (!string.IsNullOrEmpty(reg.Id))
|
// first one created (with its own, newer snapshot).
|
||||||
|
await session.FlushGate.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
session.InvId = reg.Id;
|
FdsInvoiceData fds;
|
||||||
_cache.Set(session);
|
string invId;
|
||||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
lock (session.SyncRoot)
|
||||||
token, reg.Id, change, userAccountId);
|
{
|
||||||
|
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||||
|
invId = session.InvId;
|
||||||
|
}
|
||||||
|
bool change = !string.IsNullOrEmpty(invId);
|
||||||
|
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
|
||||||
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
|
{
|
||||||
|
lock (session.SyncRoot) session.InvId = reg.Id;
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||||
|
token, reg.Id, change, userAccountId);
|
||||||
|
}
|
||||||
|
return new DraftFlushResult<FdsInvoiceData>(reg, change);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.FlushGate.Release();
|
||||||
}
|
}
|
||||||
return reg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Document?> RenderPreviewAsync(string token)
|
public async Task<Document?> RenderPreviewAsync(string token)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
var fds = BuildFdsData(session);
|
FdsInvoiceData fds;
|
||||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildFdsData(session);
|
||||||
|
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||||
|
}
|
||||||
fds.IsDraft = true;
|
fds.IsDraft = true;
|
||||||
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
||||||
}
|
}
|
||||||
@@ -616,7 +654,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
||||||
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
||||||
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
["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;
|
int idx = 0;
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
|
|||||||
|
|
||||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
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)
|
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||||
@@ -151,6 +153,27 @@ public class InvoiceService : IInvoiceService
|
|||||||
return blocks;
|
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,
|
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||||
string userAccountId, DatabaseSecurity dbSec)
|
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; }
|
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) };
|
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
try
|
||||||
|
{
|
||||||
|
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||||
|
}
|
||||||
|
catch (InvoiceAmountOutOfRangeException ex)
|
||||||
|
{
|
||||||
|
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
|
||||||
|
_logger.LogError(InvoiceAmountRejected, ex,
|
||||||
|
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
|
||||||
|
invId, ex.Parameter, ex.Value, userAccountId);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
if (change) pl.Add(SQL_VarChar("@InvId", invId));
|
||||||
if (!change)
|
bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
|
||||||
{
|
if (hasProvisionLocation)
|
||||||
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)
|
|
||||||
{
|
|
||||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||||
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
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" },
|
Conn, pl, tablenames: new[] { "inv", "det" },
|
||||||
Security: dbSec, options: new FIS_SQLOptions());
|
Security: dbSec, options: new FIS_SQLOptions());
|
||||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
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 —
|
/// 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.
|
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
|
||||||
/// </summary>
|
/// </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)
|
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
string invoiceId = invoice.Id;
|
string invoiceId = invoice.Id;
|
||||||
@@ -269,6 +301,7 @@ public class InvoiceService : IInvoiceService
|
|||||||
float vs = 0;
|
float vs = 0;
|
||||||
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
|
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
|
||||||
row["det"] = itmDic.no("det", false) is true;
|
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);
|
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
|
||||||
itemsTable.Rows.Add(row);
|
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)
|
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
|
||||||
{
|
{
|
||||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||||
@@ -302,8 +351,11 @@ public class InvoiceService : IInvoiceService
|
|||||||
: Array.Empty<string>(),
|
: Array.Empty<string>(),
|
||||||
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
||||||
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
|
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 inlineImages = await ResolveInlineImagesAsync(invoice);
|
||||||
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
||||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||||
|
|||||||
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
string oldValue = "", newValue = "";
|
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
|
||||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
|
||||||
if (!mutated)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
string oldValue = "", newValue = "";
|
||||||
|
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||||
|
if (!mutated)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh(session);
|
||||||
|
session.Version++;
|
||||||
|
session.History.Add(new ChangeHistoryEntry
|
||||||
|
{
|
||||||
|
UserAccountId = session.UserAccountId,
|
||||||
|
Target = delta.Target,
|
||||||
|
Ref = delta.Ref,
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
Version = session.Version
|
||||||
|
});
|
||||||
|
_cache.Set(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
Refresh(session);
|
|
||||||
session.Version++;
|
|
||||||
session.History.Add(new ChangeHistoryEntry
|
|
||||||
{
|
|
||||||
UserAccountId = session.UserAccountId,
|
|
||||||
Target = delta.Target,
|
|
||||||
Ref = delta.Ref,
|
|
||||||
OldValue = oldValue,
|
|
||||||
NewValue = newValue,
|
|
||||||
Version = session.Version
|
|
||||||
});
|
|
||||||
_cache.Set(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
public object BuildState(ReminderDraftSession session)
|
public object BuildState(ReminderDraftSession session)
|
||||||
{
|
{
|
||||||
session.Touch();
|
session.Touch();
|
||||||
return new
|
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
|
||||||
{
|
{
|
||||||
token = session.Token,
|
return new
|
||||||
version = session.Version,
|
|
||||||
remid = session.RemId,
|
|
||||||
isDraft = session.IsDraft,
|
|
||||||
@new = session.New,
|
|
||||||
rem = session.Rem,
|
|
||||||
sums = new
|
|
||||||
{
|
{
|
||||||
amount_total = session.Sums.AmountTotal,
|
token = session.Token,
|
||||||
amount_payed = session.Sums.AmountPayed,
|
version = session.Version,
|
||||||
amount_open = session.Sums.AmountOpen
|
remid = session.RemId,
|
||||||
},
|
isDraft = session.IsDraft,
|
||||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
@new = session.New.DeepClone(),
|
||||||
historyCount = session.History.Count,
|
rem = session.Rem.DeepClone(),
|
||||||
notes = BuildNotes(session)
|
sums = new
|
||||||
};
|
{
|
||||||
|
amount_total = session.Sums.AmountTotal,
|
||||||
|
amount_payed = session.Sums.AmountPayed,
|
||||||
|
amount_open = session.Sums.AmountOpen
|
||||||
|
},
|
||||||
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||||
|
historyCount = session.History.Count,
|
||||||
|
notes = BuildNotes(session)
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
return new { intro, closing };
|
return new { intro, closing };
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||||
|
lock (session.SyncRoot) return session.History.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||||
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
public async Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
var fds = BuildReminderData(session);
|
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
|
||||||
bool change = !string.IsNullOrEmpty(session.RemId);
|
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
|
||||||
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
|
await session.FlushGate.WaitAsync();
|
||||||
if (!string.IsNullOrEmpty(reg.Id))
|
try
|
||||||
{
|
{
|
||||||
session.RemId = reg.Id;
|
FdsReminderData fds;
|
||||||
_cache.Set(session);
|
string remId;
|
||||||
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
lock (session.SyncRoot)
|
||||||
token, reg.Id, change, userAccountId);
|
{
|
||||||
|
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||||
|
remId = session.RemId;
|
||||||
|
}
|
||||||
|
bool change = !string.IsNullOrEmpty(remId);
|
||||||
|
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
|
||||||
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
|
{
|
||||||
|
lock (session.SyncRoot) session.RemId = reg.Id;
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||||
|
token, reg.Id, change, userAccountId);
|
||||||
|
}
|
||||||
|
return new DraftFlushResult<FdsReminderData>(reg, change);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.FlushGate.Release();
|
||||||
}
|
}
|
||||||
return reg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Document? RenderPreview(string token)
|
public Document? RenderPreview(string token)
|
||||||
{
|
{
|
||||||
var session = _cache.Get(token);
|
var session = _cache.Get(token);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
var fds = BuildReminderData(session);
|
FdsReminderData fds;
|
||||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
fds = BuildReminderData(session);
|
||||||
|
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||||
|
}
|
||||||
fds.IsDraft = true;
|
fds.IsDraft = true;
|
||||||
return _reminders.GenerateReminderPdf(fds, draft: true);
|
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||||
}
|
}
|
||||||
@@ -271,7 +301,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
|||||||
["invoices"] = invoices,
|
["invoices"] = invoices,
|
||||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||||
["IsFinal"] = false,
|
["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);
|
return new GenericObjectDictionary(d);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
|
|||||||
return rem;
|
return rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
|
||||||
|
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
|
||||||
|
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
|
||||||
|
/// or one with a stored file — so the guard turns that into an error instead of an empty result
|
||||||
|
/// row the caller would read as "saved".
|
||||||
|
/// </summary>
|
||||||
|
internal static string BuildRegisterReminderSql(bool change)
|
||||||
|
{
|
||||||
|
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||||
|
sqlParts.Add(change
|
||||||
|
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
|
||||||
|
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||||
|
sqlParts.Add(change
|
||||||
|
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
|
||||||
|
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
|
||||||
|
return string.Join("\n", sqlParts);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||||
string userAccountId, DatabaseSecurity dbSec)
|
string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
|
|||||||
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
||||||
};
|
};
|
||||||
|
|
||||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
change = change && !string.IsNullOrEmpty(remId);
|
||||||
if (!change || string.IsNullOrEmpty(remId))
|
if (change) pl.Add(SQL_VarChar("RemId", remId));
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
|
||||||
else
|
|
||||||
pl.Add(SQL_VarChar("RemId", remId));
|
|
||||||
|
|
||||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
|
||||||
Conn, pl, tablenames: new[] { "rem" },
|
Conn, pl, tablenames: new[] { "rem" },
|
||||||
Security: dbSec, options: new FIS_SQLOptions());
|
Security: dbSec, options: new FIS_SQLOptions());
|
||||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||||
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
||||||
|
|
||||||
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||||
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
|
_logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
|
||||||
return reminder;
|
return reminder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +127,8 @@ public class ReminderService : IReminderService
|
|||||||
Address = reminder.InvoiceAddress,
|
Address = reminder.InvoiceAddress,
|
||||||
AdminUser = reminder.UserNameFinalized,
|
AdminUser = reminder.UserNameFinalized,
|
||||||
AdminUserEmail = reminder.UserEmailFinalized,
|
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))
|
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -173,9 +173,9 @@ public class FdsInvoiceData
|
|||||||
{
|
{
|
||||||
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
|
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
|
||||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
AmountParam("@InvoiceBalance", NewValues is null ? 0m : NewValues.getItem("total_gross"), scale: 3),
|
||||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
AmountParam("@InvoiceBalance_net", NewValues is null ? 0m : NewValues.getItem("total_net"), scale: 3),
|
||||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
AmountParam("@InvoiceVAT_net1", vatNet, scale: 3),
|
||||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||||
@@ -185,12 +185,63 @@ public class FdsInvoiceData
|
|||||||
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
||||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
AmountParam("@InvoiceService_net", Sms is null ? 0m : Sms.getItem("tscn"), scale: 2),
|
||||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
AmountParam("@InvoiceService_VAT", Sms is null ? 0m : Sms.getItem("tscvat"), scale: 2),
|
||||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
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>
|
/// <summary>
|
||||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></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>
|
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
|
||||||
public sealed class InvoiceBlock
|
public sealed class InvoiceBlock
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ public class FdsReminderData
|
|||||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||||
|
|
||||||
// -- Computed props from registration -------------------------------------
|
// -- 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 ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? "";
|
||||||
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
|
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
|
||||||
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
|
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,24 @@ public sealed class InvoiceDraftSession
|
|||||||
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
public List<ChangeHistoryEntry> History { get; } = new();
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session lock. The editor fires patches, state fetches and previews concurrently for the
|
||||||
|
/// same token, and the payload (<see cref="Newtonsoft.Json.Linq.JArray"/>/<c>JObject</c>, the
|
||||||
|
/// history list) is not thread-safe — the draft service takes this lock around every read and
|
||||||
|
/// write of the payload, and hands out deep-cloned snapshots for anything serialised later.
|
||||||
|
/// </summary>
|
||||||
|
public object SyncRoot { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="InvId"/>,
|
||||||
|
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
|
||||||
|
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
|
||||||
|
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
|
||||||
|
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
|
||||||
|
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
|
||||||
|
/// </summary>
|
||||||
|
public SemaphoreSlim FlushGate { get; } = new(1, 1);
|
||||||
|
|
||||||
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Fuchs.intranet;
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ public sealed class ReminderDraftSession
|
|||||||
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
public List<ChangeHistoryEntry> History { get; } = new();
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session lock (see <see cref="InvoiceDraftSession.SyncRoot"/>): the draft service takes it
|
||||||
|
/// around every read and write of the non-thread-safe payload, so concurrent patch/state/preview
|
||||||
|
/// requests for the same token cannot interleave.
|
||||||
|
/// </summary>
|
||||||
|
public object SyncRoot { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="RemId"/>,
|
||||||
|
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
|
||||||
|
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
|
||||||
|
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
|
||||||
|
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
|
||||||
|
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
|
||||||
|
/// </summary>
|
||||||
|
public SemaphoreSlim FlushGate { get; } = new(1, 1);
|
||||||
|
|
||||||
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,21 @@ div.modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.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 {
|
> .choicefrm {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,18 @@ $fis.resetPass = function (id, fds) {
|
|||||||
title: $t.rsp || '',
|
title: $t.rsp || '',
|
||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
|
var values = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((qs.npw || '') !== (qs.npwc || '')) {
|
if ((qs.npw || '') !== (qs.npwc || '')) {
|
||||||
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
|
/* The browser blocks every later submit while npw carries a custom error, so it
|
||||||
|
must be cleared as soon as either password field is edited again. */
|
||||||
|
var npw = c.find('input[name="npw"]:first');
|
||||||
|
npw[0].setCustomValidity($t.pnm);
|
||||||
|
c.find('input[name="npw"], input[name="npwc"]').off('input.pnm').on('input.pnm', function () {
|
||||||
|
npw[0].setCustomValidity('');
|
||||||
|
});
|
||||||
|
c.rejectInvalid();
|
||||||
} else {
|
} else {
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
||||||
|
|||||||
@@ -394,6 +394,14 @@ $inv.d = {
|
|||||||
l.rC('freeze');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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')); });
|
$.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)); }
|
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||||
$ocms.dlg(c, {
|
$ocms.dlg(c, {
|
||||||
@@ -403,7 +411,7 @@ $inv.d = {
|
|||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||||
$ocms.postXT({
|
$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');
|
ct.trigger('modal_close');
|
||||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||||
$inv.d.close();
|
$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('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||||
}
|
}
|
||||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1119,7 +1135,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
@@ -1331,7 +1348,7 @@ $inv.rrw = function () {
|
|||||||
} else if (rw.is('.itm.osum') === true) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.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
|
/* 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.',
|
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||||
ivEc: '\nMöchten Sie fortfahren?',
|
ivEc: '\nMöchten Sie fortfahren?',
|
||||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
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',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ let $rep = {
|
|||||||
};
|
};
|
||||||
let scl = function (ev) {
|
let scl = function (ev) {
|
||||||
//console.debug(ev);
|
//console.debug(ev);
|
||||||
let btn = $(this), rx = ev.data;
|
let btn = $(this), rx = ev.data, params = btn.serializeObject();
|
||||||
|
if (params === null) { return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('rep/gct/' + rx.name), data: btn.serializeObject(), success: (response) => {
|
url: $ocms.url('rep/gct/' + rx.name), data: params, success: (response) => {
|
||||||
btn.addClass('selected').siblings().removeClass('selected');
|
btn.addClass('selected').siblings().removeClass('selected');
|
||||||
$('#listframe').rC('fix').aC('hd');
|
$('#listframe').rC('fix').aC('hd');
|
||||||
repfrm.html(response);
|
repfrm.html(response);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ let $spc = {
|
|||||||
let f = c.form[0];
|
let f = c.form[0];
|
||||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||||
|
if (values === null) { c.form.rejectInvalid(); return; }
|
||||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||||
c.result.hide();
|
c.result.hide();
|
||||||
btn.prop('disabled', true).text($spt.working);
|
btn.prop('disabled', true).text($spt.working);
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
||||||
@@ -141,6 +142,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||||
@@ -162,6 +164,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
@@ -79,6 +80,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -647,7 +648,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -720,6 +721,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -734,11 +742,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -747,8 +757,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -781,7 +790,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -840,6 +849,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
|
|||||||
@@ -360,6 +360,8 @@
|
|||||||
};
|
};
|
||||||
if (c.find(':input[required]').length > 0) {
|
if (c.find(':input[required]').length > 0) {
|
||||||
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
||||||
|
}
|
||||||
|
if (isform === true) { /* shown via .invalid whenever serializeObject rejects the form, required fields or not */
|
||||||
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
||||||
}
|
}
|
||||||
if (f('cancel') === true) {
|
if (f('cancel') === true) {
|
||||||
@@ -613,7 +615,9 @@
|
|||||||
} else if (typeof options.success === 'function') {
|
} else if (typeof options.success === 'function') {
|
||||||
cfm = function (e) {
|
cfm = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) }));
|
var values = c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((options.url || '') !== ''){
|
if ((options.url || '') !== ''){
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: options.url, data: qs, success: function (response) {
|
url: options.url, data: qs, success: function (response) {
|
||||||
@@ -657,7 +661,9 @@
|
|||||||
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
||||||
let f_submit = function (e) {
|
let f_submit = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject());
|
var values = c.serializeObject();
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: '/vt/login', data: qs, success: function (response) {
|
url: '/vt/login', data: qs, success: function (response) {
|
||||||
if (((response || {}).login || '') !== '') {
|
if (((response || {}).login || '') !== '') {
|
||||||
|
|||||||
@@ -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 {
|
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;
|
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 {
|
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ let $ict = {
|
|||||||
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||||
ivEc: '\nMöchten Sie fortfahren?',
|
ivEc: '\nMöchten Sie fortfahren?',
|
||||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
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',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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')); });
|
$.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)); }
|
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||||
$ocms.dlg(c, {
|
$ocms.dlg(c, {
|
||||||
@@ -983,7 +993,7 @@ $inv.d = {
|
|||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||||
$ocms.postXT({
|
$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');
|
ct.trigger('modal_close');
|
||||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||||
$inv.d.close();
|
$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('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||||
}
|
}
|
||||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1699,7 +1717,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
@@ -1911,7 +1930,7 @@ $inv.rrw = function () {
|
|||||||
} else if (rw.is('.itm.osum') === true) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.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
|
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -725,6 +725,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -884,7 +885,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -957,6 +958,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -971,11 +979,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -984,8 +994,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -1018,7 +1027,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -1077,6 +1086,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
@@ -2139,6 +2149,8 @@ class NumArray extends Array {
|
|||||||
};
|
};
|
||||||
if (c.find(':input[required]').length > 0) {
|
if (c.find(':input[required]').length > 0) {
|
||||||
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
|
||||||
|
}
|
||||||
|
if (isform === true) { /* shown via .invalid whenever serializeObject rejects the form, required fields or not */
|
||||||
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
|
||||||
}
|
}
|
||||||
if (f('cancel') === true) {
|
if (f('cancel') === true) {
|
||||||
@@ -2392,7 +2404,9 @@ class NumArray extends Array {
|
|||||||
} else if (typeof options.success === 'function') {
|
} else if (typeof options.success === 'function') {
|
||||||
cfm = function (e) {
|
cfm = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) }));
|
var values = c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((options.url || '') !== ''){
|
if ((options.url || '') !== ''){
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: options.url, data: qs, success: function (response) {
|
url: options.url, data: qs, success: function (response) {
|
||||||
@@ -2436,7 +2450,9 @@ class NumArray extends Array {
|
|||||||
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
|
||||||
let f_submit = function (e) {
|
let f_submit = function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject());
|
var values = c.serializeObject();
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: '/vt/login', data: qs, success: function (response) {
|
url: '/vt/login', data: qs, success: function (response) {
|
||||||
if (((response || {}).login || '') !== '') {
|
if (((response || {}).login || '') !== '') {
|
||||||
@@ -2998,9 +3014,18 @@ $fis.resetPass = function (id, fds) {
|
|||||||
title: $t.rsp || '',
|
title: $t.rsp || '',
|
||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
var c = $(this).ldng(1);
|
var c = $(this).ldng(1);
|
||||||
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
|
var values = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (values === null) { c.rejectInvalid(); return; }
|
||||||
|
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
|
||||||
if ((qs.npw || '') !== (qs.npwc || '')) {
|
if ((qs.npw || '') !== (qs.npwc || '')) {
|
||||||
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
|
/* The browser blocks every later submit while npw carries a custom error, so it
|
||||||
|
must be cleared as soon as either password field is edited again. */
|
||||||
|
var npw = c.find('input[name="npw"]:first');
|
||||||
|
npw[0].setCustomValidity($t.pnm);
|
||||||
|
c.find('input[name="npw"], input[name="npwc"]').off('input.pnm').on('input.pnm', function () {
|
||||||
|
npw[0].setCustomValidity('');
|
||||||
|
});
|
||||||
|
c.rejectInvalid();
|
||||||
} else {
|
} else {
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -53,9 +53,10 @@ let $rep = {
|
|||||||
};
|
};
|
||||||
let scl = function (ev) {
|
let scl = function (ev) {
|
||||||
//console.debug(ev);
|
//console.debug(ev);
|
||||||
let btn = $(this), rx = ev.data;
|
let btn = $(this), rx = ev.data, params = btn.serializeObject();
|
||||||
|
if (params === null) { return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('rep/gct/' + rx.name), data: btn.serializeObject(), success: (response) => {
|
url: $ocms.url('rep/gct/' + rx.name), data: params, success: (response) => {
|
||||||
btn.addClass('selected').siblings().removeClass('selected');
|
btn.addClass('selected').siblings().removeClass('selected');
|
||||||
$('#listframe').rC('fix').aC('hd');
|
$('#listframe').rC('fix').aC('hd');
|
||||||
repfrm.html(response);
|
repfrm.html(response);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
let $ct={mdl:"Berichte"},$ccol={};function getMonday(e){var t=(e=new Date(e)).getDay(),a=e.getDate()-t+(0==t?-6:1);return new Date(e.setDate(a))}let $rep={init2:function(e,t){e=e||"inv",t=t||{},$ocms.getScript([],(function(){$rep.init3(e,t)}))},init3:async function(e,t){let a=$("#contentframe").empty(),l=($$.dc("cfrm hd").appendTo(a),$$.dc("init_frm",a));$("#topbar").ocmsmenu([]);$("#activemodule").text($ct.mdl);let c=$$.dc("repfrm",l),i=(new Date,[(async()=>{if(await $fis.getAuth("fds_reports")>0){l.IN((function(){}));let e=$("#listframe").empty().aC("fix").rC("hd"),t=function(e){let t=$(this);if((e.params||[]).length>0){let a=$$.dc("ipf",t).click((function(e){e.stopPropagation()}));$.each(e.params,((e,t)=>{let l,c=$$.dc("ig",a).append($$.lbl(t.name));""===(t.valuelist||"")&&(l=$$.i({name:t.name,placeholder:t.placeholder}).appendTo(c),""!==(t.default||"")&&l.val(t.default))}))}},a=function(e){let t=$(this),a=e.data;$ocms.postXT({url:$ocms.url("rep/gct/"+a.name),data:t.serializeObject(),success:e=>{t.addClass("selected").siblings().removeClass("selected"),$("#listframe").rC("fix").aC("hd"),c.html(e)},datatype:"html"})};$ocms.postXT({url:$ocms.url("rep/catalog"),data:{},success:l=>{$.each(l.reports,((l,c)=>{let i=$$.dc("repitm",e).text(c.label).click(c,a);t.call(i,c)}))}})}})(),new Promise(((e,t)=>{}))]);await Promise.all(i)}},$$rep={init2:$rep.init2,auth:{}};export default $$rep;
|
let $ct={mdl:"Berichte"},$ccol={};function getMonday(e){var t=(e=new Date(e)).getDay(),a=e.getDate()-t+(0==t?-6:1);return new Date(e.setDate(a))}let $rep={init2:function(e,t){e=e||"inv",t=t||{},$ocms.getScript([],(function(){$rep.init3(e,t)}))},init3:async function(e,t){let a=$("#contentframe").empty(),l=($$.dc("cfrm hd").appendTo(a),$$.dc("init_frm",a));$("#topbar").ocmsmenu([]);$("#activemodule").text($ct.mdl);let c=$$.dc("repfrm",l),i=(new Date,[(async()=>{if(await $fis.getAuth("fds_reports")>0){l.IN((function(){}));let e=$("#listframe").empty().aC("fix").rC("hd"),t=function(e){let t=$(this);if((e.params||[]).length>0){let a=$$.dc("ipf",t).click((function(e){e.stopPropagation()}));$.each(e.params,((e,t)=>{let l,c=$$.dc("ig",a).append($$.lbl(t.name));""===(t.valuelist||"")&&(l=$$.i({name:t.name,placeholder:t.placeholder}).appendTo(c),""!==(t.default||"")&&l.val(t.default))}))}},a=function(e){let t=$(this),a=e.data,l=t.serializeObject();null!==l&&$ocms.postXT({url:$ocms.url("rep/gct/"+a.name),data:l,success:e=>{t.addClass("selected").siblings().removeClass("selected"),$("#listframe").rC("fix").aC("hd"),c.html(e)},datatype:"html"})};$ocms.postXT({url:$ocms.url("rep/catalog"),data:{},success:l=>{$.each(l.reports,((l,c)=>{let i=$$.dc("repitm",e).text(c.label).click(c,a);t.call(i,c)}))}})}})(),new Promise(((e,t)=>{}))]);await Promise.all(i)}},$$rep={init2:$rep.init2,auth:{}};export default $$rep;
|
||||||
@@ -160,6 +160,8 @@ let $ict = {
|
|||||||
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||||
ivEc: '\nMöchten Sie fortfahren?',
|
ivEc: '\nMöchten Sie fortfahren?',
|
||||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
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',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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')); });
|
$.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)); }
|
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||||
$ocms.dlg(c, {
|
$ocms.dlg(c, {
|
||||||
@@ -964,7 +974,7 @@ $inv.d = {
|
|||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||||
$ocms.postXT({
|
$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');
|
ct.trigger('modal_close');
|
||||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||||
$inv.d.close();
|
$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('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||||
}
|
}
|
||||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
}, error: (xhr) => {
|
||||||
|
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
o.c.trigger('modal_close');
|
o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}, error: () => {
|
}, error: (xhr) => {
|
||||||
if (o) { o.c.trigger('modal_close'); }
|
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
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
backend maps to the eRechnung service date/period. */
|
backend maps to the eRechnung service date/period. */
|
||||||
$inv.provToIso = function (dmy) {
|
$inv.provToIso = function (dmy) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dmy || '')) { return dmy; }
|
||||||
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
};
|
};
|
||||||
$inv.eProvisionPeriod = function (ev) {
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
// Accept ISO values saved by older editor versions as well as German dates.
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}|\d{4}-\d{2}-\d{2}/g) || [];
|
||||||
let flds = [
|
let flds = [
|
||||||
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
@@ -1680,7 +1698,8 @@ $inv.eProvisionPeriod = function (ev) {
|
|||||||
title: 'Leistungsdatum / -zeitraum',
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
addcontent: hint,
|
addcontent: hint,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
let germanDate = (iso) => iso ? fdt(iso + 'T00:00:00', 'dd.MM.yyyy') : '';
|
||||||
|
let v = germanDate(res.von), b = germanDate(res.bis);
|
||||||
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
@@ -1892,7 +1911,7 @@ $inv.rrw = function () {
|
|||||||
} else if (rw.is('.itm.osum') === true) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.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
|
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,6 +82,7 @@ let $spc = {
|
|||||||
let f = c.form[0];
|
let f = c.form[0];
|
||||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||||
|
if (values === null) { c.form.rejectInvalid(); return; }
|
||||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||||
c.result.hide();
|
c.result.hide();
|
||||||
btn.prop('disabled', true).text($spt.working);
|
btn.prop('disabled', true).text($spt.working);
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}})))}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
|
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});null!==c?("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}}))):e.form.rejectInvalid()}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
|
||||||
@@ -152,6 +152,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
url: $ocms.url('usr/create'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
|
||||||
@@ -187,6 +188,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
|
||||||
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
|
||||||
@@ -208,6 +210,7 @@ let $usr = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usr/email'), data: qs, success: function () {
|
url: $ocms.url('usr/email'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
@@ -99,6 +100,7 @@ let $ugp = {
|
|||||||
submit: function (e) {
|
submit: function (e) {
|
||||||
let c = $(this).ldng(1);
|
let c = $(this).ldng(1);
|
||||||
let qs = c.serializeObject(true, { typedvalues: true });
|
let qs = c.serializeObject(true, { typedvalues: true });
|
||||||
|
if (qs === null) { c.rejectInvalid(); return; }
|
||||||
$ocms.postXT({
|
$ocms.postXT({
|
||||||
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
|
||||||
c.trigger('modal_close');
|
c.trigger('modal_close');
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
let $ugt={mdl:"Gruppenverwaltung",rel:"Neu Laden",col_name:"Name",loadfail:"Die Gruppenliste konnte nicht geladen werden.",empty:"Es sind keine Benutzergruppen vorhanden.",newgroup:"Neue Gruppe",newgroup_btn:"Anlegen",name_field:"Gruppenname",rename:"Gruppe umbenennen",rename_btn:"Speichern",delete:"Gruppe löschen",delete_confirm:'Soll die Gruppe "{0}" wirklich gelöscht werden? Die Mitgliedschaft aller Benutzer in dieser Gruppe geht dabei verloren.',err:{save_failed:"Die Gruppe konnte nicht gespeichert werden.",delete_failed:"Die Gruppe konnte nicht gelöscht werden."},err_generic:"Die Änderung konnte nicht gespeichert werden."},$ugp={init2:function(){$ocms.getScript([],(function(){$ugp.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($ugt.mdl),$ugp.eM(),$ugp.renderLst()},eM:function(e){let n=[{lbl:$ugt.newgroup_btn,fnc:$ugp.createDlg}];return!0===bool(e,!1)&&n.push({lbl:$ugt.rel,fnc:$ugp.renderLst}),$("#topbar").ocmsmenu(n)},iMn:function(e){return $("#topbar").ocmsmenu([{lbl:$ugt.rename,fnc:function(){$ugp.renameDlg(e)}},{lbl:$ugt.delete,fnc:function(){$ugp.deleteGroup(e)}}])},renderLst:function(){let e=$fis.frm_list(!0,!0).ldng(1),n=$$.dc("ugpfrm",e);$ugp.eM(!0),$ocms.postXT({url:$ocms.url("usrgrp/list"),success:function(e){n.empty();let t=$$.dc("ugp_grid",n),r=$$.dc("ugp_row ugp_row_hd",t);$$.dc("ugp_col_name",r).text($ugt.col_name),0!==(e.groups||[]).length?$.each(e.groups||[],(function(e,n){let r=$$.dc("ugp_row",t,{id:"ugp"+n.usergroup_id});$$.dc("ugp_col_name",r).text(n.name||""),r.click((function(){r.toggleClass("selected").siblings().rC("selected"),!0===r.is(".selected")?$ugp.iMn(n):$ugp.eM(!0)}))})):$$.dc("ugp_empty",t).text($ugt.empty)},error:function(){n.empty(),$$.dc("ugp_err",n).text($ugt.loadfail)},complete:function(){e.ldng(0)}})},createDlg:function(){$ocms.dlgform([{name:"name",label:$ugt.name_field,type:"string",required:!0}],{title:$ugt.newgroup,button:$ugt.newgroup_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usrgrp/create"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}})}})},renameDlg:function(e){$ocms.dlgform([{name:"usergroup_id",type:"hidden",value:e.usergroup_id},{name:"name",label:$ugt.name_field,type:"string",required:!0,value:e.name}],{title:$ugt.rename,button:$ugt.rename_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});$ocms.postXT({url:$ocms.url("usrgrp/rename"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}})}})},deleteGroup:function(e){confirm(string($ugt.delete_confirm,[e.name||""]))&&$ocms.postXT({url:$ocms.url("usrgrp/delete"),data:{usergroup_id:e.usergroup_id},success:function(){$ugp.eM(!0)},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){$ugp.renderLst()}})}},$$ugp={init2:$ugp.init2,auth:{}};export default $$ugp;
|
let $ugt={mdl:"Gruppenverwaltung",rel:"Neu Laden",col_name:"Name",loadfail:"Die Gruppenliste konnte nicht geladen werden.",empty:"Es sind keine Benutzergruppen vorhanden.",newgroup:"Neue Gruppe",newgroup_btn:"Anlegen",name_field:"Gruppenname",rename:"Gruppe umbenennen",rename_btn:"Speichern",delete:"Gruppe löschen",delete_confirm:'Soll die Gruppe "{0}" wirklich gelöscht werden? Die Mitgliedschaft aller Benutzer in dieser Gruppe geht dabei verloren.',err:{save_failed:"Die Gruppe konnte nicht gespeichert werden.",delete_failed:"Die Gruppe konnte nicht gelöscht werden."},err_generic:"Die Änderung konnte nicht gespeichert werden."},$ugp={init2:function(){$ocms.getScript([],(function(){$ugp.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($ugt.mdl),$ugp.eM(),$ugp.renderLst()},eM:function(e){let n=[{lbl:$ugt.newgroup_btn,fnc:$ugp.createDlg}];return!0===bool(e,!1)&&n.push({lbl:$ugt.rel,fnc:$ugp.renderLst}),$("#topbar").ocmsmenu(n)},iMn:function(e){return $("#topbar").ocmsmenu([{lbl:$ugt.rename,fnc:function(){$ugp.renameDlg(e)}},{lbl:$ugt.delete,fnc:function(){$ugp.deleteGroup(e)}}])},renderLst:function(){let e=$fis.frm_list(!0,!0).ldng(1),n=$$.dc("ugpfrm",e);$ugp.eM(!0),$ocms.postXT({url:$ocms.url("usrgrp/list"),success:function(e){n.empty();let t=$$.dc("ugp_grid",n),r=$$.dc("ugp_row ugp_row_hd",t);$$.dc("ugp_col_name",r).text($ugt.col_name),0!==(e.groups||[]).length?$.each(e.groups||[],(function(e,n){let r=$$.dc("ugp_row",t,{id:"ugp"+n.usergroup_id});$$.dc("ugp_col_name",r).text(n.name||""),r.click((function(){r.toggleClass("selected").siblings().rC("selected"),!0===r.is(".selected")?$ugp.iMn(n):$ugp.eM(!0)}))})):$$.dc("ugp_empty",t).text($ugt.empty)},error:function(){n.empty(),$$.dc("ugp_err",n).text($ugt.loadfail)},complete:function(){e.ldng(0)}})},createDlg:function(){$ocms.dlgform([{name:"name",label:$ugt.name_field,type:"string",required:!0}],{title:$ugt.newgroup,button:$ugt.newgroup_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});null!==t?$ocms.postXT({url:$ocms.url("usrgrp/create"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}}):n.rejectInvalid()}})},renameDlg:function(e){$ocms.dlgform([{name:"usergroup_id",type:"hidden",value:e.usergroup_id},{name:"name",label:$ugt.name_field,type:"string",required:!0,value:e.name}],{title:$ugt.rename,button:$ugt.rename_btn,submit:function(e){let n=$(this).ldng(1),t=n.serializeObject(!0,{typedvalues:!0});null!==t?$ocms.postXT({url:$ocms.url("usrgrp/rename"),data:t,success:function(){n.trigger("modal_close")},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){n.ldng(0),$ugp.renderLst()}}):n.rejectInvalid()}})},deleteGroup:function(e){confirm(string($ugt.delete_confirm,[e.name||""]))&&$ocms.postXT({url:$ocms.url("usrgrp/delete"),data:{usergroup_id:e.usergroup_id},success:function(){$ugp.eM(!0)},error:function(e){let n=((e||{}).responseJSON||{}).error;alert($ugt.err[n]||$ugt.err_generic)},complete:function(){$ugp.renderLst()}})}},$$ugp={init2:$ugp.init2,auth:{}};export default $$ugp;
|
||||||
@@ -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 {
|
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;
|
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 {
|
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ $ocms.postXT = function (options) {
|
|||||||
}
|
}
|
||||||
if (options.form instanceof jQuery) {
|
if (options.form instanceof jQuery) {
|
||||||
options.data = options.form.serializeObject();
|
options.data = options.form.serializeObject();
|
||||||
|
if (options.data === null) { options.form.rejectInvalid(); return; }
|
||||||
options.contentType = 'form-data';
|
options.contentType = 'form-data';
|
||||||
} else if (options.lzw instanceof jQuery) {
|
} else if (options.lzw instanceof jQuery) {
|
||||||
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
|
||||||
@@ -701,7 +702,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (rcheckableType.test(type) === true) {
|
if (rcheckableType.test(type) === true) {
|
||||||
val = e.checked ? (val !== '' ? val : 'true') : '';
|
val = e.checked ? (val !== '' ? val : 'true') : '';
|
||||||
}
|
}
|
||||||
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1) {
|
if (dtf[0].substr(0, 4) === 'date' && dtf.length > 1 && (val !== '' || req)) {
|
||||||
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
val = parseDt(val, dtf.slice(1).join(':')); /* should result in date object */
|
||||||
if (typeof val === 'boolean') {
|
if (typeof val === 'boolean') {
|
||||||
val = null;
|
val = null;
|
||||||
@@ -774,6 +775,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
c.toggleClass('invalid', !valid);
|
c.toggleClass('invalid', !valid);
|
||||||
return valid ? result : null;
|
return valid ? result : null;
|
||||||
};
|
};
|
||||||
|
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
|
||||||
|
browser point at the offending field, instead of submitting partial data. */
|
||||||
|
$.fn.rejectInvalid = function () {
|
||||||
|
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
|
||||||
|
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
$.fn.sendForm = function (url, success, options) {
|
$.fn.sendForm = function (url, success, options) {
|
||||||
var form = $(this);
|
var form = $(this);
|
||||||
options = options || {};
|
options = options || {};
|
||||||
@@ -788,11 +796,13 @@ $ocms.vbl_send = function (ev) {
|
|||||||
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
|
||||||
$ocms.failure.call(this, jqXHR);
|
$ocms.failure.call(this, jqXHR);
|
||||||
}
|
}
|
||||||
}, complete: function () {
|
}, complete: function (jqXHR) {
|
||||||
form.ldng(0);
|
form.ldng(0);
|
||||||
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var formdata = form.serializeObject();
|
||||||
|
if (formdata === null) { form.rejectInvalid(); return; }
|
||||||
var fle = form.find('input[type="file"]');
|
var fle = form.find('input[type="file"]');
|
||||||
postoptions.data = new FormData();
|
postoptions.data = new FormData();
|
||||||
if (fle.length > 0) {
|
if (fle.length > 0) {
|
||||||
@@ -801,8 +811,7 @@ $ocms.vbl_send = function (ev) {
|
|||||||
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var formdata = form.serializeObject();
|
$.each(formdata, function (di, dx) {
|
||||||
$.each(formdata || {}, function (di, dx) {
|
|
||||||
postoptions.data.append(di, dx);
|
postoptions.data.append(di, dx);
|
||||||
});
|
});
|
||||||
form.ldng();
|
form.ldng();
|
||||||
@@ -835,7 +844,7 @@ $ocms.login = {
|
|||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
var qs = f.serializeObject();
|
var qs = f.serializeObject();
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
if (qs === null) { f.rejectInvalid(); return false; }
|
||||||
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
|
||||||
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
|
||||||
alert($t.l16);
|
alert($t.l16);
|
||||||
@@ -894,6 +903,7 @@ $ocms.login = {
|
|||||||
d.find('form').submit(function (e) {
|
d.find('form').submit(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
|
||||||
|
if (qs === null) { $(this).rejectInvalid(); return false; }
|
||||||
$ocms.postXT.call(this, {
|
$ocms.postXT.call(this, {
|
||||||
url: $ocms.url(u), data: qs, complete: function () {
|
url: $ocms.url(u), data: qs, complete: function () {
|
||||||
if (s2) {
|
if (s2) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
|
}
|
||||||
@@ -294,10 +294,10 @@ public class FdsMfrClient : IDisposable
|
|||||||
dtwa.CommandAfterError = new SqlCommand(
|
dtwa.CommandAfterError = new SqlCommand(
|
||||||
$"DROP TABLE IF EXISTS [{fallbackTable}]; SELECT * INTO [{fallbackTable}] FROM {dtwa.DestinationTableName};")
|
$"DROP TABLE IF EXISTS [{fallbackTable}]; SELECT * INTO [{fallbackTable}] FROM {dtwa.DestinationTableName};")
|
||||||
{ CommandTimeout = MergeCommandTimeoutSeconds };
|
{ CommandTimeout = MergeCommandTimeoutSeconds };
|
||||||
dtwa.OnCommandAfterError += (sender, caex) =>
|
// Logged after DoSubmit returns so a truncation can be explained
|
||||||
_logger.LogError(caex,
|
// (column + length + Id) with an awaited width lookup.
|
||||||
"Update__entitytable: merge into {Table} failed ({Entity}, setid={SetId}, rows={Rows}) — staged batch kept in {FallbackTable}",
|
Exception? mergeError = null;
|
||||||
tbl.TableName, thisEntityName, setId, trc, fallbackTable);
|
dtwa.OnCommandAfterError += (sender, caex) => mergeError = caex;
|
||||||
dtwa.OnError += (sender, exc, message) =>
|
dtwa.OnError += (sender, exc, message) =>
|
||||||
_logger.LogError(exc,
|
_logger.LogError(exc,
|
||||||
"Update__entitytable: DatatableWriterAsync submission failed for {Table} ({Entity}, setid={SetId}) — {Message}",
|
"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)
|
new("@tgtid", entityId.Length > 0 ? entityId[0].ToString() : (object)DBNull.Value)
|
||||||
});
|
});
|
||||||
dtwa.DoSubmit();
|
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)
|
catch (Exception dsex)
|
||||||
{
|
{
|
||||||
@@ -341,6 +350,26 @@ public class FdsMfrClient : IDisposable
|
|||||||
return true;
|
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)
|
private static void SetRowValue(DataRow row, string key, object value)
|
||||||
{
|
{
|
||||||
if (!row.Table.Columns.Contains(key)) return;
|
if (!row.Table.Columns.Contains(key)) return;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.0" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -4,6 +4,47 @@
|
|||||||
<name>Fuchs_DataService</name>
|
<name>Fuchs_DataService</name>
|
||||||
</assembly>
|
</assembly>
|
||||||
<members>
|
<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">
|
<member name="T:fds.FdsSqlOptions">
|
||||||
<summary>
|
<summary>
|
||||||
Logs every SQL error through the worker's structured <see cref="T:Microsoft.Extensions.Logging.ILogger"/> alone — which, when
|
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.
|
(empty) shape is loaded. Empty when the entity table itself is unknown.
|
||||||
</summary>
|
</summary>
|
||||||
</member>
|
</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">
|
<member name="T:fds.FdsConfig">
|
||||||
<summary>
|
<summary>
|
||||||
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
|
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
<SqlServerVerification>False</SqlServerVerification>
|
<SqlServerVerification>False</SqlServerVerification>
|
||||||
<IncludeCompositeObjects>True</IncludeCompositeObjects>
|
<IncludeCompositeObjects>True</IncludeCompositeObjects>
|
||||||
<TargetDatabaseSet>True</TargetDatabaseSet>
|
<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>
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||||
<OutputPath>bin\Release\</OutputPath>
|
<OutputPath>bin\Release\</OutputPath>
|
||||||
@@ -148,6 +152,7 @@
|
|||||||
<Build Include="Security\fds_rwe.sql" />
|
<Build Include="Security\fds_rwe.sql" />
|
||||||
<Build Include="Security\fuchs_web.sql" />
|
<Build Include="Security\fuchs_web.sql" />
|
||||||
<Build Include="Security\fuchs_fds.sql" />
|
<Build Include="Security\fuchs_fds.sql" />
|
||||||
|
<Build Include="Security\Logins.sql" />
|
||||||
<Build Include="dbo\Functions\vat_val.sql" />
|
<Build Include="dbo\Functions\vat_val.sql" />
|
||||||
<Build Include="dbo\Functions\strings_replaceSpecialCharsInHTML.sql" />
|
<Build Include="dbo\Functions\strings_replaceSpecialCharsInHTML.sql" />
|
||||||
<Build Include="dbo\Functions\strings_removeLeading0_255.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_InvoiceIDs_mfr.sql" />
|
||||||
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
|
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
|
||||||
<Build Include="dbo\Functions\fds__fn_invoice_id.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_invoice_customerid.sql" />
|
||||||
<Build Include="dbo\Functions\fds__fn_IntermediateIsAllocatedToOther.sql" />
|
<Build Include="dbo\Functions\fds__fn_IntermediateIsAllocatedToOther.sql" />
|
||||||
<Build Include="dbo\Functions\fds__fn_DocumentName.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_simple.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createStorno_copy.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createStorno_copy.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createReminder.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createReminder.sql" />
|
||||||
|
<Build Include="dbo\Stored Procedures\fds__setReminder.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice_ServiceRequest.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice_ServiceRequest.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice_Details.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice_Details.sql" />
|
||||||
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
|
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
|
||||||
@@ -411,5 +421,16 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="Scripts\2026-09-16_fis_widgets_set_based_payment_amounts.sql" />
|
<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>
|
||||||
</Project>
|
<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]);
|
||||||
@@ -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)
|
-- INLINE = OFF: scalar UDF inlining of this @@DATEFIRST arithmetic (reached via date_add /
|
||||||
RETURNS DATE AS
|
-- date_addcustom) killed dashboard widget queries with error 596 ("session is in the kill state").
|
||||||
|
CREATE FUNCTION[dbo].[AddBusinessDays](@Date DATE,@n INT)
|
||||||
|
RETURNS DATE
|
||||||
|
WITH INLINE = OFF
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @d INT,@f INT,@DW INT;
|
DECLARE @d INT,@f INT,@DW INT;
|
||||||
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
|
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
-- Author: <Author,,Name>
|
-- Author: <Author,,Name>
|
||||||
-- Create date: <Create Date, ,>
|
-- Create date: <Create Date, ,>
|
||||||
-- Description: <Description, ,>
|
-- Description: <Description, ,>
|
||||||
|
-- INLINE = OFF: scalar UDF inlining of this IF/nested-UDF body (-> AddBusinessDays)
|
||||||
|
-- killed the dashboard widget queries with error 596 ("session is in the kill
|
||||||
|
-- state", class 21). Called with constant arguments, so per-row cost is negligible.
|
||||||
-- =============================================
|
-- =============================================
|
||||||
CREATE FUNCTION [dbo].[date_add]
|
CREATE FUNCTION [dbo].[date_add]
|
||||||
(
|
(
|
||||||
@@ -10,6 +13,7 @@ CREATE FUNCTION [dbo].[date_add]
|
|||||||
,@shift_to_first bit = 1
|
,@shift_to_first bit = 1
|
||||||
)
|
)
|
||||||
RETURNS date
|
RETURNS date
|
||||||
|
WITH INLINE = OFF
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @Ret date = @date;
|
DECLARE @Ret date = @date;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
-- Author: <Author,,Name>
|
-- Author: <Author,,Name>
|
||||||
-- Create date: <Create Date, ,>
|
-- Create date: <Create Date, ,>
|
||||||
-- Description: <Description, ,>
|
-- Description: <Description, ,>
|
||||||
|
-- INLINE = OFF: scalar UDF inlining (nested -> AddBusinessDays) killed the dashboard
|
||||||
|
-- widget queries with error 596 ("session is in the kill state", class 21).
|
||||||
-- =============================================
|
-- =============================================
|
||||||
CREATE FUNCTION [dbo].[date_addcustom]
|
CREATE FUNCTION [dbo].[date_addcustom]
|
||||||
(
|
(
|
||||||
@@ -9,6 +11,7 @@ CREATE FUNCTION [dbo].[date_addcustom]
|
|||||||
,@phrase varchar(10)
|
,@phrase varchar(10)
|
||||||
)
|
)
|
||||||
RETURNS date
|
RETURNS date
|
||||||
|
WITH INLINE = OFF
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @returndate date, @numpart integer = TRY_PARSE(left(@phrase, patindex('%[^0-9]%', @phrase + '.') - 1) as integer), @defpart varchar(10) = LOWER(RIGHT(@phrase, LEN(@phrase) - patindex('%[^0-9]%', @phrase + '.') + 1));
|
DECLARE @returndate date, @numpart integer = TRY_PARSE(left(@phrase, patindex('%[^0-9]%', @phrase + '.') - 1) as integer), @defpart varchar(10) = LOWER(RIGHT(@phrase, LEN(@phrase) - patindex('%[^0-9]%', @phrase + '.') + 1));
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ CREATE FUNCTION [dbo].[fds__fn_IntermediateIsAllocatedToOther]
|
|||||||
RETURNS bit
|
RETURNS bit
|
||||||
AS
|
AS
|
||||||
BEGIN
|
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
|
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;
|
THEN 1 ELSE 0 END;
|
||||||
|
|
||||||
RETURN @isA;
|
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 (
|
WITH inv as (
|
||||||
SELECT
|
SELECT
|
||||||
[Dateofcreation] = i.[DateFinalized]
|
[Dateofcreation] = [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])
|
||||||
,i.[InvoiceBalance]
|
,i.[InvoiceBalance]
|
||||||
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
|
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
|
||||||
FROM [dbo].[fds__invoices] as i
|
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;
|
SELECT TOP(1) @ret = FORMAT( SUM( ISNULL(inv.[InvoiceBalanceNetto],0) ) * 0.001, '#,0 k€', 'de') FROM inv;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ BEGIN
|
|||||||
DECLARE @groups TABLE([usergroup_id] varchar(5));
|
DECLARE @groups TABLE([usergroup_id] varchar(5));
|
||||||
INSERT INTO @groups SELECT * from STRING_SPLIT(@usergroup_id,',');
|
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 @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_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]
|
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)
|
SELECT TOP(1)
|
||||||
o.*
|
o.*
|
||||||
, [InvoiceId] = inv.[InvoiceId]
|
, [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)
|
, [hasFile] = CAST ( CASE WHEN o.[file] is null THEN 0 ELSE 1 END as bit)
|
||||||
, [UserNameFinalized] = [dbo].[fis_admin_getUserName_byID](o.[UserFinalized])
|
, [UserNameFinalized] = [dbo].[fis_admin_getUserName_byID](o.[UserFinalized])
|
||||||
, [UserEmailFinalized] = [dbo].[fis_admin_getUserEmail_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));
|
DECLARE @inv TABLE([Id] varchar(20), [InvoiceID] varchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit, [file_guid] varchar(36));
|
||||||
INSERT INTO @inv
|
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]
|
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
|
SELECT
|
||||||
inv.[Id],inv.[InvoiceID],inv.[DocumentName],inv.[fds]
|
inv.[Id],inv.[InvoiceID],inv.[DocumentName],inv.[fds]
|
||||||
@@ -78,15 +83,15 @@ BEGIN
|
|||||||
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
|
,[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])
|
,[customer] = (SELECT TOP(1) [ExternalId] FROM [dbo].[mfr__companies] as c WHERE c.[Id] = iv.[CustomerId])
|
||||||
,[contra_account] = ca.[account]
|
,[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])
|
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
|
||||||
,[DateOfCreation] = iv.[DateCreated]
|
,[DateOfCreation] = i.[DateOfCreation]
|
||||||
,[file_guid]
|
,[file_guid] = iv.[file_guid]
|
||||||
from [dbo].[fds__invoices] as iv
|
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 [dbo].[fds__invoice_details] as d on iv.[Id] = d.[InvId]
|
||||||
LEFT JOIN sc on iv.[id] = sc.[invoice.Id]
|
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
|
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
|
select
|
||||||
[Umsatz (ohne Soll/Haben-Kz)] = ABS([balance])--CAST(FORMAT(ABS([balance]), '0.00', 'de') as varchar(13))
|
[Umsatz (ohne Soll/Haben-Kz)] = ABS([balance])--CAST(FORMAT(ABS([balance]), '0.00', 'de') as varchar(13))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user