Compare commits
7
Commits
bcf1f2dbb6
...
86f6a60860
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f6a60860 | ||
|
|
944a5bb442 | ||
|
|
a60d4c2eaf | ||
|
|
dcfc459693 | ||
|
|
4cac109283 | ||
|
|
99bf5337f8 | ||
|
|
b5613bee0d |
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Vendored
+24
-1
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+15
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>= > | ! < /</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 })
|
||||||
|
{
|
||||||
|
fileName = file.Name;
|
||||||
|
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);
|
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
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.
|
||||||
+74
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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" <email></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:
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+13
-2
@@ -983,11 +983,22 @@ 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)
|
||||||
{
|
{
|
||||||
|
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver?.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
|
||||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 = () => {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
|
|||||||
@@ -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 <p>).</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();
|
||||||
|
|||||||
@@ -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 <p>).</param>
|
||||||
|
<param name="fallback">Used when nothing usable remains after cleanup.</param>
|
||||||
|
</member>
|
||||||
</members>
|
</members>
|
||||||
</doc>
|
</doc>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user