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:
Stefan
2026-09-16 21:07:24 +02:00
co-authored by Claude Sonnet 5
parent b5613bee0d
commit 99bf5337f8
18 changed files with 373 additions and 38 deletions
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Fuchs.Tests;
/// <summary>
/// Installs PdfSharp's global font resolver once, before any test runs.
/// </summary>
/// <remarks>
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
/// property of the code under test) would win for every other test in the process. Installing
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
/// outcome deterministic instead of order-dependent.
/// </remarks>
internal static class FontResolverTestBootstrap
{
[ModuleInitializer]
internal static void Install() =>
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
/// The method signals every failure with an empty array, so the three distinct causes — render
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
/// logged. Each must be logged individually, and none may reach the blob upload or the
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
/// connection so the write fails fast inside the OCORE helper.
/// </summary>
public class InvoiceServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<InvoiceService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
// try/catch (returning false) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceService(
new Fuchs_intranet(config),
pdf.Object, blob.Object, events.Object, erechnung.Object,
new NoHttp(), config, log);
return (svc, log, blob, events);
}
private static FdsInvoiceData Invoice(bool isFinal) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "W7VAL",
["InvoiceId"] = "R2026-0258",
["IsFinal"] = isFinal
}),
IsDraft = !isFinal
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("W7VAL", entry.Message);
Assert.Contains($"draft={draft}", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("font resolver exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
{
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
// so neither render message may appear, and the PDF must not be archived to blob storage
// as if the primary DB write had succeeded.
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+5 -14
View File
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.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.
+1 -1
View File
@@ -272,7 +272,7 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|---|---|---|---|
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
| **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 |
+28 -4
View File
@@ -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";
+23 -4
View File
@@ -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
View File
@@ -983,12 +983,23 @@ public static class FuchsPdf
/// PdfSharp 6+ no longer resolves system fonts (e.g. "Courier New") on its own — without this,
/// 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();
}
}
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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();
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -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();
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';