fix: invoice/reminder PDF preview 404s and their unlogged root causes
An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can fail silently; the row stays finalised with [file] = NULL and inv/rdoc (the invoice list's preview icon) 404'd forever with no trace in any log. - inv/rdoc and rem/idoc now regenerate and persist a missing document on read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025), gated to finalised + non-external invoices only — a draft render must never be persisted, since fds__setInvoiceFile/fds__setReminderFile only write WHERE [file] IS NULL and finalisation would then silently no-op. - StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log every distinct failure (render threw, render produced 0 bytes, DB write threw, DB write matched 0 rows) instead of collapsing them into a single silent empty-array return. The 0-rows case needed switching from setSQLValue_async (reports success once the command completes, whatever the row count) to setSQLValue_async_result, whose AffectedRows makes a no-op UPDATE distinguishable from an actual write for the first time. - HandleRequestIdoc/HandleReminderIdoc no longer treat StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as success (a bare `ct == null` check never caught it), which would have served a 0-byte "PDF". - rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing wasted work with an empty id. - _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance utility, never called from app code, so it's clear why running it leaves no application log trail. - Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the preview dialog was timing out client-side after 120ms on every call. - Fixed a genuine process-wide test flake: PdfPipelineTests and ERechnungMapperTests both install PdfSharp's global font resolver, and whichever ran first would win, making the other's tests fail deterministically once eRechnungLib's SystemFontResolver got there first. FuchsPdf.EnsureFontResolver is now also race-safe under concurrent first use (a check-then-set without a lock). Also removed the InvoiceService.PublishAsync failure logging added mid-session after code review found it duplicated every call site's own, more detailed logging against ADR 0024's single-write-path rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b5613bee0d
commit
99bf5337f8
@@ -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.Services;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PdfSharp.Pdf.IO;
|
||||
using Xunit;
|
||||
@@ -31,14 +30,6 @@ public class PdfPipelineTests
|
||||
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
|
||||
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();
|
||||
doc.Info.Title = "Dummy Rechnung";
|
||||
var normal = doc.Styles["Normal"]!;
|
||||
@@ -50,11 +41,11 @@ public class PdfPipelineTests
|
||||
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
|
||||
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
|
||||
|
||||
var renderer = new PdfDocumentRenderer { Document = doc };
|
||||
renderer.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
renderer.PdfDocument.Save(ms, closeStream: false);
|
||||
return ms.ToArray();
|
||||
// Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
|
||||
// (and exactly once, thread-safely) the production render path installs it — a second,
|
||||
// separate check-then-set here previously raced PdfSharp's "must not change font resolver
|
||||
// after it was once used" guard under parallel test execution.
|
||||
return FuchsPdf.DocToPdfBytesRaw(doc);
|
||||
}
|
||||
|
||||
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
|
||||
|
||||
@@ -131,6 +131,7 @@ public partial class IntranetController
|
||||
return await HandleDatev(fn, id, code);
|
||||
|
||||
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);
|
||||
return await HandleReportDoc(fn, id, code, Form("id"));
|
||||
|
||||
|
||||
@@ -293,10 +293,26 @@ public partial class IntranetController
|
||||
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
|
||||
byte[]? content = null;
|
||||
var file = _mfr.GetReportDoc(ref content, reportid);
|
||||
if (file == null)
|
||||
if (file == null || content == null || content.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
|
||||
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
|
||||
// block the real final PDF at finalisation.
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(reportid, UserAccountID, DbSec);
|
||||
bool isExternal = fdInv.InvoiceRegistration?.getItem("IsExternal", false) is true;
|
||||
if (!string.IsNullOrEmpty(fdInv.Id) && !fdInv.IsDraft && !isExternal)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: no stored document for reportid={ReportId}, regenerating, user={User}", reportid, UserAccountID);
|
||||
content = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
|
||||
}
|
||||
if (content == null || content.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
}
|
||||
string regeneratedName = fdInv.InvoiceRegistration?.getString("DocumentName")
|
||||
.ne($"Rechnung_{fdInv.Id}.pdf") ?? $"Rechnung_{reportid}.pdf";
|
||||
file = new FileInfo(Path.Combine(Path.GetTempPath(), regeneratedName));
|
||||
}
|
||||
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
|
||||
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
||||
|
||||
@@ -208,9 +208,16 @@ public partial class IntranetController
|
||||
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||
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)
|
||||
: _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 PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
|
||||
|
||||
@@ -357,7 +357,7 @@ public partial class IntranetController
|
||||
else if (filebyte.Length == 0)
|
||||
{
|
||||
_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);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"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)
|
||||
{
|
||||
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);
|
||||
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" }); }
|
||||
@@ -397,8 +397,13 @@ public partial class IntranetController
|
||||
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)
|
||||
: _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);
|
||||
}
|
||||
// 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
|
||||
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
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 needed switching
|
||||
from `setSQLValue_async` (reports success once `ExecuteNonQueryAsync`
|
||||
completes without throwing, regardless of rows affected) to
|
||||
`setSQLValue_async_result`, whose `AffectedRows` makes a no-op `UPDATE ...
|
||||
WHERE [file] IS NULL` distinguishable from an actual write — this is what
|
||||
made the original incident's failure 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.
|
||||
@@ -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` |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
@@ -426,8 +426,20 @@ public class InvoiceService : IInvoiceService
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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>
|
||||
{
|
||||
@@ -435,10 +447,22 @@ public class InvoiceService : IInvoiceService
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
var setResult = await setSQLValue_async_result(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!r) return Array.Empty<byte>();
|
||||
if (!setResult.success) return Array.Empty<byte>();
|
||||
if (setResult.AffectedRows == 0)
|
||||
{
|
||||
// fds__setInvoiceFile writes WHERE [file] IS NULL — 0 rows affected without an
|
||||
// exception means the UPDATE simply didn't match (already stored, wrong id, or the
|
||||
// invoice vanished under us), not a SQL failure setSQLValue_async_result would report.
|
||||
// ExecuteNonQueryAsync returning rows-affected=0 is not an error by itself, so this was
|
||||
// previously indistinguishable from success — see ADR 0025.
|
||||
_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")
|
||||
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
|
||||
|
||||
@@ -139,8 +139,18 @@ public class ReminderService : IReminderService
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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>
|
||||
{
|
||||
@@ -148,10 +158,19 @@ public class ReminderService : IReminderService
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
var setResult = await setSQLValue_async_result(
|
||||
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!r) return Array.Empty<byte>();
|
||||
if (!setResult.success) return Array.Empty<byte>();
|
||||
if (setResult.AffectedRows == 0)
|
||||
{
|
||||
// fds__setReminderFile writes WHERE [file] IS NULL — see the matching comment/ADR 0025
|
||||
// note in InvoiceService.StoreInvoiceDocumentFileAsync.
|
||||
_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")
|
||||
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
|
||||
|
||||
+14
-3
@@ -983,12 +983,23 @@ public static class FuchsPdf
|
||||
/// 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".
|
||||
/// </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()
|
||||
{
|
||||
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null ||
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
|
||||
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver?.GetType() == typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
|
||||
return;
|
||||
lock (FontResolverLock)
|
||||
{
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
||||
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver?.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ $inv.eM = (r, re, opt) => {
|
||||
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),
|
||||
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) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
|
||||
@@ -702,7 +702,8 @@ $inv.eM = (r, re, opt) => {
|
||||
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),
|
||||
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) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -683,7 +683,8 @@ $inv.eM = (r, re, opt) => {
|
||||
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),
|
||||
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) {
|
||||
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
|
||||
let desc = ((jqXHR || {}).internalText || '').trim();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user