feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s

- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
This commit is contained in:
Stefan
2026-09-30 16:56:08 +02:00
parent 7357f60120
commit c741535aec
79 changed files with 2739 additions and 143 deletions
+3
View File
@@ -13,6 +13,7 @@
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -61,6 +62,8 @@
## Database
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
+3
View File
@@ -15,6 +15,7 @@
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -49,6 +50,8 @@
## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
+3
View File
@@ -15,6 +15,7 @@
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
@@ -49,6 +50,8 @@
## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -173,6 +173,62 @@ public class AzureBlobStorageServiceTests
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
}
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
[Fact]
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
}
[Fact]
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
{
var (service, _, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("network timeout"));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
Assert.Null(result);
}
[Fact]
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
}
[Theory]
[InlineData(false)] // storage error
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
{
var (service, _, blob) = CreateMockedClientChain();
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
}
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
[Fact]
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
/// </summary>
public class DatevOverviewCommandTests
{
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
private static readonly Regex ParamDecl = new(
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
RegexOptions.Compiled);
private static readonly Regex ExecArg = new(
@"(?<name>@\w+)\s*(?<named>=)?",
RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
private static List<(string Name, bool HasDefault)> ProcParameters()
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
return ParamDecl.Matches(sql[start..end])
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
.ToList();
}
/// <summary>
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
/// does not declare, and required (no default) parameters that are not supplied.
/// </summary>
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
{
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var m = ExecArg.Match(part);
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
string name = m.Groups["name"].Value;
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
problems.Add($"unknown parameter {name}");
supplied.Add(name);
}
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
[Fact]
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
{
var ps = ProcParameters();
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
}
[Fact]
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
[Theory]
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
public void BindingProblems_MisboundCommand_IsReported(string execSql)
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
[Fact]
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using fds;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
/// batch holding such a row was rejected with the column-less message 8152. Covers the
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
/// </summary>
public class FdsColumnWidthsTests
{
private const long LongNoteServiceObjectId = 38722404355;
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
{
var t = new DataTable("mfr__serviceobjects");
t.Columns.Add("setid", typeof(string));
t.Columns.Add("Id", typeof(long));
t.Columns.Add("Name", typeof(string));
t.Columns.Add("Note", typeof(string));
t.Columns.Add("CustomValues", typeof(string));
t.Columns.Add("Version", typeof(int));
foreach (var (id, note) in rows)
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
return t;
}
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
// ── IsTruncationError ────────────────────────────────────────────────────
[Theory]
[InlineData(8152, true)] // classic "String or binary data would be truncated."
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
[InlineData(2627, false)] // PK violation
[InlineData(515, false)] // NULL into NOT NULL
[InlineData(8672, false)] // MERGE duplicate source row
[InlineData(0, false)]
[InlineData(-2, false)] // client timeout
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
{
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
}
[Fact]
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
{
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
}
// ── WidthsSql ────────────────────────────────────────────────────────────
[Fact]
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
{
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
}
// ── ToWidthMap ───────────────────────────────────────────────────────────
private static DataTable WidthRows(params (object Name, object Max)[] rows)
{
var t = new DataTable();
t.Columns.Add("columnname", typeof(object));
t.Columns.Add("maxchars", typeof(object));
foreach (var (n, m) in rows) t.Rows.Add(n, m);
return t;
}
[Fact]
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
Assert.Equal(255, map["note"]);
Assert.Equal(5, map["SETID"]);
}
[Fact]
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
Assert.Equal(255, Assert.Single(map).Value);
}
[Fact]
public void ToWidthMap_InvalidRows_AreSkipped()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
Assert.Equal(new[] { "Ok" }, map.Keys);
}
[Fact]
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
var wrong = new DataTable();
wrong.Columns.Add("name");
wrong.Rows.Add("Note");
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
}
// ── FindOverflows ────────────────────────────────────────────────────────
[Theory]
[InlineData(0, false)]
[InlineData(254, false)]
[InlineData(255, false)] // exactly the limit fits
[InlineData(256, true)] // one over is rejected by SQL Server
[InlineData(753, true)] // the production ServiceObject note
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
{
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
if (!overflows) { Assert.Empty(result); return; }
var o = Assert.Single(result);
Assert.Equal("Note", o.Column);
Assert.Equal(255, o.MaxAllowed);
Assert.Equal(length, o.LongestLength);
Assert.Equal(1, o.RowsOver);
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
{
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
Assert.Equal(2, o.RowsOver);
Assert.Equal(539, o.LongestLength);
Assert.Equal(3L, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
{
var batch = ServiceObjectBatch((7, new string('n', 300)));
batch.Rows[0]["setid"] = "toolong";
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
}
[Fact]
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
{
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
}
[Fact]
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
{
var batch = ServiceObjectBatch((123456789012, "x"));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
}
[Fact]
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
}
[Fact]
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
{
var t = new DataTable("mfr__*PartnerSet");
t.Columns.Add("Property", typeof(string));
t.Rows.Add(new string('p', 60));
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
Assert.Null(o.LongestRowId);
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
}
// ── Describe ─────────────────────────────────────────────────────────────
[Fact]
public void Describe_NoOverflow_SaysNoneDetected()
{
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
}
[Fact]
public void Describe_Overflows_NameColumnLengthLimitAndId()
{
var text = FdsColumnWidths.Describe(new[]
{
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
});
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
}
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
private static readonly Regex ColumnDecl = new(
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
if (onlyWithin != null)
{
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
}
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in ColumnDecl.Matches(sql))
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
Assert.NotEmpty(map); // the regex still understands the file
return map;
}
public static IEnumerable<object[]> NoteCarriers() => new[]
{
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
new object[] { @"Tables\mfr__companies.sql", null! },
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
};
[Theory]
[MemberData(nameof(NoteCarriers))]
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
{
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
}
[Theory]
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
{
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
}
[Fact]
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
{
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
widths["Note"] = 255;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
}
}
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
/// </summary>
public class InvoiceDatesTests
{
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
{
["DateFinalized"] = finalized,
["DateCreated"] = created
});
[Fact]
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
=> Assert.Equal(new DateOnly(2026, 9, 30),
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
=> Assert.Equal(new DateOnly(2026, 9, 2),
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
=> Assert.Equal(new DateOnly(2026, 3, 1),
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
[Theory]
// UTC late evening is already the next German day (summer +2h, winter +1h)
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
// backdated invoices are stored at 12:00 UTC and stay on their day
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
=> Assert.Equal(DateOnly.Parse(expected),
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
[Theory]
[InlineData("2026-09-30", 2026, 9, 30)]
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NULL")]
[InlineData("30.09.2026")]
[InlineData("2026-02-30")]
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
[InlineData("TRUE", true)]
[InlineData("on", true)]
[InlineData("0", false)]
[InlineData("false", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("yes please", false)]
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
[Theory]
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
[Fact]
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
// the number alone never arrives without a message; a row with no exception is a success
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
[Theory]
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
}
+32
View File
@@ -0,0 +1,32 @@
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
/// </summary>
public class InvoiceItemReferenceTests
{
[Theory]
[InlineData("XH6EH", "XH6EH")]
[InlineData(" 6BOY0 ", "6BOY0")]
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("NULL")]
[InlineData("123456789012345678901")] // longer than the column
[InlineData("AB'; DROP")]
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
}
+39
View File
@@ -0,0 +1,39 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
/// </summary>
public class InvoicePgetIdTests
{
[Theory]
[InlineData("12345", 12345L)]
[InlineData(" 987654321 ", 987654321L)]
[InlineData("9223372036854775807", long.MaxValue)]
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
{
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData("XH6EH")] // Intranet invoice id
[InlineData("6BOY0")] // alphanumeric, starts with a digit
[InlineData("00000")] // numeric but not a valid (positive) mfr id
[InlineData("0")]
[InlineData("-5")]
[InlineData("1.5")]
[InlineData("1e3")]
[InlineData("9223372036854775808")] // overflow
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
{
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
}
}
+298
View File
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
/// function call must pass the declared number of arguments. None of this is visible to the
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
/// </summary>
public class SqlContractTests
{
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
private const char JoinedBreak = '\u0001';
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
private static readonly Regex Declaration = new(
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParameterDeclaration = new(
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex Reference = new(
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
private static string RepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
return dir.FullName;
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
return "";
}
internal static string StripSqlComments(string sql) =>
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
internal static SqlObject? ParseObject(string sql)
{
sql = StripSqlComments(sql);
var m = Declaration.Match(sql);
if (!m.Success) return null;
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
string header = "";
string rest = sql[(m.Index + m.Length)..];
if (kind == "FUNCTION")
{
int open = rest.IndexOf('('), depth = 0;
for (int i = open; open >= 0 && i < rest.Length; i++)
{
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
if (depth == 0) { header = rest[(open + 1)..i]; break; }
}
}
else if (kind == "PROCEDURE")
{
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
}
var ps = ParameterDeclaration.Matches(header)
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
.ToList();
return new SqlObject(m.Groups["name"].Value, kind, ps);
}
private static Dictionary<string, SqlObject> LoadProject()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
if (ParseObject(File.ReadAllText(file)) is { } o)
objects[o.Name] = o;
return objects;
}
/// <summary>
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
/// comments name objects too) and adjacent concatenated literals are joined, so an
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
/// </summary>
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
{
var lines = source.Split('\n');
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
// reported line number but does not end the statement's argument list.
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
foreach (Match m in Reference.Matches(code))
{
string name = m.Groups["name"].Value;
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
continue;
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
string tail = code[(m.Index + m.Length)..];
string args;
if (m.Groups["call"].Success)
{
int depth = 1, i = 0;
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
args = tail[..Math.Max(0, i - 1)];
}
else
{
int end = tail.IndexOfAny([';', '"', '\n']);
args = end < 0 ? tail : tail[..end];
}
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
}
}
private static List<SqlReference> ScanRepository()
{
string root = RepoRoot();
return ScannedProjects
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
.ToList();
}
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool sawNamed = false;
int position = 0;
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var named = Regex.Match(arg, @"^(@\w+)\s*=");
if (named.Success)
{
sawNamed = true;
string n = named.Groups[1].Value.ToLowerInvariant();
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
supplied.Add(n);
continue;
}
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
supplied.Add(ps[position++].Name);
}
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
}
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
{
var problems = new List<string>();
foreach (var r in refs)
{
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
: [];
problems.AddRange(found.Select(p => $"{at}: {p}"));
}
return problems;
}
// ── The contract, against the real repository ─────────────────────────────────────────────
[Fact]
public void ScanRepository_RealSources_FindsTheKnownCallSites()
{
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
var refs = ScanRepository();
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
{
var problems = Problems(ScanRepository(), LoadProject());
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
}
[Fact]
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
{
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
Assert.NotEmpty(updt);
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
}
[Fact]
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
[Theory]
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
[InlineData("@tgtdate, @mode, @files, @authuser")]
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
=> Assert.Empty(ExecuteProblems(args, Datev));
[Theory]
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
[InlineData("", "required parameter @tgtdate")]
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
[Theory]
[InlineData("'fds_reports', @authuser", 2, true)]
[InlineData("LEFT(@a, 3), @b", 2, true)]
[InlineData("", 0, true)]
[InlineData("@authuser", 2, false)]
[InlineData("@a, @b, @c", 2, false)]
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
{
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
}
[Fact]
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
{
const string src = """
var a = "EXECUTE [dbo].[proc_a] @x, " +
"@y;";
// "EXECUTE [dbo].[proc_commented] @z;"
/// <c>[dbo].[proc_doc]</c>
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
""";
var refs = ScanSource("x.cs", src).ToList();
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
Assert.Equal("@x, @y", refs[0].Args);
Assert.Equal(1, refs[0].Line);
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
Assert.Equal(5, refs[1].Line);
}
[Fact]
public void Problems_ObjectMissingFromProject_Reported()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
}
[Theory]
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
{
var o = ParseObject(sql)!;
Assert.Equal(kind, o.Kind);
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
}
}
@@ -3,6 +3,7 @@ using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
// only signals (ADR 0007).
public partial class IntranetController
{
/// <summary>
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
/// null when not allowed — fds_inv &gt; 2 and no invoice finalized after that date yet. The SQL function
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
/// </summary>
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<string>(
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
_intranet.Intranet__SQLConnectionString, "",
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
return InvoiceDates.ParseBackdate(val.Result);
}
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
@@ -71,7 +86,8 @@ public partial class IntranetController
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
// POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
@@ -81,7 +97,8 @@ public partial class IntranetController
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
});
}
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
// Partial class: Invoice sub-handlers
public partial class IntranetController
{
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
{
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
// storno window empty. Legacy parity: skip the mfr reset and report success.
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
return await JSONAsync(new { ok = true });
}
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = _mfrFactory.Create())
@@ -263,6 +275,16 @@ public partial class IntranetController
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
}
/// <summary>
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
/// </summary>
internal const string DatevOverviewSql =
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
{
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
@@ -274,10 +296,10 @@ public partial class IntranetController
string mode = Form("mode").ne("m");
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
DatevOverviewSql,
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
tablenames: new[] { "files", "invoices", "debits" },
tablenames: DatevOverviewTables,
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
@@ -465,7 +487,10 @@ public partial class IntranetController
["NameOrNumber"] = "",
["htmltext"] = d["Text"],
["position"] = d["Position"],
["SortOrder"] = d["SortOrder"]
["SortOrder"] = d["SortOrder"],
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
});
}
sdic["items"] = itm;
@@ -1,5 +1,6 @@
using System.Globalization;
using Fuchs.intranet;
using Fuchs.Observability;
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
@@ -302,14 +303,41 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
// Admin option: date the invoice to the last working day of the previous month (ADR 0032).
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
_logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
// final/sent (double submit) — it must not be reported as "create it again without backdating".
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
if (backdate)
{
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
if (outcome == InvoiceFinalizeOutcome.Finalized)
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
}
switch (outcome)
{
case InvoiceFinalizeOutcome.BackdateNotAllowed:
return await InvoiceIssueResult(
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
Form("id"));
case InvoiceFinalizeOutcome.AlreadyFinal:
return await InvoiceIssueResult(
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
Form("id"));
case InvoiceFinalizeOutcome.Error:
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
}
var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
+1
View File
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
@@ -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.
+31 -1
View File
@@ -181,6 +181,36 @@ Confirming the preview modal posts to **`req/sconf`**
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
(the draft only ever had the internal numeric `Id`). `Version` is bumped
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
the last working day (Mon–Fri) of the previous month, as long as no
invoice has been finalised after that day. `req/sconf` then passes
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
`DueDate` follow the backdated day. The proc serializes all finalisations
(transaction + `sp_getapplock`) so the check and the numbering cannot
interleave with a concurrent finalisation. The controller maps 60001 to
the "create again without backdating" message; a 0-row result (already
final/sent, e.g. double submit) is reported as "already finalised".
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
the DATEV export (period + `Belegdatum`), request details and reminders
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
appears in the previous month everywhere, including DATEV. The reminder
PDF's own date follows the same rule (reminder `DateFinalized`).
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
credited intermediate invoice in `reference_InvId` (seeded by
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
editor as item `ref`); finalisation records it in
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
not offered again. The claim only counts while the crediting invoice is
alive: once it is cancelled (storno, or replaced via storno + recreate)
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
free again and the next finalisation takes the allocation over. A
Stornorechnung (`'c'`) never claims an allocation, even though a storno
copy mirrors the credit lines.
2. On success (`IsFinal == true` returned), the controller:
- Reloads the invoice via `LoadInvoiceAsync`.
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
@@ -283,7 +313,7 @@ Once an invoice is `IsFinal`, the request/invoice list menus
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
+5
View File
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
### Erstellen / Bearbeiten
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
+3
View File
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
public static readonly Counter<long> BlobBackupCopiesFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
"Number of blob backup-copy attempts that failed.");
public static readonly Counter<long> InvoicesBackdated =
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
public static readonly Counter<long> InvoiceFileBackups =
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
+11
View File
@@ -135,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
@@ -270,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
category, documentId, containerName, blobName);
return blobClient.Uri;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
throw;
}
catch (Exception ex)
{
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
+1 -7
View File
@@ -259,13 +259,7 @@ public static class ERechnungMapper
}
// ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
+4 -2
View File
@@ -12,7 +12,8 @@ public interface IBlobStorageService
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow.
/// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
/// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
@@ -50,7 +51,8 @@ public interface IBlobStorageService
/// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
/// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
+2 -1
View File
@@ -616,7 +616,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
int idx = 0;
+15 -1
View File
@@ -205,6 +205,17 @@ public class InvoiceService : IInvoiceService
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
/// </summary>
/// <summary>
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
/// </summary>
internal static string? ItemReferenceInvId(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
}
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
{
string invoiceId = invoice.Id;
@@ -269,6 +280,7 @@ public class InvoiceService : IInvoiceService
float vs = 0;
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
row["det"] = itmDic.no("det", false) is true;
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
itemsTable.Rows.Add(row);
}
@@ -302,7 +314,9 @@ public class InvoiceService : IInvoiceService
: Array.Empty<string>(),
AdminUser = reg?.getString("UserNameFinalized") ?? "",
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
// Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
};
var inlineImages = await ResolveInlineImagesAsync(invoice);
var doc = await _pdf.WriteLetterAsync(tb, draft);
+2 -1
View File
@@ -271,7 +271,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
return new GenericObjectDictionary(d);
}
+2 -1
View File
@@ -111,7 +111,8 @@ public class ReminderService : IReminderService
Address = reminder.InvoiceAddress,
AdminUser = reminder.UserNameFinalized,
AdminUserEmail = reminder.UserEmailFinalized,
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
// Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
};
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
{
-1
View File
@@ -32,7 +32,6 @@ public class FdsReminderData
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
// -- Computed props from registration -------------------------------------
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
public string ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? "";
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
+86
View File
@@ -0,0 +1,86 @@
using static OCORE.OCORE_dictionaries;
namespace Fuchs.intranet;
/// <summary>Result of a <c>fds__setInvoiceFinal</c> call as seen by <c>req/sconf</c>.</summary>
public enum InvoiceFinalizeOutcome
{
/// <summary>The draft was finalized (one row returned).</summary>
Finalized,
/// <summary>SQL error 60001: no longer allowed to backdate; nothing was finalized.</summary>
BackdateNotAllowed,
/// <summary>No error but no row: the invoice is already final/sent (or unknown); nothing was changed.</summary>
AlreadyFinal,
/// <summary>Any other SQL error (authorization 60000, constraint, timeout ...).</summary>
Error
}
/// <summary>
/// The invoice date ("Rechnungsdatum") printed on the PDF and carried as the e-invoice issue date
/// (BT-2). One source for both so they can never disagree (ADR 0032):
/// <c>DateFinalized</c> (the finalisation — or, for a backdated invoice, the backdated — date),
/// falling back to <c>DateCreated</c> for drafts. Both columns are written with <c>GETUTCDATE()</c>,
/// so the value is converted to the German calendar day.
/// </summary>
public static class InvoiceDates
{
private static readonly TimeZoneInfo German = ResolveGermanZone();
public static DateOnly IssueDate(GenericObjectDictionary? registration, DateTime? utcNow = null)
{
DateTime? stored = registration?.getDate_null("DateFinalized") ?? registration?.getDate_null("DateCreated");
DateTime utc = stored ?? utcNow ?? DateTime.UtcNow;
return DateOnly.FromDateTime(ToGermanTime(utc));
}
/// <summary>Parses the ISO date returned by <c>fds__fn_invoiceBackdate</c>; null when empty/invalid.</summary>
public static DateOnly? ParseBackdate(string? iso)
=> DateOnly.TryParseExact((iso ?? "").Trim(), "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d) ? d : null;
/// <summary>True when the finalise request asked for backdating (checkbox value "1"/"true").</summary>
public static bool IsBackdateRequested(string? formValue)
=> (formValue ?? "").Trim().ToLowerInvariant() is "1" or "true" or "on";
/// <summary>SQL error number of <c>THROW 60001, 'backdating not allowed'</c> in <c>fds__setInvoiceFinal</c>.</summary>
public const int BackdateNotAllowedErrorNumber = 60001;
/// <summary>
/// Classifies what <c>fds__setInvoiceFinal</c> did, from the OCORE result: an exception (with the SQL
/// error number), or the number of rows the guarded <c>UPDATE ... WHERE isFinal = 0 AND isSent = 0</c>
/// returned. Only the dedicated 60001 means "backdating no longer allowed"; a 0-row result means the
/// invoice was already final/sent (e.g. a double submit) — reporting that as a backdate rejection
/// would invite the user to create a duplicate.
/// </summary>
public static InvoiceFinalizeOutcome ClassifyFinalizeResult(string? sqlException, int? sqlErrorNumber, int rowCount)
{
if (!string.IsNullOrEmpty(sqlException))
return sqlErrorNumber == BackdateNotAllowedErrorNumber
? InvoiceFinalizeOutcome.BackdateNotAllowed
: InvoiceFinalizeOutcome.Error;
return rowCount > 0 ? InvoiceFinalizeOutcome.Finalized : InvoiceFinalizeOutcome.AlreadyFinal;
}
/// <summary>Tag value for the <c>fuchs.invoices.backdated</c> counter's <c>outcome</c> dimension.</summary>
public static string OutcomeTag(InvoiceFinalizeOutcome outcome) => outcome switch
{
InvoiceFinalizeOutcome.Finalized => "ok",
InvoiceFinalizeOutcome.BackdateNotAllowed => "rejected",
InvoiceFinalizeOutcome.AlreadyFinal => "already_final",
_ => "error"
};
internal static DateTime ToGermanTime(DateTime utc)
=> TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), German);
private static TimeZoneInfo ResolveGermanZone()
{
foreach (var id in new[] { "Europe/Berlin", "W. Europe Standard Time" })
{
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
}
return TimeZoneInfo.Local;
}
}
+15
View File
@@ -247,6 +247,21 @@ div.modal {
}
.modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
> .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
> input {
margin-right: 0.5rem;
}
}
> .choicefrm {
padding: 1rem;
+19 -5
View File
@@ -394,6 +394,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -403,7 +411,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -703,7 +711,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -878,13 +886,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1011,13 +1022,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1331,7 +1345,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
@@ -36,6 +36,8 @@
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
+15
View File
@@ -1309,6 +1309,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
border-top: 1px solid #e5e5e5;
}
div.modal > .modal-dialog > .modal-content .modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
margin-right: 0.5rem;
}
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
padding: 1rem;
}
+21 -5
View File
@@ -160,6 +160,8 @@ let $ict = {
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
@@ -974,6 +976,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -983,7 +993,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -1283,7 +1293,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -1458,13 +1468,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1591,13 +1604,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1911,7 +1927,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -160,6 +160,8 @@ let $ict = {
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
ivEc: '\nMöchten Sie fortfahren?',
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
@@ -955,6 +957,14 @@ $inv.d = {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
let bdChk = null;
if ((response.backdate || '') !== '') {
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
}
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
@@ -964,7 +974,7 @@ $inv.d = {
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
@@ -1264,7 +1274,7 @@ $inv.cInv2 = function (data) {
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
}
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
});
}
@@ -1439,13 +1449,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1572,13 +1585,16 @@ $inv.cSt = function (data) {
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
}, error: (xhr) => {
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}, complete: () => {
o.c.trigger('modal_close');
}
});
}, error: () => {
}, error: (xhr) => {
if (o) { o.c.trigger('modal_close'); }
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
}
});
};
@@ -1892,7 +1908,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
File diff suppressed because one or more lines are too long
+15
View File
@@ -243,6 +243,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
border-top: 1px solid #e5e5e5;
}
div.modal > .modal-dialog > .modal-content .modal-body {
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
display: block;
margin: 0 0 1rem 0;
padding: 0.5rem 1rem;
background-color: #fff8e1;
border: 1px solid #f0c36d;
font-weight: bold;
cursor: pointer;
}
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
margin-right: 0.5rem;
}
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
padding: 1rem;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+101
View File
@@ -0,0 +1,101 @@
using System.Data;
using System.Globalization;
namespace fds;
/// <summary>
/// Explains an mfr merge that failed with "String or binary data would be truncated": SQL Server
/// (message 8152, or 2628 below compatibility level 150 / without VERBOSE_TRUNCATION_WARNINGS)
/// does not name the column, and the merge proc copies the staging table into both the
/// <c>mfr__tt__*</c> table type and the <c>mfr__*</c> target. After such a failure the staged
/// <see cref="DataTable"/> is compared against the narrower of the two declared widths, so the
/// error log names the column, the longest value and an entity Id.
/// Runs only on the failure path — no cost for a successful merge.
/// </summary>
internal static class FdsColumnWidths
{
/// <summary>One column whose staged values exceed the declared width.</summary>
internal sealed record Overflow(string Column, int MaxAllowed, int LongestLength, int RowsOver, object? LongestRowId)
{
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture,
$"{Column}: {LongestLength} > {MaxAllowed} chars ({RowsOver} row{(RowsOver == 1 ? "" : "s")}{(LongestRowId is null ? "" : $", e.g. Id={LongestRowId}")})");
}
/// <summary>8152 = classic truncation message; 2628 = verbose variant (compat level 150+).</summary>
internal static bool IsTruncationError(int sqlErrorNumber) => sqlErrorNumber is 8152 or 2628;
internal static bool IsTruncationError(Exception? exc) =>
exc is Microsoft.Data.SqlClient.SqlException sx && IsTruncationError(sx.Number);
/// <summary>
/// Char width of every bounded string column of the target table and of its
/// <c>mfr__tt__</c> table type (same naming rule as the <c>mfr__updt__</c> merge proc);
/// where both declare the column the narrower wins. MAX columns (max_length = -1) are omitted.
/// Expects parameter <c>@tablename</c>.
/// </summary>
internal const string WidthsSql = """
SELECT [columnname] = c.[name],
[maxchars] = MIN(CASE WHEN c.[system_type_id] IN (231, 239) THEN c.[max_length] / 2 ELSE c.[max_length] END)
FROM sys.columns AS c
WHERE c.[system_type_id] IN (167, 175, 231, 239)
AND c.[max_length] > 0
AND (c.[object_id] = OBJECT_ID(N'[dbo].' + QUOTENAME(@tablename))
OR c.[object_id] IN (SELECT tt.[type_table_object_id] FROM sys.table_types AS tt
WHERE tt.[schema_id] = SCHEMA_ID(N'dbo')
AND tt.[name] = REPLACE(@tablename, N'__', N'__tt__')))
GROUP BY c.[name];
""";
/// <summary>Rows of <see cref="WidthsSql"/> → column name → max chars (case-insensitive).</summary>
internal static Dictionary<string, int> ToWidthMap(DataTable? widthRows)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (widthRows == null || !widthRows.Columns.Contains("columnname") || !widthRows.Columns.Contains("maxchars"))
return map;
foreach (DataRow row in widthRows.Rows)
{
if (row["columnname"] is not string name || string.IsNullOrWhiteSpace(name)) continue;
if (row["maxchars"] is DBNull) continue;
int width = Convert.ToInt32(row["maxchars"], CultureInfo.InvariantCulture);
if (width <= 0) continue;
map[name] = map.TryGetValue(name, out int existing) ? Math.Min(existing, width) : width;
}
return map;
}
/// <summary>
/// String columns of <paramref name="batch"/> holding at least one value longer than the
/// declared width, one entry per column with the longest value's length and row Id.
/// Columns without a known width (MAX or not declared) are never reported.
/// </summary>
internal static IReadOnlyList<Overflow> FindOverflows(DataTable batch, IReadOnlyDictionary<string, int> widths)
{
var result = new List<Overflow>();
if (batch.Rows.Count == 0 || widths.Count == 0) return result;
DataColumn? idColumn = batch.Columns.Contains("Id") ? batch.Columns["Id"] : null;
foreach (DataColumn col in batch.Columns)
{
if (col.DataType != typeof(string) || !widths.TryGetValue(col.ColumnName, out int maxAllowed)) continue;
int longest = 0, rowsOver = 0;
object? longestId = null;
foreach (DataRow row in batch.Rows)
{
if (row.RowState == DataRowState.Deleted || row[col] is not string s || s.Length <= maxAllowed) continue;
rowsOver++;
if (s.Length > longest)
{
longest = s.Length;
longestId = idColumn != null && row[idColumn] is not DBNull ? row[idColumn] : null;
}
}
if (rowsOver > 0) result.Add(new Overflow(col.ColumnName, maxAllowed, longest, rowsOver, longestId));
}
return result;
}
/// <summary>Log-friendly summary; "none detected" when the batch fits the declared widths.</summary>
internal static string Describe(IReadOnlyList<Overflow> overflows) =>
overflows.Count == 0 ? "none detected" : string.Join("; ", overflows);
}
+33 -4
View File
@@ -294,10 +294,10 @@ public class FdsMfrClient : IDisposable
dtwa.CommandAfterError = new SqlCommand(
$"DROP TABLE IF EXISTS [{fallbackTable}]; SELECT * INTO [{fallbackTable}] FROM {dtwa.DestinationTableName};")
{ CommandTimeout = MergeCommandTimeoutSeconds };
dtwa.OnCommandAfterError += (sender, caex) =>
_logger.LogError(caex,
"Update__entitytable: merge into {Table} failed ({Entity}, setid={SetId}, rows={Rows}) — staged batch kept in {FallbackTable}",
tbl.TableName, thisEntityName, setId, trc, fallbackTable);
// Logged after DoSubmit returns so a truncation can be explained
// (column + length + Id) with an awaited width lookup.
Exception? mergeError = null;
dtwa.OnCommandAfterError += (sender, caex) => mergeError = caex;
dtwa.OnError += (sender, exc, message) =>
_logger.LogError(exc,
"Update__entitytable: DatatableWriterAsync submission failed for {Table} ({Entity}, setid={SetId}) — {Message}",
@@ -319,6 +319,15 @@ public class FdsMfrClient : IDisposable
new("@tgtid", entityId.Length > 0 ? entityId[0].ToString() : (object)DBNull.Value)
});
dtwa.DoSubmit();
if (mergeError != null)
{
string overlong = FdsColumnWidths.IsTruncationError(mergeError)
? await DescribeOverlongColumnsAsync(tbl)
: "n/a";
_logger.LogError(mergeError,
"Update__entitytable: merge into {Table} failed ({Entity}, setid={SetId}, rows={Rows}) — staged batch kept in {FallbackTable}; over-long columns: {OverlongColumns}",
tbl.TableName, thisEntityName, setId, trc, fallbackTable, overlong);
}
}
catch (Exception dsex)
{
@@ -341,6 +350,26 @@ public class FdsMfrClient : IDisposable
return true;
}
/// <summary>
/// Failure path only: names the staged columns wider than the target table / table type allow.
/// Never throws — a failed lookup is reported in the returned text instead.
/// </summary>
private async Task<string> DescribeOverlongColumnsAsync(DataTable batch)
{
try
{
var widths = await getSQLDatatable_async(FdsColumnWidths.WidthsSql, FdsShared.FDSConnectionString(),
SqlParameterList: new List<SqlParameter> { new("@tablename", batch.TableName) },
options: new FdsSqlOptions(logger: _logger));
if (!string.IsNullOrEmpty(widths.Exception)) return "column widths unavailable: " + widths.Exception;
return FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, FdsColumnWidths.ToWidthMap(widths.DataTable)));
}
catch (Exception ex)
{
return "column widths unavailable: " + ex.Message;
}
}
private static void SetRowValue(DataRow row, string key, object value)
{
if (!row.Table.Columns.Contains(key)) return;
+47
View File
@@ -4,6 +4,47 @@
<name>Fuchs_DataService</name>
</assembly>
<members>
<member name="T:fds.FdsColumnWidths">
<summary>
Explains an mfr merge that failed with "String or binary data would be truncated": SQL Server
(message 8152, or 2628 below compatibility level 150 / without VERBOSE_TRUNCATION_WARNINGS)
does not name the column, and the merge proc copies the staging table into both the
<c>mfr__tt__*</c> table type and the <c>mfr__*</c> target. After such a failure the staged
<see cref="T:System.Data.DataTable"/> is compared against the narrower of the two declared widths, so the
error log names the column, the longest value and an entity Id.
Runs only on the failure path — no cost for a successful merge.
</summary>
</member>
<member name="T:fds.FdsColumnWidths.Overflow">
<summary>One column whose staged values exceed the declared width.</summary>
</member>
<member name="M:fds.FdsColumnWidths.Overflow.#ctor(System.String,System.Int32,System.Int32,System.Int32,System.Object)">
<summary>One column whose staged values exceed the declared width.</summary>
</member>
<member name="M:fds.FdsColumnWidths.IsTruncationError(System.Int32)">
<summary>8152 = classic truncation message; 2628 = verbose variant (compat level 150+).</summary>
</member>
<member name="F:fds.FdsColumnWidths.WidthsSql">
<summary>
Char width of every bounded string column of the target table and of its
<c>mfr__tt__</c> table type (same naming rule as the <c>mfr__updt__</c> merge proc);
where both declare the column the narrower wins. MAX columns (max_length = -1) are omitted.
Expects parameter <c>@tablename</c>.
</summary>
</member>
<member name="M:fds.FdsColumnWidths.ToWidthMap(System.Data.DataTable)">
<summary>Rows of <see cref="F:fds.FdsColumnWidths.WidthsSql"/> → column name → max chars (case-insensitive).</summary>
</member>
<member name="M:fds.FdsColumnWidths.FindOverflows(System.Data.DataTable,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Int32})">
<summary>
String columns of <paramref name="batch"/> holding at least one value longer than the
declared width, one entry per column with the longest value's length and row Id.
Columns without a known width (MAX or not declared) are never reported.
</summary>
</member>
<member name="M:fds.FdsColumnWidths.Describe(System.Collections.Generic.IReadOnlyList{fds.FdsColumnWidths.Overflow})">
<summary>Log-friendly summary; "none detected" when the batch fits the declared widths.</summary>
</member>
<member name="T:fds.FdsSqlOptions">
<summary>
Logs every SQL error through the worker's structured <see cref="T:Microsoft.Extensions.Logging.ILogger"/> alone — which, when
@@ -29,6 +70,12 @@
(empty) shape is loaded. Empty when the entity table itself is unknown.
</summary>
</member>
<member name="M:fds.FdsMfrClient.DescribeOverlongColumnsAsync(System.Data.DataTable)">
<summary>
Failure path only: names the staged columns wider than the target table / table type allow.
Never throws — a failed lookup is reported in the returned text instead.
</summary>
</member>
<member name="T:fds.FdsConfig">
<summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
+22 -2
View File
@@ -22,7 +22,11 @@
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
<SuppressTSqlWarnings>71501</SuppressTSqlWarnings>
<!-- 71501: unresolved reference (dynamic SQL / cross-database objects); 71558: identifier differs only by case,
harmless under the CI model collation. Every other T-SQL warning is a build error (ADR 0036) so an
ambiguous or unresolved column never reaches a database. -->
<SuppressTSqlWarnings>71501;71558</SuppressTSqlWarnings>
<TreatTSqlWarningsAsErrors>True</TreatTSqlWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
@@ -148,6 +152,7 @@
<Build Include="Security\fds_rwe.sql" />
<Build Include="Security\fuchs_web.sql" />
<Build Include="Security\fuchs_fds.sql" />
<Build Include="Security\Logins.sql" />
<Build Include="dbo\Functions\vat_val.sql" />
<Build Include="dbo\Functions\strings_replaceSpecialCharsInHTML.sql" />
<Build Include="dbo\Functions\strings_removeLeading0_255.sql" />
@@ -205,6 +210,10 @@
<Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_id.sql" />
<Build Include="dbo\Functions\fds__fn_invoiceBackdate.sql" />
<Build Include="dbo\Functions\fds__fn_invoiceDate.sql" />
<Build Include="dbo\Functions\ocms_fn_generatePassword.sql" />
<Build Include="dbo\Functions\ocms_fn_randbetween.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_customerid.sql" />
<Build Include="dbo\Functions\fds__fn_IntermediateIsAllocatedToOther.sql" />
<Build Include="dbo\Functions\fds__fn_DocumentName.sql" />
@@ -411,5 +420,16 @@
</ItemGroup>
<ItemGroup>
<None Include="Scripts\2026-09-16_fis_widgets_set_based_payment_amounts.sql" />
<None Include="Scripts\2026-09-30_backfill_intermediate_invoice_allocations.sql" />
<None Include="Scripts\2026-09-30_mfr_truncation_fix.sql" />
</ItemGroup>
</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]);
+12
View File
@@ -0,0 +1,12 @@
-- Application logins (SQL authentication). They already exist on every environment and their real
-- passwords live in Azure Key Vault; nothing here is a secret.
--
-- The file exists so the users in this folder (fuchs_fds, fuchs_web) bind to a login when the project is
-- built: SSDT reports a user whose login is not in the model as error SQL71501, which is what kept the
-- database project from building at all (ADR 0036). On an existing server the deployment sees the login
-- as present and leaves it alone (SSDT never compares passwords); on a fresh server it is created with
-- an expired placeholder that must be changed before the login can be used.
CREATE LOGIN [fuchs_fds] WITH PASSWORD = N'Change-Me-Before-First-Use-1!' MUST_CHANGE, CHECK_EXPIRATION = ON, CHECK_POLICY = ON;
GO
CREATE LOGIN [fuchs_web] WITH PASSWORD = N'Change-Me-Before-First-Use-1!' MUST_CHANGE, CHECK_EXPIRATION = ON, CHECK_POLICY = ON;
GO
@@ -12,9 +12,13 @@ CREATE FUNCTION [dbo].[fds__fn_IntermediateIsAllocatedToOther]
RETURNS bit
AS
BEGIN
DECLARE @IsA bit = CASE WHEN EXISTS (SELECT * FROM [dbo].[fds__invoices] as intermediates JOIN [dbo].[fds__invoice_details] as id on intermediates.[Id] = id.[InvId] and id.[AllocatedTo_InvId] is not null
-- An allocation only counts while the crediting invoice is alive: once that invoice is cancelled
-- (storno) its credit line is void and the Abschlagsrechnung must be offered again (ADR 0034).
DECLARE @IsA bit = CASE WHEN EXISTS (SELECT 1 FROM [dbo].[fds__invoices] as intermediates JOIN [dbo].[fds__invoice_details] as id on intermediates.[Id] = id.[InvId] and id.[AllocatedTo_InvId] is not null
JOIN [dbo].[fds__invoices] as non_intermediates ON id.[AllocatedTo_InvId] = non_intermediates.[Id] and id.[AllocatedTo_InvId] is not null
WHERE intermediates.[Id] = @Intermediate_InvID and (@Target_InvID is null or non_intermediates.[Id] <> @Target_InvID))
WHERE intermediates.[Id] = @Intermediate_InvID
and ISNULL(non_intermediates.[IsCanceled], 0) = 0
and (@Target_InvID is null or non_intermediates.[Id] <> @Target_InvID))
THEN 1 ELSE 0 END;
RETURN @isA;
@@ -0,0 +1,33 @@
-- =============================================
-- Description: Invoice backdating (ADR 0032). Returns the date an invoice may be backdated to
-- (the last working day Mon-Fri of the previous month, Europe/Berlin), or NULL when
-- backdating is not allowed:
-- * @authuser needs fds_inv > 2, and
-- * no invoice may have been finalized after that date yet (invoice numbers are
-- issued in date order; DateFinalized is stored in UTC, hence the 2h margin).
-- Single source for fds__setInvoiceFinal (@backdate = 1) and the preview dialog.
-- The check is only race-free inside fds__setInvoiceFinal, which serializes all
-- finalizations with a transaction-owned app lock; the preview call is advisory.
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_invoiceBackdate]
(
@authuser varchar(25)
)
RETURNS date
AS
BEGIN
IF [dbo].[fis_getModuleAuth]('fds_inv', @authuser) <= 2
RETURN NULL;
DECLARE @today date = CAST(SYSDATETIMEOFFSET() AT TIME ZONE 'W. Europe Standard Time' AS date);
DECLARE @eom date = EOMONTH(@today, -1);
-- DATEFIRST-independent weekday: 0 = Monday ... 5 = Saturday, 6 = Sunday
DECLARE @bd date = DATEADD(day, CASE DATEDIFF(day, '19000101', @eom) % 7 WHEN 5 THEN -1 WHEN 6 THEN -2 ELSE 0 END, @eom);
IF EXISTS (SELECT 0 FROM [dbo].[fds__invoices]
WHERE [DateFinalized] IS NOT NULL
AND [DateFinalized] >= DATEADD(HOUR, -2, CAST(DATEADD(DAY, 1, @bd) AS datetime)))
RETURN NULL;
RETURN @bd;
END
@@ -0,0 +1,18 @@
-- =============================================
-- Description: The invoice date ("Rechnungsdatum", ADR 0032) as a German calendar day:
-- DateFinalized (the finalisation - or, when backdated, the backdated - moment),
-- falling back to DateCreated for drafts. Both columns are stored in UTC.
-- SQL twin of Fuchs/code/InvoiceDates.IssueDate. Every list, report, export and
-- reminder that shows or groups by an invoice date uses this function (ADR 0035).
-- Also used for reminders (fds__reminder has the same DateFinalized/DateCreated pair).
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_invoiceDate]
(
@DateFinalized datetime,
@DateCreated datetime
)
RETURNS date
AS
BEGIN
RETURN CAST(ISNULL(@DateFinalized, @DateCreated) AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time' AS date);
END
@@ -20,11 +20,11 @@ BEGIN
WITH inv as (
SELECT
[Dateofcreation] = i.[DateFinalized]
[Dateofcreation] = [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])
,i.[InvoiceBalance]
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
FROM [dbo].[fds__invoices] as i
WHERE i.[isFinal] = 1 AND [dbo].[date_monthfirst](i.[DateFinalized]) = @thismonthfirst
WHERE i.[isFinal] = 1 AND [dbo].[date_monthfirst]([dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])) = @thismonthfirst
)
SELECT TOP(1) @ret = FORMAT( SUM( ISNULL(inv.[InvoiceBalanceNetto],0) ) * 0.001, '#,0 k€', 'de') FROM inv;
END
@@ -14,7 +14,7 @@ BEGIN
DECLARE @groups TABLE([usergroup_id] varchar(5));
INSERT INTO @groups SELECT * from STRING_SPLIT(@usergroup_id,',');
DECLARE @isMember bit = CASE WHEN EXISTS (SELECT * FROM [dbo].[fis_usergroups] as g
DECLARE @isMember bit = CASE WHEN EXISTS (SELECT 1 FROM [dbo].[fis_usergroups] as g
JOIN @groups as groupstotest On groupstotest.[usergroup_id] = g.[usergroup_id]
JOIN [dbo].[fis_usergroups_members] as gm on g.[usergroup_id] = gm.[usergroup_id] and gm.[DateDeleted] is null AND g.[DateDeleted] is null
JOIN [dbo].[fis_useraccounts] as u on gm.[useraccount_id] = u.[useraccount_id]
@@ -0,0 +1,88 @@

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

CREATE FUNCTION [dbo].[ocms_fn_randbetween](@bottom integer, @top integer)
RETURNS integer
AS
BEGIN
RETURN (select cast(round((@top-@bottom) * [rn] +@bottom,0) as integer) from [dbo].[ocms_fn_rand])
END
@@ -96,7 +96,7 @@ BEGIN
SELECT TOP(1)
o.*
, [InvoiceId] = inv.[InvoiceId]
, [InvoiceDate] = inv.[DateFinalized]
, [InvoiceDate] = [dbo].[fds__fn_invoiceDate](inv.[DateFinalized], inv.[DateCreated])
, [hasFile] = CAST ( CASE WHEN o.[file] is null THEN 0 ELSE 1 END as bit)
, [UserNameFinalized] = [dbo].[fis_admin_getUserName_byID](o.[UserFinalized])
, [UserEmailFinalized] = [dbo].[fis_admin_getUserEmail_byID](o.[UserFinalized])
@@ -43,11 +43,16 @@ BEGIN
;
-- The invoice date (fds__fn_invoiceDate: DateFinalized as German day, ADR 0035) drives both the export
-- period and the Belegdatum; DateCreated is only the draft's creation and may lie in a later month for
-- a backdated invoice.
-- [DateOfCreation] keeps its name for the callers (HandleDatev / FdsMfr.GetDatevZip) but carries that day.
DECLARE @inv TABLE([Id] varchar(20), [InvoiceID] varchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit, [file_guid] varchar(36));
INSERT INTO @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0), [file_guid]
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = d.[InvoiceDate],[fds] = IIF([isExternal] = 0,1,0), [file_guid]
FROM [dbo].[fds__invoices]
where CAST([DateCreated] as date) between @startdate AND @enddate and [isfinal] = 1;
CROSS APPLY (SELECT [InvoiceDate] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])) as d
where [isfinal] = 1 and d.[InvoiceDate] between @startdate AND @enddate;
SELECT
inv.[Id],inv.[InvoiceID],inv.[DocumentName],inv.[fds]
@@ -78,15 +83,15 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
,[customer] = (SELECT TOP(1) [ExternalId] FROM [dbo].[mfr__companies] as c WHERE c.[Id] = iv.[CustomerId])
,[contra_account] = ca.[account]
,[Belegdatum] = iv.[DateCreated]
,[Belegdatum] = i.[DateOfCreation] -- the invoice date (DateFinalized as German day), see @inv
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[file_guid]
from [dbo].[fds__invoices] as iv
,[DateOfCreation] = i.[DateOfCreation]
,[file_guid] = iv.[file_guid]
from [dbo].[fds__invoices] as iv
JOIN @inv as i on iv.[Id] = i.[Id]
LEFT JOIN [dbo].[fds__invoice_details] as d on iv.[Id] = d.[InvId]
LEFT JOIN sc on iv.[id] = sc.[invoice.Id]
LEFT JOIN @contraaccounts as ca ON (CASE WHEN d.InvoiceOptions like '%§13b%' and RIGHT(ca.[name], 3) = '13b' then 1 WHEN d.InvoiceOptions like '%§13b%' THEN 0 when ISNULL(iv.[InvoiceVAT_1], 19.0) = ca.[vat] THEN 1 ELSE 0 END) = 1
where iv.Id in (SELECT [Id] FROM @inv)
)
select
[Umsatz (ohne Soll/Haben-Kz)] = ABS([balance])--CAST(FORMAT(ABS([balance]), '0.00', 'de') as varchar(13))
@@ -16,15 +16,15 @@ BEGIN
IF @type = 'invoice'
BEGIN
SELECT TOP(1)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [InvoiceID],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateCreated] ASC)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [InvoiceID],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized] ASC)
FROM [dbo].[fds__invoices] WHERE [id] = @reportid AND [isFinal] = 1;
END
ELSE IF @type = 'reminder'
BEGIN
SELECT TOP(1)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [ReminderID] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [DateCreated],[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateCreated] ASC)
[Id] = CAST([Id] as varchar(20)),[type] = @type, [ReminderID] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = CAST( 1 as bit), [file]
,[order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized] ASC)
FROM [dbo].[fds__reminder] WHERE [id] = @reportid AND [isFinal] = 1;
END
@@ -46,9 +46,9 @@ BEGIN
DECLARE @inv TABLE([Id] varchar(15), [InvoiceID] varchar(255), [URI] nvarchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit);
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
where CAST([DateCreated] as date) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
where [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
DECLARE @SReq TABLE ([id] bigint, [DateOfCreation] datetime, [name] nvarchar(255), [invoice.Id] varchar(15), [customer.ExternalId] bigint, [customer.Id] bigint, [ExternalId] nvarchar(255), [fds] bit);
INSERT INTO @SReq
@@ -87,9 +87,9 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
,[customer] = (SELECT TOP(1) [customer.ExternalId] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id] and [customer.ExternalId] is not null ORDER BY SReq.DateOfCreation ASC)
,[contra_account] = ca.[account]
,[Belegdatum] = FORMAT(iv.[DateCreated], 'dd.MM.yy')
,[Belegdatum] = FORMAT([dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated]), 'dd.MM.yy')
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[DateOfCreation] = [dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated])
,[DueDate]
,[PaymentTerm] = ISNULL([PaymentTerm], @stage1)
,[PaymentStatus]
@@ -69,23 +69,23 @@ BEGIN
IF @searchmode = 'i'
BEGIN
insert into @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
where [InvoiceID] = @search OR [InvoiceID] like ('R20[0-9][0-9][-]' + @search);
END
ELSE IF @searchmode = 'c'
BEGIN
insert into @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
WHERE [CustomerId] IN (SELECT [CustomerId] fROM @customerfilter);
END
ELSE
BEGIN
insert into @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
WHERE CAST([DateCreated] as date) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
WHERE [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]) between @startdate AND @enddate and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1);
END;
@@ -126,9 +126,9 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
--,[customer] = (SELECT TOP(1) [customer.ExternalId] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id] and [customer.ExternalId] is not null ORDER BY SReq.DateOfCreation ASC)
,[contra_account] = ca.[account]
,[Belegdatum] = FORMAT(iv.[DateCreated], 'dd.MM.yy')
,[Belegdatum] = FORMAT([dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated]), 'dd.MM.yy')
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[DateOfCreation] = [dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated])
,[DueDate]
,[PaymentTerm] = ISNULL([PaymentTerm], @stage1)
,[PaymentStatus]
@@ -74,9 +74,9 @@ BEGIN
DECLARE @inv TABLE([Id] varchar(15), [InvoiceID] varchar(255), [DocumentName] nvarchar(255), [DateOfCreation] date, [fds] bit);
INSERT INTO @inv
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [DateCreated],[fds] = IIF([isExternal] = 0,1,0)
SELECT [Id],[invoiceid],[DocumentName],[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = IIF([isExternal] = 0,1,0)
FROM [dbo].[fds__invoices]
where CAST([DateCreated] as date) between @startdate AND @enddate
where [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]) between @startdate AND @enddate
and (ISNULL(@include_drafts,0) = 1 OR [isfinal] = 1)
and (@only_drafts = 0 OR [isfinal] = 0)
and (@only_akonto = 0 OR [InvoiceType] = 'i')
@@ -120,9 +120,9 @@ BEGIN
,[deb_cred] = CASE WHEN [invoiceBalance] >= 0 THEN 'S' ELSE 'H' END
,[customer] = (SELECT TOP(1) [customer.ExternalId] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id] and [customer.ExternalId] is not null ORDER BY SReq.DateOfCreation ASC)
,[contra_account] = ca.[account]
,[Belegdatum] = FORMAT(iv.[DateCreated], 'dd.MM.yy')
,[Belegdatum] = FORMAT([dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated]), 'dd.MM.yy')
,[Buchungstext] = (SELECT TOP(1) SReq.[name] FROM @SReq as sreq WHERE iv.[Id] = sreq.[invoice.Id])
,[DateOfCreation] = iv.[DateCreated]
,[DateOfCreation] = [dbo].[fds__fn_invoiceDate](iv.[DateFinalized], iv.[DateCreated])
,[DueDate]
,[PaymentTerm] = ISNULL([PaymentTerm], @stage1)
,[PaymentStatus]
@@ -68,7 +68,7 @@ BEGIN
)
SELECT TOP(1) o.*
,inv.[InvoiceId]
,[InvoiceDate] = inv.[DateFinalized]
,[InvoiceDate] = [dbo].[fds__fn_invoiceDate](inv.[DateFinalized], inv.[DateCreated])
,[InvoiceFileName] = inv.[DocumentName]
,[InvoiceFile] = CASE WHEN ISNULL(@includefile,0) = 1 THEN inv.[file] else NULL END -- do not return file if not explicitly requested
,[hasFile] = CAST ( CASE WHEN o.[file] is null THEN 0 ELSE 1 END as bit)
@@ -13,9 +13,9 @@ BEGIN
DECLARE @idbigint bigint = TRY_CAST(@reportid as bigint);
SELECT TOP(1)
[Id] = CAST([Id] as varchar(20)),[InvoiceID],[URI] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [DateCreated],[fds] = CAST( 1 as bit)
[Id] = CAST([Id] as varchar(20)),[InvoiceID],[URI] = '',[DocumentName],[isInvoice] = 1,[DateOfCreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]),[fds] = CAST( 1 as bit)
, [file] = ISNULL(iv.[file], (SELECT TOP(1) mfrf.[file] FROM [dbo].[fds__mfr_invoicefiles] as mfrf where iv.[IsExternal] = 1 and mfrf.[id] = iv.[externalid]))
, [order] = ROW_NUMBER() OVER (ORDER BY [DateCreated] ASC)
, [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized] ASC)
FROM [dbo].[fds__invoices] as iv
WHERE [id] = @reportid AND [isFinal] = 1;
;
@@ -216,14 +216,14 @@ BEGIN
[MappingId] [nvarchar](255) NULL,
[ExternalId] [nvarchar](255) NULL,
[Name] [nvarchar](255) NULL,
[Note] [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) NOT NULL,
[MainContactId] [bigint] NOT NULL,
[CustomValues] [nvarchar](max) NULL,
[MainContactId] [bigint] NULL,
[address] nvarchar(1000) null
);
INSERT INTO @company SELECT DISTINCT cy.*, [address] = [dbo].[fds__getCompanyAddress](cy.[id]) FROM [mfr__companies] as cy JOIN @SReq as s on cy.id = s.[CustomerId];
@@ -377,6 +377,7 @@ BEGIN
)
SELECT [Id] = CAST(i.[Id] as varchar(20))
,i.[DateCreated]
,[InvoiceDate] = [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])
,[isFinal]
,[isSent]
,i.[DocumentName]
@@ -387,10 +388,10 @@ BEGIN
WHEN [DueDate] is null THEN 'op'
WHEN [invoiceBalance] < 0 THEN ''
WHEN @today >= [DueDate] THEN 'due'
WHEN @stage1 <> '' AND ISNULL(rem.[#],0) = 0 AND @today > [dbo].[date_addcustom](ISNULL(DueDate, i.[DateCreated]), @stage1) THEN 'ovd'
WHEN @stage1 <> '' AND ISNULL(rem.[#],0) = 0 AND @today > [dbo].[date_addcustom](ISNULL(DueDate, [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])), @stage1) THEN 'ovd'
WHEN ISNULL(rem.[#],0) > 0 THEN 'rem'
END
,[order] = ROW_NUMBER() OVER ( ORDER BY inv.[isChild], inv.[baseorder], i.[isFinal] DESC, i.[datecreated] ASC )
,[order] = ROW_NUMBER() OVER ( ORDER BY inv.[isChild], inv.[baseorder], i.[isFinal] DESC, [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated]) ASC )
FROM [dbo].[fds__invoices] as i
JOIN inv on i.[Id] = inv.[Id]
LEFT JOIN reminder as rem on inv.id = rem.[InvId]
@@ -189,14 +189,14 @@ BEGIN
[MappingId] [nvarchar](255) NULL,
[ExternalId] [nvarchar](255) NULL,
[Name] [nvarchar](255) NULL,
[Note] [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) NOT NULL,
[MainContactId] [bigint] NOT NULL,
[CustomValues] [nvarchar](max) NULL,
[MainContactId] [bigint] NULL,
[address] nvarchar(1000) null
);
INSERT INTO @company SELECT DISTINCT cy.*, [address] = [dbo].[fds__getCompanyAddress](cy.[id]) FROM [mfr__companies] as cy JOIN @SReq as s on cy.id = s.[CustomerId];
@@ -228,6 +228,7 @@ BEGIN
-- Add virtual items for intermediate invoices
With associated_interims as (
SELECT inv.InvoiceVAT_1
,[InvId] = inv.[Id] --carried as reference_InvId so fds__setInvoiceFinal can record the allocation
,inv.InvoiceBalance_net
,inv.DocumentName
,inv.DateCreated
@@ -259,7 +260,7 @@ BEGIN
,[NameOrNumber] = REPLACE(ai.DocumentName, '.pdf','')
,[CustomValues] = NULL
,[Note] = '<p>' + 'Anrechnung der ' + REPLACE(ai.DocumentName, '.pdf','') + ' vom ' + FORMAT(ai.[dateFinalized], 'dd.MM.yyyy', 'de-de') + '</p>'
,[ExternalId] = NULL
,[ExternalId] = ai.[InvId] --referenced intermediate invoice (-> reference_InvId)
,[Discount] = NULL
,[VAT] = ai.InvoiceVAT_1
,[IsManual] = 1
@@ -289,7 +290,7 @@ BEGIN
,[NameOrNumber] = 'offener Betrag aus ' + REPLACE(ai.DocumentName, '.pdf','')
,[CustomValues] = NULL
,[Note] = '<p>' + 'Berechnung des offenen Betrags aus ' + REPLACE(ai.DocumentName, '.pdf','') + ' vom ' + FORMAT(ai.[dateFinalized], 'dd.MM.yyyy', 'de-de') + '</p><p>bereits gezahlt (brutto): ' + FORMAT([payed], '0.00', 'de') + '€.</p>'
,[ExternalId] = NULL
,[ExternalId] = ai.[InvId] --referenced intermediate invoice (-> reference_InvId)
,[Discount] = NULL
,[VAT] = ai.InvoiceVAT_1
,[IsManual] = 1
@@ -393,6 +394,7 @@ BEGIN
, [Note] = IIF(ISNULL([Discount], 0) > 0, '<b>Rabatt '+ FORMAT([discount]*0.01,'#.0%','de-de') + '</b>', '') + ISNULL(itm.[Note],'')
, [order] = (itm.[baseorder] * 100) + itm.[order]
, [SetItmId]
, [reference_InvId] = CASE WHEN itm.[Type] IN ('IntInvoiceAllocation', 'IntInvoiceAllocationDiff') THEN CAST(itm.[ExternalId] as varchar(20)) ELSE NULL END
FROM @itm as itm
LEFT JOIN [dbo].[fds__custom_vat] as vat ON itm.[Id] = vat.[EntityId] and vat.[EntityType] = 'item';
END
@@ -49,7 +49,7 @@ BEGIN
END
, [type] = @type
, [invoiceid] = inv.[Invoiceid]
, [invoicedate] = inv.[DateFinalized]
, [invoicedate] = [dbo].[fds__fn_invoiceDate](inv.[DateFinalized], inv.[DateCreated])
, [amount] = inv.[InvoiceBalance]
, [amount_payed] = CAST( ISNULL([dbo].[fds__fn_InvoicePaymentAmount_full](inv.[Id]),0.0) as numeric(10,2))
FROM inv;
@@ -188,14 +188,14 @@ BEGIN
[MappingId] [nvarchar](255) NULL,
[ExternalId] [nvarchar](255) NULL,
[Name] [nvarchar](255) NULL,
[Note] [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) NOT NULL,
[MainContactId] [bigint] NOT NULL,
[CustomValues] [nvarchar](max) NULL,
[MainContactId] [bigint] NULL,
[address] nvarchar(1000) null
);
INSERT INTO @company SELECT DISTINCT cy.*, [address] = [dbo].[fds__getCompanyAddress](cy.[id]) FROM [mfr__companies] as cy JOIN @SReq as s on cy.id = s.[CustomerId];
@@ -236,12 +236,12 @@ BEGIN
,[NameOrNumber] = REPLACE(inv.DocumentName, '.pdf','')
,[CustomValues] = NULL
,[Note] = '<p>' + 'Anrechnung der ' + REPLACE(inv.DocumentName, '.pdf','') + ' vom ' + FORMAT(inv.[dateFinalized], 'dd.MM.yyyy', 'de-de') + '</p>'
,[ExternalId] = NULL
,[ExternalId] = inv.[Id] --referenced intermediate invoice (-> reference_InvId)
,[Discount] = NULL
,[VAT] = inv.InvoiceVAT_1
,[IsManual] = 1
,[SortOrder] = ROW_NUMBER() OVER (PARTITION BY srq.[mfr__servicerequest] ORDER BY inv.[dateFinalized], inv.DateCreated)
,[Type] = 'PartialInvoice'
,[Type] = 'IntInvoiceAllocation' -- same line type as fds__prepInvoice: only this type is taken over as an allocation by fds__setInvoiceFinal
,[ServiceRequestId] = -2 --s.[id]
,[ServiceObjectId] = Null
,[CreatorId] = NULL
@@ -292,7 +292,7 @@ BEGIN
,[NameOrNumber] = REPLACE(inv.DocumentName, '.pdf','')
,[CustomValues] = NULL
,[Note] = '<p>' + 'Storno der ' + REPLACE(inv.DocumentName, '.pdf','') + ' vom ' + FORMAT(inv.[dateFinalized], 'dd.MM.yyyy', 'de-de') + '</p>'
,[ExternalId] = NULL
,[ExternalId] = inv.[Id] --replaced invoice (-> reference_InvId; fds__setInvoiceFinal builds Replaces_InvId from it)
,[Discount] = NULL
,[VAT] = inv.InvoiceVAT_1
,[IsManual] = 1
@@ -387,6 +387,7 @@ BEGIN
, itm.[Note]
, [order] = (itm.[baseorder] * 100) + itm.[order]
, [SetItmId]
, [reference_InvId] = CASE WHEN itm.[Type] IN ('IntInvoiceAllocation', 'CanceledInvoice') THEN CAST(itm.[ExternalId] as varchar(20)) ELSE NULL END
FROM @itm as itm
LEFT JOIN [dbo].[fds__custom_vat] as vat ON itm.[Id] = vat.[EntityId] and vat.[EntityType] = 'item';
END
@@ -35,7 +35,7 @@ BEGIN
WITH inv as (
SELECT
[Dateofcreation] = i.[DateCreatEd]
[Dateofcreation] = [dbo].[fds__fn_invoiceDate](i.[DateFinalized], i.[DateCreated])
,i.[InvoiceBalance]
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
FROM [dbo].[fds__invoices] as i
@@ -39,21 +39,21 @@ BEGIN
WITH inv as(
select
[InvoiceBalance] = SUM( ISNULL(_i.[InvoiceBalance],0) )
,[Dateofcreation] = CAST(_i.[DateCreated] as date)
,[Dateofcreation] = [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated])
,_i.CustomerId
,[customerName] = cy.Name
,[seq] = ROW_NUMBER() OVER (ORDER BY CAST(_i.[DateCreated] as date))
,[seq] = ROW_NUMBER() OVER (ORDER BY [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
FROM [dbo].[fds__invoices] as _i
JOIN [dbo].[mfr__companies] as cy on _i.CustomerId = cy.id
where YEAR(_i.[DateCreated]) = @year and _i.[isfinal] = 1
GROUP BY CAST(_i.[DateCreated] as date), CustomerId, cy.name
where YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated])) = @year and _i.[isfinal] = 1
GROUP BY [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]), CustomerId, cy.name
), yr1 as (
SELECT
[year] = YEAR( _i.[DateCreated] )
[year] = YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
, [total] = SUM( ISNULL(_i.[InvoiceBalance],0) )
FROM [dbo].[fds__invoices] as _i
WHERE _i.[isfinal] = 1
GROUP BY YEAR( _i.[DateCreated])
GROUP BY YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
), yr as (
SELECT [year], [total] = SUM([total])
from yr1 GROUP BY [year]
@@ -40,20 +40,20 @@ BEGIN
WITH inv as(
SELECT
[itmPrice] = SUM( ISNULL(itm.[value_total],0) )
,[Dateofcreation] = CAST(_i.[DateCreated] as date)
,[Dateofcreation] = [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated])
,[material] = itm.[text]
,[seq] = ROW_NUMBER() OVER (ORDER BY CAST(_i.[DateCreated] as date))
,[seq] = ROW_NUMBER() OVER (ORDER BY [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
FROM [dbo].[fds__invoices] as _i
JOIN [fds__invoice_items] as itm on itm.[invid] = _i.[id] AND itm.[Type] like 'Material'
where YEAR(_i.[DateCreated]) = @year and _i.[isfinal] = 1
GROUP BY CAST(_i.[DateCreated] as date), itm.[text]
where YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated])) = @year and _i.[isfinal] = 1
GROUP BY [dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]), itm.[text]
), yr1 as (
SELECT
[year] = YEAR( _i.[DateCreated] )
[year] = YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
, [total] = SUM( ISNULL(_i.[InvoiceBalance],0) )
FROM [dbo].[fds__invoices] as _i
WHERE _i.[isfinal] = 1
GROUP BY YEAR( _i.[DateCreated])
GROUP BY YEAR([dbo].[fds__fn_invoiceDate](_i.[DateFinalized], _i.[DateCreated]))
), yr as (
SELECT [year], [total] = SUM([total])
from yr1 GROUP BY [year]
@@ -47,10 +47,10 @@ BEGIN
with inv as(
select
[InvoiceBalance] = SUM( ISNULL(_i.[InvoiceBalance],0) )
,[Dateofcreation] = CAST([Datecreated] as date)
,[seq] = ROW_NUMBER() OVER (ORDER BY CAST([Datecreated] as date))
FROM [dbo].[fds__invoices] as _i where _i.IsFinal = 1 and month([Datecreated]) = @Month and YEAR([Datecreated]) = @year
GROUP BY CAST([Datecreated] as date)
,[Dateofcreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])
,[seq] = ROW_NUMBER() OVER (ORDER BY [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]))
FROM [dbo].[fds__invoices] as _i where _i.IsFinal = 1 and MONTH([dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])) = @Month and YEAR([dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])) = @year
GROUP BY [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])
)
SELECT
--[Monat] = FORMAT( inv.[Dateofcreation], 'yyyy - MM', 'de')
@@ -46,10 +46,10 @@ BEGIN
with inv as(
select
[InvoiceBalance] = SUM( ISNULL(_i.[InvoiceBalance],0) )
,[Dateofcreation] = CAST([Datecreated] as date)
,[seq] = ROW_NUMBER() OVER (ORDER BY CAST([Datecreated] as date))
FROM [dbo].[fds__invoices] as _i where _i.IsFinal = 1 and YEAR([Datecreated]) = @year
GROUP BY CAST([Datecreated] as date)
,[Dateofcreation] = [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])
,[seq] = ROW_NUMBER() OVER (ORDER BY [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated]))
FROM [dbo].[fds__invoices] as _i where _i.IsFinal = 1 and YEAR([dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])) = @year
GROUP BY [dbo].[fds__fn_invoiceDate]([DateFinalized], [DateCreated])
)
SELECT
--[Monat] = FORMAT( inv.[Dateofcreation], 'yyyy - MM', 'de')
@@ -5,7 +5,8 @@
-- =============================================
CREATE PROCEDURE [dbo].[fds__setInvoiceFinal]
@Id varchar(8),
@authuser varchar(25)
@authuser varchar(25),
@backdate bit = 0 -- 1: date the invoice to the last working day of the previous month (ADR 0032)
AS
BEGIN
SET NOCOUNT ON;
@@ -13,10 +14,33 @@ BEGIN
THROW 60000, N'not authorized', 1;
INSERT INTO [dbo].[fds__admin_activity] ([activity] ,[authuser] ,[info])
VALUES ('fds__setInvoiceFinal' ,@authuser , (SELECT * FROM (VALUES(@Id, @authuser)) as z ([id],[authuser]) FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER));
VALUES ('fds__setInvoiceFinal' ,@authuser , (SELECT * FROM (VALUES(@Id, @authuser, @backdate)) as z ([id],[authuser],[backdate]) FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER));
DECLARE @now datetime = GETUTCDATE();
-- Finalizations are serialized: the "no later invoice" check of fds__fn_invoiceBackdate and the
-- numbering UPDATE below must not interleave with a concurrent finalization, or a higher invoice
-- number could end up with an earlier DateFinalized (ADR 0032). The app lock is transaction-owned
-- and released at COMMIT/ROLLBACK; every finalization (backdated or not) takes it.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @lock int;
EXEC @lock = sp_getapplock @Resource = N'fds__setInvoiceFinal', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 15000;
IF @lock < 0
THROW 60002, N'invoice finalization lock not acquired', 1;
-- Invoice date: now, or (admins, fds_inv > 2) the last working day of the previous month as long as
-- no invoice has been finalized after it. Noon keeps the UTC-stored value on the same calendar day.
DECLARE @finalized datetime = @now, @backdateDay date = NULL;
IF ISNULL(@backdate, 0) = 1
BEGIN
SET @backdateDay = [dbo].[fds__fn_invoiceBackdate](@authuser);
IF @backdateDay IS NULL
THROW 60001, N'backdating not allowed', 1;
SET @finalized = DATEADD(HOUR, 12, CAST(@backdateDay AS datetime));
END
DECLARE @ids as TABLE([Id] varchar(10));
DECLARE @Replaces TABLE([RefInvID] varchar(10));
@@ -24,7 +48,9 @@ BEGIN
DECLARE @Replaces_InvId varchar(50) = CASE WHEN EXISTS(SELECT * FROM @Replaces) THEN STUFF( (SELECT ',' + [refinvid] as [Text] FROM @replaces FOR XML PATH, TYPE).value('.', 'nvarchar(max)'), 1, 1, '') ELSE NULL END;
UPDATE [dbo].[fds__invoices] SET [DateFinalized] = @now, [UserFinalized] = @authuser, [DateModified] = @now, [UserModified] = @authuser, [invoiceid] = [dbo].[fds__newInvoiceId](Year(@now)), [version] = (ISNULL([version],0) + 1), [Replaces_InvId] = @Replaces_InvId
UPDATE [dbo].[fds__invoices] SET [DateFinalized] = @finalized, [UserFinalized] = @authuser, [DateModified] = @now, [UserModified] = @authuser, [invoiceid] = [dbo].[fds__newInvoiceId](Year(@finalized)),
[DueDate] = CASE WHEN @backdateDay IS NULL THEN [DueDate] -- same term arithmetic as fds__setInvoice, anchored on the backdated invoice date
ELSE DATEADD(DAY, ISNULL(TRY_PARSE(REPLACE(REPLACE(ISNULL([PaymentTerm], '10wd'), 'w', ''), 'd', '') AS tinyint), 10), @backdateDay) END, [version] = (ISNULL([version],0) + 1), [Replaces_InvId] = @Replaces_InvId
OUTPUT inserted.[id]
INTO @ids
WHERE [Id] = @Id AND @Id is not null and [isFinal] = 0 and [isSent] = 0;
@@ -32,35 +58,54 @@ BEGIN
SELECT iv.* FROM [dbo].[fds__invoices] as iv JOIN @ids as ids on iv.[id] = ids.Id; --this is necessary to get calculated fields also
-- update references of intermediate invoices
-- update references of intermediate invoices (ADR 0034)
WITH IntInvoiceAllocs as(
SELECT [tgtInvId] = @Id, [reference_InvId] FROM [dbo].[fds__invoice_items] where [Type] = 'IntInvoiceAllocation' AND [InvId] = @Id
SELECT DISTINCT [tgtInvId] = @Id, [reference_InvId] FROM [dbo].[fds__invoice_items] where [Type] = 'IntInvoiceAllocation' AND [InvId] = @Id
AND ISNULL([reference_InvId], '') <> '' -- a NULL reference used to abort this MERGE (515 on fds__invoice_details.InvId)
-- a Stornorechnung (copy) mirrors the cancelled invoice's credit lines but consumes no Abschlagsrechnung
AND EXISTS (SELECT * FROM [dbo].[fds__invoices] as me WHERE me.[Id] = @Id AND me.[InvoiceType] <> 'c')
), existing as (
SELECT intermediates.Id
FROM [dbo].[fds__invoice_details] as id
JOIN [dbo].[fds__invoices] as intermediates on id.[InvId] = intermediates.[id]
-- intermediates already credited on ANOTHER *live* invoice: a credit on a cancelled invoice, or on the
-- invoice this one replaces (storno + recreate), is void and may be taken over
SELECT intermediates.Id
FROM [dbo].[fds__invoice_details] as id
JOIN [dbo].[fds__invoices] as intermediates on id.[InvId] = intermediates.[id]
JOIN IntInvoiceAllocs as iia on iia.[reference_InvId] = intermediates.[Id]
JOIN [dbo].[fds__invoices] as tgt on tgt.[Id] = id.[AllocatedTo_InvId] -- (a dangling AllocatedTo_InvId counts as free)
WHERE ISNULL(intermediates.[iscanceled], 0) = 0 AND isnull(intermediates.[isfinal],0) = 1 -- reallocation is allowed for cancelled inv.
and ISNULL(id.[AllocatedTo_InvId], '6AA609E8-1') <> @id
and tgt.[Id] <> @Id -- (an unallocated one used to count as taken, so nothing was ever allocated)
and ISNULL(tgt.[IsCanceled], 0) = 0
and NOT EXISTS (SELECT * FROM @Replaces as r WHERE r.[RefInvID] = tgt.[Id])
)
MERGE [dbo].[fds__invoice_details] as TARGET
USING IntInvoiceAllocs as iia ON TARGET.[InvID] = iia.[reference_InvId]
WHEN NOT MATCHED BY TARGET
AND NOT EXISTS (SELECT * FROM existing WHERE existing.[id] = iia.[reference_InvId]) THEN
WHEN NOT MATCHED BY TARGET
AND NOT EXISTS (SELECT * FROM existing WHERE existing.[id] = iia.[reference_InvId]) THEN
INSERT ([InvId] ,[AllocatedTo_InvId])
VALUES (iia.[reference_InvId], iia.[tgtInvId])
WHEN NOT MATCHED BY SOURCE THEN
UPDATE
SET [AllocatedTo_InvId] = NULL
WHEN MATCHED -- free, ours already, or held by a cancelled/replaced invoice -> (re)allocate to this one
AND NOT EXISTS (SELECT * FROM existing WHERE existing.[id] = iia.[reference_InvId]) THEN
UPDATE
SET [AllocatedTo_InvId] = iia.[tgtInvId]
WHEN NOT MATCHED BY SOURCE AND TARGET.[AllocatedTo_InvId] = @Id THEN -- only this invoice's own stale allocations (was: every row of the table)
UPDATE
SET [AllocatedTo_InvId] = NULL
;
--updated replaced invoices
UPDATE refinv
SET [DateCancelled] = @now, [UserCancelled] = @authuser, [version] = (ISNULL([version],0) + 1)
SET [DateCancelled] = @finalized, [UserCancelled] = @authuser, [version] = (ISNULL([version],0) + 1)
FROM [dbo].[fds__invoices] as refinv JOIN @Replaces as r on refinv.Id = r.[RefInvID]
WHERE refinv.[isFinal] = 1 and refinv.[IsCanceled] = 0;
--update replaced invoices (by stornos)
EXECUTE [dbo].[fds__maint__updateCancelledStatus];
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW; -- keeps the original number (60001 = backdating not allowed) for the caller
END CATCH;
END
+1 -1
View File
@@ -9,7 +9,7 @@
[MappingId] NVARCHAR (255) NULL,
[ExternalId] NVARCHAR (255) NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[SupportTelephone] NVARCHAR (255) NULL,
[SupportFax] NVARCHAR (255) NULL,
[SupportMail] NVARCHAR (255) NULL,
@@ -2,7 +2,7 @@
[Id] BIGINT NOT NULL,
[Location#ID] BIGINT NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[CustomValues] NVARCHAR (MAX) NOT NULL,
[DateModified] DATETIME NOT NULL,
[ExternalId] NVARCHAR (255) NULL,
@@ -10,7 +10,7 @@
[MappingId] NVARCHAR (255) NULL,
[ExternalId] NVARCHAR (255) NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[SupportTelephone] NVARCHAR (255) NULL,
[SupportFax] NVARCHAR (255) NULL,
[SupportMail] NVARCHAR (255) NULL,
@@ -3,7 +3,7 @@
[Id] BIGINT NOT NULL,
[Location#ID] BIGINT NULL,
[Name] NVARCHAR (255) NULL,
[Note] NVARCHAR (255) NULL,
[Note] NVARCHAR (MAX) NULL,
[CustomValues] NVARCHAR (MAX) NOT NULL,
[DateModified] DATETIME NOT NULL,
[ExternalId] NVARCHAR (255) NULL,