Compare commits

..
7 Commits
Author SHA1 Message Date
Stefan 86f6a60860 feat: implement set-based invoice payment calculations and enhance email logging configuration
Playwright Tests / test (push) Canceled after 0s
2026-09-16 22:31:27 +02:00
Stefan 944a5bb442 Refactor document name sanitization for consistent filename generation
- Introduced FdsDocumentNames class to centralize the logic for sanitizing document names used in downloads.
- Updated FdsMfr to utilize the new SafeFileName method for generating safe filenames.
- Enhanced documentation for FdsDocumentNames and its methods to clarify usage and purpose.
- Added global.json file to configure testing framework settings.
2026-09-16 22:18:42 +02:00
Stefan a60d4c2eaf fix: update mfr__updt__companies procedure and mfr__tt__companies type for improved data handling 2026-09-16 21:52:53 +02:00
Stefan dcfc459693 feat: update launch and task configurations for improved debugging and release builds 2026-09-16 21:22:10 +02:00
StefanandClaude Opus 5 4cac109283 fix: detect no-op document stores via OUTPUT rowset; sanitise regenerated filename
Follow-up to 99bf533 from code review:

- The AffectedRows == 0 guard in Store{Invoice,Reminder}DocumentFileAsync
  was dead: fds__setInvoiceFile / fds__setReminderFile run SET NOCOUNT ON,
  so ExecuteNonQuery returns -1 whether or not the UPDATE ... WHERE [file]
  IS NULL matched. Both procs already return the updated Id through their
  OUTPUT clause, so read that result set (getSQLDatatable_async) instead:
  empty rowset = no-op store, logged, and the blob upload / FileCreated
  event are skipped rather than reporting unpersisted bytes as success.
- inv/rdoc's regenerate path built the download name from the raw
  DocumentName; a user-typed title with '/' truncated FileInfo.Name.
  SafeDocumentFileName strips editor markup and every invalid filename
  char, and the path no longer goes through FileInfo at all.
- Reminder counterpart tests for the store-file failure branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:16:49 +02:00
StefanandClaude Sonnet 5 99bf5337f8 fix: invoice/reminder PDF preview 404s and their unlogged root causes
An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can
fail silently; the row stays finalised with [file] = NULL and inv/rdoc
(the invoice list's preview icon) 404'd forever with no trace in any log.

- inv/rdoc and rem/idoc now regenerate and persist a missing document on
  read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025),
  gated to finalised + non-external invoices only — a draft render must
  never be persisted, since fds__setInvoiceFile/fds__setReminderFile only
  write WHERE [file] IS NULL and finalisation would then silently no-op.
- StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log
  every distinct failure (render threw, render produced 0 bytes, DB write
  threw, DB write matched 0 rows) instead of collapsing them into a single
  silent empty-array return. The 0-rows case needed switching from
  setSQLValue_async (reports success once the command completes, whatever
  the row count) to setSQLValue_async_result, whose AffectedRows makes a
  no-op UPDATE distinguishable from an actual write for the first time.
- HandleRequestIdoc/HandleReminderIdoc no longer treat
  StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as
  success (a bare `ct == null` check never caught it), which would have
  served a 0-byte "PDF".
- rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing
  wasted work with an empty id.
- _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance
  utility, never called from app code, so it's clear why running it leaves
  no application log trail.
- Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the
  preview dialog was timing out client-side after 120ms on every call.
- Fixed a genuine process-wide test flake: PdfPipelineTests and
  ERechnungMapperTests both install PdfSharp's global font resolver, and
  whichever ran first would win, making the other's tests fail
  deterministically once eRechnungLib's SystemFontResolver got there
  first. FuchsPdf.EnsureFontResolver is now also race-safe under
  concurrent first use (a check-then-set without a lock). Also removed
  the InvoiceService.PublishAsync failure logging added mid-session after
  code review found it duplicated every call site's own, more detailed
  logging against ADR 0024's single-write-path rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:24 +02:00
StefanandClaude Sonnet 5 b5613bee0d docs: sync CA1416 build note across CLAUDE.md/CODEX.md/copilot-instructions.md
CLAUDE.md and CODEX.md already documented that CA1416 is suppressed
solution-wide via the root .editorconfig; copilot-instructions.md was
missing that note, breaking the three-file sync rule the header of each
file requires. Also adds the .editorconfig itself, which the note
references and which was untracked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:07 +02:00
44 changed files with 1118 additions and 85 deletions
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
# CA1416: Validate platform compatibility. This app targets Windows only (System.Drawing,
# registry, etc. usage throughout Fuchs/OCORE); the platform-compat analyzer's warnings here
# are expected noise, not signal. See CLAUDE.md "Build & Test" for the pre-existing-warnings note.
dotnet_diagnostic.CA1416.severity = none
+2 -1
View File
@@ -14,7 +14,8 @@
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - 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.
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. - Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
- Project structure (relative to `Fuchs/`): - Project structure (relative to `Fuchs/`):
- `Controllers/` — `IntranetController` partials (no area) - `Controllers/` — `IntranetController` partials (no area)
+24 -1
View File
@@ -2,7 +2,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Fuchs: Debug ASP.NET Core", "name": "Fuchs: Debug (Debug config, Development env)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build Fuchs", "preLaunchTask": "build Fuchs",
@@ -23,6 +23,29 @@
"sourceFileMap": { "sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views" "/Views": "${workspaceFolder}/Fuchs/Views"
} }
},
{
"name": "Fuchs: Launch (Release config, Production env)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build Fuchs Release",
"program": "${workspaceFolder}/Fuchs/bin/Release/net10.0/Fuchs.dll",
"args": [],
"cwd": "${workspaceFolder}/Fuchs",
"stopAtEntry": false,
"justMyCode": true,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
"uriFormat": "%s"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Production",
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views"
}
} }
] ]
} }
+15
View File
@@ -19,6 +19,21 @@
"isDefault": true "isDefault": true
} }
}, },
{
"label": "build Fuchs Release",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Fuchs/Fuchs.csproj",
"--configuration",
"Release",
"/p:RestoreIgnoreFailedSources=true",
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{ {
"label": "gulp: all", "label": "gulp: all",
"type": "shell", "type": "shell",
+2 -2
View File
@@ -16,8 +16,8 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - 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. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
+2 -2
View File
@@ -16,8 +16,8 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - 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. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`. - Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Fuchs.Tests;
/// <summary>
/// Installs PdfSharp's global font resolver once, before any test runs.
/// </summary>
/// <remarks>
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
/// property of the code under test) would win for every other test in the process. Installing
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
/// outcome deterministic instead of order-dependent.
/// </remarks>
internal static class FontResolverTestBootstrap
{
[ModuleInitializer]
internal static void Install() =>
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
/// The method signals every failure with an empty array, so the three distinct causes — render
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
/// logged. Each must be logged individually, and none may reach the blob upload or the
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
/// connection so the write fails fast inside the OCORE helper.
/// </summary>
public class InvoiceServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<InvoiceService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
// try/catch (returning false) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceService(
new Fuchs_intranet(config),
pdf.Object, blob.Object, events.Object, erechnung.Object,
new NoHttp(), config, log);
return (svc, log, blob, events);
}
private static FdsInvoiceData Invoice(bool isFinal) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "W7VAL",
["InvoiceId"] = "R2026-0258",
["IsFinal"] = isFinal
}),
IsDraft = !isFinal
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("W7VAL", entry.Message);
Assert.Contains($"draft={draft}", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("font resolver exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
{
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
// so neither render message may appear, and the PDF must not be archived to blob storage
// as if the primary DB write had succeeded.
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+5 -14
View File
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Services; using Fuchs.Services;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using PdfSharp.Pdf.IO; using PdfSharp.Pdf.IO;
using Xunit; using Xunit;
@@ -31,14 +30,6 @@ public class PdfPipelineTests
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ── // ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
private static byte[] BuildDummyPdfWithPdfSharp() private static byte[] BuildDummyPdfWithPdfSharp()
{ {
// Same font resolver the production render path installs (PdfSharp 6 no longer
// resolves system fonts on its own).
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver is null ||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
{
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
var doc = new Document(); var doc = new Document();
doc.Info.Title = "Dummy Rechnung"; doc.Info.Title = "Dummy Rechnung";
var normal = doc.Styles["Normal"]!; var normal = doc.Styles["Normal"]!;
@@ -50,11 +41,11 @@ public class PdfPipelineTests
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto"); sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto"); sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
var renderer = new PdfDocumentRenderer { Document = doc }; // Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
renderer.RenderDocument(); // (and exactly once, thread-safely) the production render path installs it — a second,
using var ms = new MemoryStream(); // separate check-then-set here previously raced PdfSharp's "must not change font resolver
renderer.PdfDocument.Save(ms, closeStream: false); // after it was once used" guard under parallel test execution.
return ms.ToArray(); return FuchsPdf.DocToPdfBytesRaw(doc);
} }
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ── // ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
+86
View File
@@ -70,6 +70,7 @@ public class ProcessWebComServiceTests
private sealed class CapturingComService : ProcessWebComService private sealed class CapturingComService : ProcessWebComService
{ {
public readonly List<(bool success, List<string> log)> Entries = new(); public readonly List<(bool success, List<string> log)> Entries = new();
public readonly List<string> Configs = new();
public CapturingComService( public CapturingComService(
IOptions<ProcessWebComSettings> settings, IOptions<ProcessWebComSettings> settings,
@@ -83,6 +84,7 @@ public class ProcessWebComServiceTests
DateTime sent, bool success, IEnumerable<string> errors) DateTime sent, bool success, IEnumerable<string> errors)
{ {
Entries.Add((success, errors.ToList())); Entries.Add((success, errors.ToList()));
Configs.Add(config);
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
@@ -267,6 +269,90 @@ public class ProcessWebComServiceTests
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value); Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
} }
// ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─
// Regression: the service logged config = "" on every path; OPENJSON('') throws, which took
// the "last emails" dashboard widget down and lost who/what/attachments from the audit trail.
[Fact]
public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody()
{
var files = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] };
string json = ProcessWebComService.BuildAuditConfig(
"SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys);
var o = JObject.Parse(json);
Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]);
Assert.Equal("", (string?)o["from"]);
Assert.Equal(new[] { "\"Buchhaltung Erne\" <buchhaltung@example.de>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t));
Assert.Empty(o["bcc"]!);
Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t));
Assert.Null(o["body"]); // never the HTML body
Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2)
}
[Theory]
[InlineData(null, "a@b.de", "a@b.de")] // no name → bare address
[InlineData("", "a@b.de", "a@b.de")]
[InlineData(" ", "a@b.de", "a@b.de")]
[InlineData("Ann", "a@b.de", "\"Ann\" <a@b.de>")] // name → quoted display form the widget unwraps
[InlineData(" Ann ", "a@b.de", "\"Ann\" <a@b.de>")]
public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null));
Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t));
Assert.Empty(o["cc"]!);
Assert.Empty(o["attachments"]!);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null));
Assert.Equal("", (string?)o["subject"]);
}
public static IEnumerable<object[]> AllAuditPaths() =>
[
[ "success", HttpStatusCode.OK, true, "kunde@example.de" ],
[ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ],
[ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ],
[ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ],
];
[Theory]
[MemberData(nameof(AllAuditPaths))]
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
string path, HttpStatusCode status, bool enabled, string email)
{
var svc = CreateCapturing(new StubHandler(status), enabled: enabled);
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
string config = Assert.Single(svc.Configs);
var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails
Assert.Equal("Subj " + path, (string?)o["subject"]);
Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t));
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
{
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
var o = JObject.Parse(Assert.Single(svc.Configs));
Assert.Equal(new[] { "\"Kunde\" <dev-inbox@example.test>" }, o["to"]!.Select(t => (string?)t)); // where it really went
Assert.Empty(o["cc"]!); // cc is dropped by the override
Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable
}
// ── Dev/test recipient override safety net ───────────────────────────────── // ── Dev/test recipient override safety net ─────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress() public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Reminder counterpart of <see cref="InvoiceServiceStoreFileTests"/>: every failure branch of
/// <see cref="ReminderService.StoreReminderDocumentFileAsync"/> must be logged individually and
/// must not reach the blob upload or the ReminderFileCreated event (ADR 0025).
/// </summary>
public class ReminderServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<ReminderService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (ReminderService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new ReminderService(new Fuchs_intranet(config), pdf.Object, blob.Object, events.Object, log);
return (svc, log, blob, events);
}
private static FdsReminderData Reminder() => new()
{
ReminderRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "M1ABC",
["subject"] = "Zahlungserinnerung",
["IsFinal"] = true
}),
IsDraft = false
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadReminderPdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.ReminderFileCreatedAsync(It.IsAny<FdsReminderData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError()
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("M1ABC", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionOnce()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("render exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyAndSkipsBlob()
{
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+84
View File
@@ -0,0 +1,84 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <see cref="IntranetController.SafeDocumentFileName"/> names a regenerated invoice document
/// (inv/rdoc, ADR 0025). DocumentName comes from the user-typed invoice title, so it must survive
/// path separators and editor markup without truncating or breaking the download filename.
/// </summary>
public class ReportDocFileNameTests
{
[Theory]
[InlineData("Rechnung R2026-0258.pdf", "Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf", "Wartung 2025_26 R2026-0001.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf", "Angebot A_B_ _x_ _y__z__.pdf")]
[InlineData("<p>Rechnung R2026-0002.pdf</p>", "Rechnung R2026-0002.pdf")]
[InlineData(" Rechnung R2026-0003.pdf ", "Rechnung R2026-0003.pdf")]
public void SafeDocumentFileName_ReplacesInvalidCharsAndStripsMarkup(string documentName, string expected)
{
Assert.Equal(expected, IntranetController.SafeDocumentFileName(documentName, "Rechnung_X.pdf"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("<p></p>")]
[InlineData("<p> </p>")]
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
{
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
}
/// <summary>
/// The stored-document path (<c>FdsMfr.GetReportDoc</c>) replaced exactly these characters
/// before the sanitiser was shared: <c>= &gt; | ! &lt; /</c>. The shared helper must still
/// produce the identical result for them — unifying the two paths may not loosen either.
/// </summary>
[Theory]
[InlineData("a>b|c!d<e/f=g.pdf", "a_b_c_d_e_f_g.pdf")]
[InlineData("<p>Summe=100!.pdf</p>", "Summe_100_.pdf")]
public void SafeFileName_KeepsEveryReplacementTheStoredDocumentPathMade(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
/// <summary>
/// Characters the old stored-document path did NOT catch but Windows rejects — the reason
/// the sanitiser was unified (code review on ADR 0025): a stored and a regenerated document
/// must never diverge in filename safety.
/// </summary>
[Theory]
[InlineData("Angebot A\\B.pdf", "Angebot A_B.pdf")]
[InlineData("Re: 2026.pdf", "Re_ 2026.pdf")]
[InlineData("x*y?z\".pdf", "x_y_z_.pdf")]
[InlineData("tab\there.pdf", "tab_here.pdf")]
public void SafeFileName_ReplacesInvalidCharsTheStoredDocumentPathMissed(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
[Theory]
[InlineData("Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf")]
[InlineData("a>b|c!d<e/f=g.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf")]
[InlineData("<p></p>")]
[InlineData(null)]
public void SafeDocumentFileName_MatchesSharedSanitiserForEveryInput(string? documentName)
{
Assert.Equal(
fds.FdsDocumentNames.SafeFileName(documentName, "Rechnung_X.pdf"),
IntranetController.SafeDocumentFileName(documentName!, "Rechnung_X.pdf"));
}
[Fact]
public void SafeFileName_ReturnsFallbackVerbatim_WhenNothingUsableRemains()
{
// The fallback is the caller's responsibility and is not sanitised or second-guessed.
Assert.Equal("W7VAL", fds.FdsDocumentNames.SafeFileName("<p></p>", "W7VAL"));
Assert.Equal("", fds.FdsDocumentNames.SafeFileName("", ""));
}
}
@@ -131,6 +131,7 @@ public partial class IntranetController
return await HandleDatev(fn, id, code); return await HandleDatev(fn, id, code);
case "rdoc": case "rdoc":
if (!HasForm("id")) { _logger.LogWarning("rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID); _logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID);
return await HandleReportDoc(fn, id, code, Form("id")); return await HandleReportDoc(fn, id, code, Form("id"));
@@ -293,19 +293,49 @@ public partial class IntranetController
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID); _logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
byte[]? content = null; byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid); var file = _mfr.GetReportDoc(ref content, reportid);
if (file == null) string fileName, mimeType;
if (file != null && content is { Length: > 0 })
{ {
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID); fileName = file.Name;
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); mimeType = file.MimeType();
}
else
{
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
// block the real final PDF at finalisation.
var fdInv = await _invoices.LoadInvoiceAsync(reportid, UserAccountID, DbSec);
bool isExternal = fdInv.InvoiceRegistration?.getItem("IsExternal", false) is true;
if (!string.IsNullOrEmpty(fdInv.Id) && !fdInv.IsDraft && !isExternal)
{
_logger.LogWarning("HandleReportDoc: no stored document for reportid={ReportId}, regenerating, user={User}", reportid, UserAccountID);
content = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
}
if (content == null || content.Length == 0)
{
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
fileName = SafeDocumentFileName(
fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
mimeType = "application/pdf";
} }
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}", _logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID); reportid, fileName, mimeType, content.Length, UserAccountID);
if (Form("typ") != "img") if (Form("typ") != "img")
return await FileContentResultAsync(content!, file.MimeType(), file.Name); return await FileContentResultAsync(content, mimeType, fileName);
return await PdfPreviewAsync("inv/rdoc", reportid, async () => return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) })); await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
} }
/// <summary>
/// Download filename for a regenerated document — the one shared sanitiser
/// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
/// applies to stored documents, so both paths name the same DocumentName identically.
/// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback) =>
fds.FdsDocumentNames.SafeFileName(documentName, fallback);
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code) private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{ {
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }
@@ -36,6 +36,28 @@ public partial class IntranetController
{ {
var result = await render(); var result = await render();
sw.Stop(); sw.Stop();
// The client (postXT) gives up after a fixed timeout and aborts the connection while a
// slow render/regenerate-on-miss (ADR 0025) is still running server-side; that work is
// deliberately NOT cancelled — the regenerated document must still be stored so the
// next click is fast (ADR 0025) — but writing the now-finished result back to a socket
// the client already closed would only surface as a low-level framework/Kestrel
// exception that never reaches an EventId'd `_logger` call: it is either below the SQL
// sink's Warning floor or purely a debugger first-chance notification that never goes
// through ILogger at all — exactly the "exception in the debug console but nothing in
// fds__debuglog" gap. Recognise the disconnect here instead and log it deliberately, at
// Warning, so it is guaranteed to land in fds__debuglog (ADR 0024) — and skip the write.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(
"PDF preview {What} ref={RefId} user={User} finished in {Ms} ms but the client had already disconnected (timeout) — result discarded",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
// A draft session that expired between page load and preview click is an expected // A draft session that expired between page load and preview click is an expected
// client-driven outcome (the browser reopens the draft), not a render failure — keep // client-driven outcome (the browser reopens the draft), not a render failure — keep
// it out of the failure telemetry/logging this guard exists to produce. // it out of the failure telemetry/logging this guard exists to produce.
@@ -51,6 +73,20 @@ public partial class IntranetController
{ {
sw.Stop(); sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message); act?.SetStatus(ActivityStatusCode.Error, ex.Message);
// Same client-gone rationale as above, but the render itself threw (e.g. it tried to
// write partial output to the aborted connection). Still an expected outcome of a
// client timeout, not a render defect — logged at Warning, not Error, but still
// guaranteed to reach fds__debuglog (ADR 0024) rather than only a debug console.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms because the client had already disconnected",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
FuchsTelemetry.PdfPreviews.Add(1, FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what), new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure")); new KeyValuePair<string, object?>("outcome", "failure"));
@@ -208,9 +208,16 @@ public partial class IntranetController
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf"); string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
if (Form("typ") != "img") if (Form("typ") != "img")
{ {
byte[] ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec) ? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); : _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
// StoreReminderDocumentFileAsync signals failure with Array.Empty<byte>() — without this
// guard a failed render/store would be served as a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleReminderIdoc: no PDF bytes available for reminder {RemId} user={User}", fdRem.Id, UserAccountID);
return await ReminderIssueResult("Die Mahnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdRem.Id);
}
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true); return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
} }
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () => return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
@@ -357,7 +357,7 @@ public partial class IntranetController
else if (filebyte.Length == 0) else if (filebyte.Length == 0)
{ {
_logger.LogError( _logger.LogError(
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}", "Invoice PDF could not be rendered or stored (see preceding InvoiceService/SQL error) — invoiceId={InvoiceId} user={User}",
invId, UserAccountID); invId, UserAccountID);
await _events.InvoiceIssueAsync( await _events.InvoiceIssueAsync(
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.", $"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
@@ -387,7 +387,7 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); } if (!HasForm("id")) { _logger.LogWarning("HandleRequestIdoc: missing form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID); _logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec); var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); } if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
@@ -397,8 +397,13 @@ public partial class IntranetController
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
if (ct == null) // StoreInvoiceDocumentFileAsync signals failure with Array.Empty<byte>(), not null — a
// bare `ct == null` check never catches that and would silently serve a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleRequestIdoc: no PDF bytes available for invoice {InvId} user={User}", fdInv.Id, UserAccountID);
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
}
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The // "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.) // OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
@@ -0,0 +1,79 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/IntranetController.Invoices*.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFile.sql"
supersededBy: ""
---
# 0025 — Invoice document endpoints regenerate a missing final PDF on read
## Context
A finalised invoice's PDF is rendered exactly once, at finalisation
(`req/sconf` → `StoreInvoiceDocumentFileAsync` → `fds__setInvoiceFile`), and
stored in `fds__invoices.[file]` (primary) plus blob storage (archive). If that
single write fails — observed on 2026-09-16 for `W7VAL`/`R2026-0258`, where the
`fds__setInvoiceFile` call timed out under DB load — the invoice is left
finalised with `[file] = NULL` and nothing ever retries.
The read endpoints then disagreed: `req/idoc` regenerated a missing file
(a fallback carried over from the legacy VB `fuchs_ocms_intranet_requests.vb`
"idoc" case), while `inv/rdoc` — the endpoint the invoice list's preview icon
actually calls — read `fds__getReportDocument` and returned a permanent 404.
## Decision
Both invoice document read endpoints (`req/idoc`, `inv/rdoc`) regenerate and
persist the PDF via `StoreInvoiceDocumentFileAsync` when no stored bytes exist,
**only** when the invoice is
- **finalised** (`IsFinal`, i.e. `!FdsInvoiceData.IsDraft`), and
- **not external** (`IsExternal` false — an MFR-sourced document has no
template to regenerate from; a missing MFR file stays a 404).
A read is therefore allowed to write `[file]`, upload the blob and emit
`InvoiceFileCreated` — the same side effects finalisation would have had.
Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync`
(render threw, render produced no bytes, DB write threw, DB write matched 0
rows) is logged at Error level individually, because the method signals all
four to callers with the same empty array. The 0-rows case is detected via
the procs' existing `OUTPUT inserted.[Id]` result set (read with
`getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
matched nothing). Rows-affected is **not** usable for this: both procs run
`SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
`UPDATE` matched, which is why `setSQLValue_async` could never distinguish
a no-op from a write and the original incident was invisible to every log.
## Consequences
- **Drafts are never persisted by a read.** `fds__setInvoiceFile` writes
`WHERE [file] IS NULL` and `fds__setInvoiceFinal` never clears `[file]`; a
draft render stored early would make the later final write a no-op and the
invoice would carry the watermarked draft PDF forever. The `!IsDraft` gate is
load-bearing — do not relax it.
- The regenerated PDF is rendered from the invoice's *current* data. That is
acceptable because a finalised invoice is immutable in the editor; it is not
acceptable for anything mutable.
- A read on a cold miss now costs a render plus a DB write, so it is exposed
to the same `fds__setInvoiceFile` timeout that caused the miss. The client
preview timeout (`$inv.pdfPreviewTimeout`) must stay generous (120 s).
- `inv/rdoc` still serves external (MFR) documents purely from
`fds__getReportDocument`; regeneration never applies to them.
## Alternatives considered
- **Retry at finalisation instead of on read.** Would cover the store failure
but not an already-damaged row; regeneration on read heals both and matches
the behaviour `req/idoc` already had.
- **Regenerate drafts too, to mirror `req/idoc`'s render path.** `req/idoc`
*renders* a draft on the fly without storing it; persisting a draft through
`fds__setInvoiceFile` is what causes the permanent-draft defect above.
- **Return 404 and rely on the `_BackupAndClearInvoiceFile` recovery links.**
That proc is a manual-only maintenance tool, and the `inv/rdoc?create=1`
link it prints was never implemented on that endpoint.
@@ -0,0 +1,74 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Services/FuchsWidgetService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql"
- "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql"
- "Fuchs_Database/dbo/Functions/fds_wgd_*.sql"
- "Fuchs_Database/dbo/Functions/fds_wdg_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql"
- "Fuchs_Database/Scripts/**"
supersededBy: ""
---
# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract
## Context
Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16):
1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called
`[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar
UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`)
and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over
~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice:
≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s,
hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`,
joined to the invoices) takes < 0.5 s and returns the identical figure.
2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses
`fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement
for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the
whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent
through the new service.
Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check
that a widget's SQL is cheap or that the columns it parses exist.
## Decision
- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the
inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from
the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar
`fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder
preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called
per row from a widget or any other query that iterates a whole invoice set.
- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig`
in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses
as `"Name" <email>` and attachments as file names only (never the body, never attachment bytes) —
on **every** audit path (success, API error, exception, invalid address, service disabled). SQL
consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before
parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`.
- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files
(`<None Include>` in the sqlproj, not part of the schema build) and are run once per environment
after the schema deploy they depend on.
## Consequences
- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails`
error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a
budget.
- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the
`fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column
on the TVF, as done for `PaymentAmount`.
- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape
requires updating `fds_wgd_lastEmails` in the same change.
- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on
Production after the schema deploy (it is a no-op once applied).
## Alternatives considered
- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid
invoice and every banking upload, and it would still block a dashboard load for minutes.
- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it
needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an
inline TVF is a larger change to a function shared with the banking-assignment UI.
- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any
writer, any environment) would take the widget down again; tolerance belongs in the reader.
+1 -1
View File
@@ -272,7 +272,7 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|---|---|---|---| |---|---|---|---|
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` | | **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) | | **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only | | **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline | | **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` | | **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised | | **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `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 |
+32
View File
@@ -236,6 +236,38 @@ public class Program
// calls silently no-op instead of reaching Debug output / ErrorLog.txt. // calls silently no-op instead of reaching Debug output / ErrorLog.txt.
OCORE.Logging.Logger = app.Logger; OCORE.Logging.Logger = app.Logger;
// Exceptions that never pass through a request (Do()'s catch-all, PdfPreviewAsync,
// UseExceptionHandler) or a hosted service's own try/catch — a faulted fire-and-forget
// Task nobody awaited, or a crash on a non-request thread — are otherwise visible only
// as a debugger notification or a process exit: they never reach ILogger and therefore
// never reach fds__debuglog (ADR 0024). Route them through the app logger so the SQL
// sink and ErrorLog.txt both see them. On a terminating crash the fire-and-forget SQL
// write may not complete; the synchronous file sink (0003) is the record then.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
app.Logger.LogError(new EventId(0, "Unobserved task exception"), e.Exception,
"Unobserved task exception: {Message}", e.Exception.GetBaseException().Message);
e.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
app.Logger.LogCritical(new EventId(0, "Unhandled exception"), ex,
"Unhandled exception on a non-request thread (terminating={IsTerminating}): {Message}",
e.IsTerminating, ex?.Message ?? e.ExceptionObject?.ToString());
};
// One deliberate Critical entry per application start. Critical is the one level no
// provider filters (file sink, console, SQL sink at Warning+), so a fds__debuglog row
// with CodeReference 'Application started' proves on every restart that the whole
// log pipeline (ADR 0024) is working end to end — and its absence is itself the alarm.
// Registered on ApplicationStarted so it is written once the host and every sink are up.
app.Lifetime.ApplicationStarted.Register(() =>
app.Logger.LogCritical(new EventId(0, "Application started"),
"Fuchs started: version={Version} environment={Environment} machine={Machine} pid={Pid}",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "?",
app.Environment.EnvironmentName, Environment.MachineName, Environment.ProcessId));
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/error"); app.UseExceptionHandler("/error");
+27 -4
View File
@@ -426,8 +426,20 @@ public class InvoiceService : IInvoiceService
{ {
byte[] ba; byte[] ba;
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); } try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); } catch (Exception ex)
if (ba.Length == 0) return Array.Empty<byte>(); {
_logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id);
return Array.Empty<byte>();
}
if (ba.Length == 0)
{
// Distinct from the catch above: the renderer returned normally but with no bytes, and
// callers only see the same empty array either way.
_logger.LogError(
"StoreInvoiceDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
invoice.Id, draft, userAccountId);
return Array.Empty<byte>();
}
var pl = new List<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -435,10 +447,21 @@ public class InvoiceService : IInvoiceService
SQL_VarChar("@Id", invoice.Id), SQL_VarChar("@Id", invoice.Id),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
bool r = await setSQLValue_async( // fds__setInvoiceFile writes WHERE [file] IS NULL and returns the updated Id via its
// OUTPUT clause, so an empty result set means the UPDATE matched nothing (already stored,
// wrong id, invoice gone) without any SQL error. Rows-affected cannot be used for this:
// the proc runs SET NOCOUNT ON, so ExecuteNonQuery reports -1 either way (ADR 0025).
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;", "EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!r) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (stored.Count == 0)
{
_logger.LogError(
"StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}",
invoice.Id, userAccountId);
return Array.Empty<byte>();
}
string fileName = invoice.InvoiceRegistration?.getString("DocumentName") string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf"; .ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
+30 -4
View File
@@ -74,8 +74,8 @@ public class ProcessWebComService : IComService
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email")); FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
act?.SetStatus(ActivityStatusCode.Error, "invalid-email"); act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged). // Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
await WriteAuditLogAsync(reference, "", "", default, false, await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
[$"Invalid recipient email address: '{email}'"]); default, false, [$"Invalid recipient email address: '{email}'"]);
return false; return false;
} }
@@ -86,7 +86,8 @@ public class ProcessWebComService : IComService
_logger.LogInformation( _logger.LogInformation(
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}", "[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email); reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]); await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
default, false, ["Service disabled – email not sent"]);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled")); FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
return false; return false;
} }
@@ -153,7 +154,8 @@ public class ProcessWebComService : IComService
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms", _logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
reference, success, sw.ElapsedMilliseconds); reference, success, sw.ElapsedMilliseconds);
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors); await WriteAuditLogAsync(reference, messageId, BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
success ? DateTime.UtcNow : default, success, errors);
return success; return success;
} }
@@ -308,6 +310,30 @@ public class ProcessWebComService : IComService
} }
} }
/// <summary>
/// Serialises the audit <c>config</c> stored in <c>fds__emaillog.config</c>: who the mail went
/// to, its subject and the attachment <em>names</em> — never the body or attachment bytes.
/// The shape (<c>subject</c>/<c>from</c>/<c>to[]</c>/<c>cc[]</c>/<c>bcc[]</c>/<c>attachments[]</c>,
/// addresses as <c>"Name" &lt;email&gt;</c>) is the one the legacy mailer wrote and that
/// <c>[dbo].[fds_wgd_lastEmails]</c> reads back with <c>OPENJSON</c>; an empty string here
/// made that dashboard widget fail for every row the new service logged.
/// Pure so the contract can be unit-tested without a database.
/// </summary>
internal static string BuildAuditConfig(string subject, string email, string? name, string? cc,
IEnumerable<string>? attachmentNames) =>
JsonConvert.SerializeObject(new
{
subject = subject ?? "",
from = "",
to = new[] { FormatAddress(email, name) },
cc = string.IsNullOrWhiteSpace(cc) ? Array.Empty<string>() : new[] { cc.Trim() },
bcc = Array.Empty<string>(),
attachments = (attachmentNames ?? []).ToArray()
});
private static string FormatAddress(string email, string? name) =>
string.IsNullOrWhiteSpace(name) ? (email ?? "") : $"\"{name.Trim()}\" <{email}>";
/// <summary> /// <summary>
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares /// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL: /// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
+23 -4
View File
@@ -139,8 +139,18 @@ public class ReminderService : IReminderService
{ {
byte[] ba; byte[] ba;
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); } try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); } catch (Exception ex)
if (ba.Length == 0) return Array.Empty<byte>(); {
_logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id);
return Array.Empty<byte>();
}
if (ba.Length == 0)
{
_logger.LogError(
"StoreReminderDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
reminder.Id, draft, userAccountId);
return Array.Empty<byte>();
}
var pl = new List<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -148,10 +158,19 @@ public class ReminderService : IReminderService
SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@authuser", userAccountId),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
bool r = await setSQLValue_async( // OUTPUT-clause row count, not rows-affected (SET NOCOUNT ON) — see the matching note in
// InvoiceService.StoreInvoiceDocumentFileAsync / ADR 0025.
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;", "EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!r) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (stored.Count == 0)
{
_logger.LogError(
"StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}",
reminder.Id, userAccountId);
return Array.Empty<byte>();
}
string fileName = reminder.ReminderRegistration?.getString("DocumentName") string fileName = reminder.ReminderRegistration?.getString("DocumentName")
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf"; .ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
+14 -3
View File
@@ -983,12 +983,23 @@ public static class FuchsPdf
/// PdfSharp 6+ no longer resolves system fonts (e.g. "Courier New") on its own — without this, /// PdfSharp 6+ no longer resolves system fonts (e.g. "Courier New") on its own — without this,
/// PdfDocumentRenderer.RenderDocument() throws "cannot be resolved for predefined error font". /// PdfDocumentRenderer.RenderDocument() throws "cannot be resolved for predefined error font".
/// </summary> /// </summary>
private static readonly Lock FontResolverLock = new();
/// <summary>
/// Thread-safe: PdfSharp throws "You must not change font resolver after it was once used" if
/// the setter runs a second time after any render has already used the resolver. A plain
/// check-then-set here would race under concurrent first renders (e.g. two requests, or two
/// tests, hitting this before either has set it) — both could pass the null check, and
/// whichever setter runs second would throw.
/// </summary>
private static void EnsureFontResolver() private static void EnsureFontResolver()
{ {
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null || if (PdfSharp.Fonts.GlobalFontSettings.FontResolver?.GetType() == typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver)) return;
lock (FontResolverLock)
{ {
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver(); if (PdfSharp.Fonts.GlobalFontSettings.FontResolver?.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
} }
} }
+22 -5
View File
@@ -123,7 +123,8 @@ $inv.eM = (r, re, opt) => {
and one handler that always tells the user something: the server's description and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there), header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */ a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */ /* Milliseconds. postXT only treats values below 100 as seconds — 120 would be 120 ms. */
$inv.pdfPreviewTimeout = 120000;
$inv.pdfPreviewFailed = function (jqXHR, textStatus) { $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; } if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim(); let desc = ((jqXHR || {}).internalText || '').trim();
@@ -132,13 +133,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -1562,6 +1577,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -1571,11 +1587,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -1585,7 +1602,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
@@ -38,6 +38,7 @@
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
+23 -5
View File
@@ -162,6 +162,7 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
@@ -702,7 +703,8 @@ $inv.eM = (r, re, opt) => {
and one handler that always tells the user something: the server's description and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there), header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */ a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */ /* Milliseconds. postXT only treats values below 100 as seconds — 120 would be 120 ms. */
$inv.pdfPreviewTimeout = 120000;
$inv.pdfPreviewFailed = function (jqXHR, textStatus) { $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; } if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim(); let desc = ((jqXHR || {}).internalText || '').trim();
@@ -711,13 +713,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -2141,6 +2157,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2150,11 +2167,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2164,7 +2182,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
File diff suppressed because one or more lines are too long
+23 -5
View File
@@ -162,6 +162,7 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
@@ -683,7 +684,8 @@ $inv.eM = (r, re, opt) => {
and one handler that always tells the user something: the server's description and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there), header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */ a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */ /* Milliseconds. postXT only treats values below 100 as seconds — 120 would be 120 ms. */
$inv.pdfPreviewTimeout = 120000;
$inv.pdfPreviewFailed = function (jqXHR, textStatus) { $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; } if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim(); let desc = ((jqXHR || {}).internalText || '').trim();
@@ -692,13 +694,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -2122,6 +2138,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2131,11 +2148,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2145,7 +2163,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -205,9 +205,12 @@ public class FdsMfr : IFdsMfr
if (fl != null) if (fl != null)
{ {
file = fl; file = fl;
string safeName = fln.Replace("<p>", "").Replace("</p>", "").Replace("=", "_") // Same sanitiser as the regenerate-on-miss path (ADR 0025) so a stored and a
.Replace(">", "_").Replace("|", "_").Replace("!", "_").Replace("<", "_").Replace("/", "_"); // regenerated document with the same DocumentName download under the same name.
return new FileInfo(Path.GetTempPath() + safeName); // Fallback to the report id: an empty DocumentName used to yield the temp-dir
// segment ("Temp") as the download name.
string safeName = FdsDocumentNames.SafeFileName(fln, reportid);
return new FileInfo(Path.Combine(Path.GetTempPath(), safeName));
} }
} }
return null; return null;
+27
View File
@@ -40,6 +40,33 @@ public static class FdsConfig
Current["Fds:MFR_host"] ?? ""; Current["Fds:MFR_host"] ?? "";
} }
/// <summary>
/// Download-filename sanitising shared by every path that hands a document to the browser —
/// stored documents (<see cref="FdsMfr.GetReportDoc"/>) and documents regenerated on a miss
/// (<c>IntranetController.HandleReportDoc</c>, ADR 0025) — so both produce the same name for the
/// same DocumentName. DocumentName is derived from the user-typed invoice title, so it can carry
/// path separators ("Wartung 2025/26"), stray editor markup and characters Windows rejects.
/// </summary>
public static class FdsDocumentNames
{
// '=' and '!' are legal filename characters, but the stored-document path replaced them
// long before the regenerate path existed; kept so unifying the two never loosens either.
private static readonly char[] Replaced =
[.. Path.GetInvalidFileNameChars(), '=', '!'];
/// <param name="documentName">Raw DocumentName (may be null, empty, or wrapped in &lt;p&gt;).</param>
/// <param name="fallback">Used when nothing usable remains after cleanup.</param>
public static string SafeFileName(string? documentName, string fallback)
{
string name = (documentName ?? "").Replace("<p>", "").Replace("</p>", "").Trim();
if (name.Length == 0) return fallback;
foreach (char c in Replaced)
name = name.Replace(c, '_');
name = name.Trim();
return name.Length == 0 ? fallback : name;
}
}
internal static class FdsShared internal static class FdsShared
{ {
internal static string FDSConnectionString() => FdsConfig.FDSConnectionString(); internal static string FDSConnectionString() => FdsConfig.FDSConnectionString();
+13
View File
@@ -37,5 +37,18 @@
which <c>Program.cs</c> calls once at startup before any <see cref="T:fds.FdsMfr"/> use. which <c>Program.cs</c> calls once at startup before any <see cref="T:fds.FdsMfr"/> use.
</summary> </summary>
</member> </member>
<member name="T:fds.FdsDocumentNames">
<summary>
Download-filename sanitising shared by every path that hands a document to the browser —
stored documents (<see cref="M:fds.FdsMfr.GetReportDoc(System.Byte[]@,System.String,System.Boolean)"/>) and documents regenerated on a miss
(<c>IntranetController.HandleReportDoc</c>, ADR 0025) — so both produce the same name for the
same DocumentName. DocumentName is derived from the user-typed invoice title, so it can carry
path separators ("Wartung 2025/26"), stray editor markup and characters Windows rejects.
</summary>
</member>
<member name="M:fds.FdsDocumentNames.SafeFileName(System.String,System.String)">
<param name="documentName">Raw DocumentName (may be null, empty, or wrapped in &lt;p&gt;).</param>
<param name="fallback">Used when nothing usable remains after cleanup.</param>
</member>
</members> </members>
</doc> </doc>
+5
View File
@@ -61,6 +61,7 @@
<Folder Include="dbo\Tables\" /> <Folder Include="dbo\Tables\" />
<Folder Include="dbo\Views\" /> <Folder Include="dbo\Views\" />
<Folder Include="Security\" /> <Folder Include="Security\" />
<Folder Include="Scripts\" />
<Folder Include="dbo\Functions\" /> <Folder Include="dbo\Functions\" />
<Folder Include="dbo\Stored Procedures\" /> <Folder Include="dbo\Stored Procedures\" />
<Folder Include="dbo\User Defined Types\" /> <Folder Include="dbo\User Defined Types\" />
@@ -199,6 +200,7 @@
<Build Include="dbo\Functions\fds__fn_invoice-srq_id.sql" /> <Build Include="dbo\Functions\fds__fn_invoice-srq_id.sql" />
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount_full.sql" /> <Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount_full.sql" />
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount.sql" /> <Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount.sql" />
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmounts.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" /> <Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" /> <Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_id.sql" /> <Build Include="dbo\Functions\fds__fn_invoice_id.sql" />
@@ -405,4 +407,7 @@
<Build Include="dbo\Functions\fds_wgd_lastEmails.sql" /> <Build Include="dbo\Functions\fds_wgd_lastEmails.sql" />
<Build Include="dbo\Functions\fds_wdg_MFRSync.sql" /> <Build Include="dbo\Functions\fds_wdg_MFRSync.sql" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="Scripts\2026-09-16_fis_widgets_set_based_payment_amounts.sql" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,31 @@
-- =============================================
-- One-off data migration (fis_widgets rows are data, not schema — run once per environment
-- after deploying the schema that adds [dbo].[fds__fn_InvoicePaymentAmounts] and the
-- [PaymentAmount]/[OpenAmount] columns of [dbo].[fds__fn_unpaidInvoices]).
--
-- Both widgets called the scalar [fds__fn_InvoicePaymentAmount](Id) once per invoice row. That
-- function declares a table variable, so SQL Server cannot inline it and re-runs the whole
-- banking TVF per row (~0.5 s each): wdg_invoices_overdue_sum took ~100 s over 187 unpaid
-- invoices and timed out the 90 s widget command; wdg_unbez_akonto took ~5 s (hidden by daycache).
-- Set-based replacements give identical totals in < 0.5 s.
-- Idempotent: each UPDATE only rewrites the old text and is a no-op once applied.
-- =============================================
SET NOCOUNT ON;
UPDATE [dbo].[fis_widgets]
SET [sql] = REPLACE([sql], N'[invoicebalance] - [dbo].[fds__fn_InvoicePaymentAmount](Id)', N'[OpenAmount]')
WHERE [short_name] = 'wdg_invoices_overdue_sum'
AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount](Id)%';
PRINT CONCAT('wdg_invoices_overdue_sum rows updated: ', @@ROWCOUNT);
UPDATE [dbo].[fis_widgets]
SET [sql] = REPLACE(REPLACE([sql]
, N'[dbo].[fds__fn_InvoicePaymentAmount]([id])', N'ISNULL(pay.[Amount], 0.0)')
, N'FROM [dbo].[fds__invoices] as i where', N'FROM [dbo].[fds__invoices] as i LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = i.[id] where')
WHERE [short_name] = 'wdg_unbez_akonto'
AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount]([[]id])%'
AND [sql] LIKE N'%FROM [[]dbo].[[]fds__invoices] as i where%';
PRINT CONCAT('wdg_unbez_akonto rows updated: ', @@ROWCOUNT);
-- Verify: nothing on the dashboard still calls the per-row scalar function.
SELECT [short_name], [sql] FROM [dbo].[fis_widgets] WHERE [sql] LIKE N'%fds__fn_InvoicePaymentAmount](%';
@@ -0,0 +1,23 @@
-- =============================================
-- Description: Set-based counterpart of the scalar [fds__fn_InvoicePaymentAmount]:
-- returns the summed (non-storno) banking payments for EVERY invoice in one pass
-- over [fds__fn_bankingtransactions]. Join it instead of calling the scalar
-- function per row — the scalar one declares a table variable, so SQL Server
-- cannot inline it and re-runs the whole banking TVF for each invoice
-- (~0.5 s × N rows; the overdue-sum widget timed out at 90 s with 187 rows).
-- Same semantics as the scalar version with an empty id list: all invoices,
-- no date filter, storno credits NOT included (see *_full for those).
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_InvoicePaymentAmounts]
(
)
RETURNS TABLE
AS
RETURN
(
SELECT [InvId] = bs.[InvId]
,[Amount] = CAST(SUM(ISNULL(bs.[Amount], 0.0)) as numeric(9,2))
FROM [dbo].[fds__fn_bankingtransactions](NULL, NULL, NULL, NULL, DEFAULT) as bs
WHERE bs.[InvId] IS NOT NULL
GROUP BY bs.[InvId]
)
@@ -1,7 +1,9 @@
-- ============================================= -- =============================================
-- Author: <Author,,Name> -- Description: Finalized, not-paid, not-canceled, not-storno'd invoices with a balance > 0.
-- Create date: <Create Date,,> -- [PaymentAmount] / [OpenAmount] come from one set-based pass over the banking
-- Description: <Description,,> -- transactions ([fds__fn_InvoicePaymentAmounts]) — use them instead of calling the
-- scalar [fds__fn_InvoicePaymentAmount](Id) per row, which re-runs the whole
-- banking TVF for every invoice and timed out the dashboard widgets.
-- ============================================= -- =============================================
CREATE FUNCTION [dbo].[fds__fn_unpaidInvoices] CREATE FUNCTION [dbo].[fds__fn_unpaidInvoices]
( (
@@ -28,6 +30,10 @@ RETURN
and InvoiceBalance > 0 and InvoiceBalance > 0
AND NOT exists (SELECT 0 FROM [dbo].[fds__invoice_details] as d where d.[StornoTo_InvId] = iv.[id]) AND NOT exists (SELECT 0 FROM [dbo].[fds__invoice_details] as d where d.[StornoTo_InvId] = iv.[id])
) )
SELECT * /*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */ SELECT inv.*
,[PaymentAmount] = ISNULL(pay.[Amount], 0.0)
,[OpenAmount] = CAST(inv.[InvoiceBalance] - ISNULL(pay.[Amount], 0.0) as numeric(10,2))
/*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */
FROM inv FROM inv
LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = inv.[Id]
) )
@@ -2,6 +2,10 @@
returns table returns table
as return as return
-- [config] is NOT NULL but a logging path may have stored '' (or otherwise non-JSON text).
-- OPENJSON/JSON_QUERY raise on that and took the whole widget down, so the JSON is normalised
-- once (invalid -> '{}') before anything parses it; a WHERE ISJSON() alone is not guaranteed to
-- be evaluated ahead of the CROSS APPLY.
SELECT TOP (@count) SELECT TOP (@count)
[Betreff] = oj.[subject], [Betreff] = oj.[subject],
[Empfänger] = (SELECT STRING_AGG( [Empfänger] = (SELECT STRING_AGG(
@@ -9,9 +13,10 @@ SELECT TOP (@count)
THEN SUBSTRING([value], CHARINDEX('<', [value]) + 1, CHARINDEX('>', [value]) - CHARINDEX('<', [value]) - 1) THEN SUBSTRING([value], CHARINDEX('<', [value]) + 1, CHARINDEX('>', [value]) - CHARINDEX('<', [value]) - 1)
ELSE [value] ELSE [value]
END, ', ') END, ', ')
FROM OPENJSON(JSON_QUERY(el.[config], '$.to'))), FROM OPENJSON(JSON_QUERY(j.[cfg], '$.to'))),
[attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(el.[config], '$.attachments'))), [attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(j.[cfg], '$.attachments'))),
[gesendet] = FORMAT(el.DateSent, 'dd.MM.yy', 'de-DE') [gesendet] = FORMAT(el.DateSent, 'dd.MM.yy', 'de-DE')
FROM [dbo].[fds__emaillog] as el FROM [dbo].[fds__emaillog] as el
CROSS APPLY OPENJSON(el.[config], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj CROSS APPLY (SELECT [cfg] = IIF(ISJSON(el.[config]) = 1, el.[config], N'{}')) as j
CROSS APPLY OPENJSON(j.[cfg], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj
order by [DateSent] desc order by [DateSent] desc
@@ -1,4 +1,8 @@
 
-- NOTE: Manual-only maintenance utility. Never called from application code (IntranetController /
-- InvoiceService) — run by hand against the database when needed. Ignore this proc when
-- investigating missing invoice PDFs via app logs: it clears [fds__invoices].[file] without going
-- through the app, so it leaves no application log entry.
-- Example usage: EXEC dbo.usp_BackupAndClearInvoiceFile @InvoiceID = 'R2025-0057'; -- Example usage: EXEC dbo.usp_BackupAndClearInvoiceFile @InvoiceID = 'R2025-0057';
@@ -1,7 +1,11 @@
-- ============================================= -- =============================================
-- Author: <Author,,Name> -- Description: mfr sync — merges one staged batch of Company entities into [mfr__companies].
-- Create date: <Create Date,,> -- @tblname is the bulk-copied staging table (same layout as [mfr__tt__companies]);
-- Description: <Description,,> -- [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] CREATE PROCEDURE [dbo].[mfr__updt__companies]
@tblname as nvarchar(50) @tblname as nvarchar(50)
@@ -14,7 +18,6 @@ BEGIN
BEGIN TRY BEGIN TRY
DECLARE @now datetime = GETUTCDATE();
DECLARE @tmp [dbo].[mfr__tt__companies]; DECLARE @tmp [dbo].[mfr__tt__companies];
DECLARE @tmp_cmd nvarchar(1000) = N'SELECT DISTINCT * FROM ' + @tblname DECLARE @tmp_cmd nvarchar(1000) = N'SELECT DISTINCT * FROM ' + @tblname
@@ -45,11 +48,15 @@ BEGIN
--OUTPUT deleted.*, @now INTO [dbo].[mfr__d_comments] --OUTPUT deleted.*, @now INTO [dbo].[mfr__d_comments]
FROM [dbo].[mfr__comments] as cm FROM [dbo].[mfr__comments] as cm
WHERE EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = cm.[CompanyId] AND ISNULL(cm.[ServiceRequestId],0) = 0); WHERE EXISTS (SELECT * FROM @tmp as tmp where tmp.[id] = cm.[CompanyId] AND ISNULL(cm.[ServiceRequestId],0) = 0);
END 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 MERGE [dbo].[mfr__companies] as TARGET
USING @tmp as SOURCE ON TARGET.[Id] = SOURCE.[Id] USING SOURCE ON TARGET.[Id] = SOURCE.[Id]
WHEN MATCHED AND (TARGET.[Version] <= SOURCE.[Version] OR TARGET.[DateModified] <= SOURCE.[DateModified]) THEN WHEN MATCHED AND (TARGET.[Version] <= SOURCE.[Version] OR TARGET.[DateModified] <= SOURCE.[DateModified]) THEN
UPDATE UPDATE
SET [Id] = SOURCE.[Id] SET [Id] = SOURCE.[Id]
@@ -16,6 +16,6 @@
[SupportMail] NVARCHAR (255) NULL, [SupportMail] NVARCHAR (255) NULL,
[QuickSearch] NVARCHAR (255) NULL, [QuickSearch] NVARCHAR (255) NULL,
[DateModified] DATETIME NOT NULL, [DateModified] DATETIME NOT NULL,
[CustomValues] NVARCHAR (MAX) NOT NULL, [CustomValues] NVARCHAR (MAX) NULL,
[MainContactId] BIGINT NOT NULL); [MainContactId] BIGINT NULL);
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}