Refactor stored procedure and update project structure
Playwright Tests / test (push) Has been cancelled

- Modified the stored procedure `fds__admin_getReportCatalog.sql` to use the correct schema for `all_objects`.
- Added new folders and projects for `eRechnungLib` in the solution file `Fuchs_Intranet.slnx`, including validation and test projects.
- Updated submodule reference for `OCORE`.
- Added new submodule `eRechnungLib` with initial commit.
This commit is contained in:
2026-07-06 00:01:35 +02:00
parent daac828c19
commit 4abf81cd7d
27 changed files with 1544 additions and 94 deletions
@@ -235,7 +235,7 @@ public partial class IntranetController
}
default:
return Ok();
return await JSONAsync(new { ok = true });
}
}
@@ -36,7 +36,7 @@ public partial class IntranetController
StdParamlist(SQL_VarChar("@Id", invoiceId)),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
return ok ? Ok() : StatusCode(500);
return ok ? await JSONAsync(new { ok = true }) : StatusCode(500);
}
case "setupd":
@@ -50,7 +50,7 @@ public partial class IntranetController
StdParamlist(SQL_VarChar("@Id", invoiceId)),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
return ok ? Ok() : StatusCode(500);
return ok ? await JSONAsync(new { ok = true }) : StatusCode(500);
}
case "setvat":
@@ -72,7 +72,7 @@ public partial class IntranetController
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
if (!string.IsNullOrEmpty(sqlEx))
_logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID);
return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx });
return string.IsNullOrEmpty(sqlEx) ? await JSONAsync(new { ok = true }) : StatusCode(500, new { error = sqlEx });
}
case "sis":
@@ -94,7 +94,7 @@ public partial class IntranetController
}
else
await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID);
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
}
case "pget":
@@ -156,11 +156,11 @@ public partial class IntranetController
using (var mfr = _mfrFactory.Create())
await mfr.Update__entitytable(EntityTypes.Invoice,
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
return Ok();
return await JSONAsync(new { ok = true });
default:
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
return Ok();
return await JSONAsync(new { ok = true });
}
}
}
@@ -58,7 +58,7 @@ public partial class IntranetController
_logger.LogInformation("HandleInvoicePget reset complete for tgtid={TgtId} invoices={InvCount} serviceRequests={SrqCount} user={User}",
tgtid, invIds.Count, srqIds.Count, UserAccountID);
}
return Ok();
return await JSONAsync(new { ok = true });
}
private async Task<IActionResult> HandleInvoiceGet(string fn, string id, string code)
@@ -66,7 +66,7 @@ public partial class IntranetController
await _events.ReminderIssueAsync(
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
UserAccountID, Form("id"));
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
}
case "rdoc":
@@ -98,7 +98,7 @@ public partial class IntranetController
});
}
default: return Ok();
default: return await JSONAsync(new { ok = true });
}
}
@@ -160,7 +160,7 @@ public partial class IntranetController
$"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.",
UserAccountID, remId);
}
return Ok();
return await JSONAsync(new { ok = true });
}
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
}
@@ -223,7 +223,7 @@ public partial class IntranetController
UserAccountID, remId);
}
}
return Ok();
return await JSONAsync(new { ok = true });
}
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
}
@@ -92,13 +92,13 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
Security: DbSec, options: SqlOpt(fn, id, code));
return Ok();
return await JSONAsync(new { ok = true });
case "sconf": return await HandleRequestSconf(fn, id, code);
case "idoc": return await HandleRequestIdoc(fn, id, code);
case "resend": return await HandleRequestResend(fn, id, code);
default: return Ok();
default: return await JSONAsync(new { ok = true });
}
}
@@ -165,7 +165,13 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestPget(string fn, string id, string code)
{
if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid)) return BadRequest400();
if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid))
{
_logger.LogWarning("HandleRequestPget: missing/invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID);
return BadRequest400();
}
_logger.LogDebug("HandleRequestPget tgtid={TgtId} user={User}", tgtid, UserAccountID);
var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fds__getRequestTreeIds](@srqid);",
_intranet.Intranet__SQLConnectionString,
@@ -181,15 +187,19 @@ public partial class IntranetController
if (iid > 0 && !ids.Contains(iid)) ids.Add(iid);
}
}
_logger.LogDebug("HandleRequestPget tgtid={TgtId} resolved {Count} related ids: {Ids}", tgtid, ids.Count, string.Join(",", ids));
var schemaDic = new Dictionary<string, fds.FdsMfrClient.DatabaseSchema>
{
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
};
using var mfr = _mfrFactory.Create();
await mfr.Update__entitytable(EntityTypes.ServiceRequest,
bool ok = await mfr.Update__entitytable(EntityTypes.ServiceRequest,
fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic);
return Ok();
_logger.LogInformation("HandleRequestPget MFR update complete tgtid={TgtId} ids={Count} success={Success} user={User}",
tgtid, ids.Count, ok, UserAccountID);
return await JSONAsync(new { ok });
}
private async Task<IActionResult> HandleRequestGet(string fn, string id, string code)
@@ -321,7 +331,7 @@ public partial class IntranetController
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
UserAccountID, invId);
}
return Ok();
return await JSONAsync(new { ok = true });
}
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
}
@@ -381,7 +391,7 @@ public partial class IntranetController
UserAccountID, invId);
}
}
return Ok();
return await JSONAsync(new { ok = true });
}
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
}
+11 -11
View File
@@ -144,7 +144,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
IActionResult? result = fn.ToLower() switch
{
"ping" => Ok(),
"ping" => await JSONAsync(new { ok = true }),
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
"todos" => new PhysicalFileResult(
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
@@ -168,7 +168,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_logger.LogWarning("No handler matched fn={Fn}", fn);
else
_logger.LogDebug("Do completed fn={Fn}/{Id} result={ResultType}", fn, id, result.GetType().Name);
return result ?? Ok();
return result ?? await JSONAsync(new { ok = true });
}
catch (Exception ex)
{
@@ -255,7 +255,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
UserAccountID, HttpContext.Connection.RemoteIpAddress);
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
return Ok();
return await JSONAsync(new { ok = true });
}
// ── Password helpers ──────────────────────────────────────────────────────
@@ -285,7 +285,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogDebug("HandleSendPasswordCode: no SMS sent for email={Email} (user not found, name mismatch, no mobile, or localhost)", email);
}
return Ok(); // always OK to prevent enumeration
return await JSONAsync(new { ok = true }); // always OK to prevent enumeration
}
private async Task<IActionResult> HandleSendPassword(string fn, string id, string code)
@@ -323,7 +323,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogWarning("HandleSendPassword: TOTP verification failed for email={Email}", email);
}
return Ok();
return await JSONAsync(new { ok = true });
}
private async Task<IActionResult> HandleAccount(string fn, string id, string code)
@@ -345,7 +345,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogDebug("HandleAccount sms: no SMS sent for user={User} (no mobile or localhost)", UserAccountID);
}
return Ok();
return await JSONAsync(new { ok = true });
case "changepassword":
string? npw = Request.Form["npw"];
@@ -400,10 +400,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
},
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("Password changed successfully for user={User}", UserAccountID);
return Ok();
return await JSONAsync(new { ok = true });
}
_logger.LogWarning("HandleAccount unknown action={Action} user={User}", id, UserAccountID);
return Ok();
return await JSONAsync(new { ok = true });
}
private async Task<IActionResult> HandleMfr(string fn, string id, string code)
@@ -429,7 +429,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
}
_logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}",
UserAccountID, UserIdent.Authorization);
return Ok();
return await JSONAsync(new { ok = true });
}
private async Task<IActionResult> HandleMfrUpdate(string fn, string id, string code)
@@ -444,7 +444,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
using var mfrSingle = _mfrFactory.Create();
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
return Ok();
return await JSONAsync(new { ok = true });
}
if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"]))
{
@@ -453,7 +453,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
using var mfr = _mfrFactory.Create();
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
return Ok();
return await JSONAsync(new { ok = true });
}
_logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID);
return BadRequest400();
@@ -0,0 +1,84 @@
---
status: Accepted
date: 2026-07-05
applyTo:
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/FuchsPdfService.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/ReminderService.cs"
- "eRechnungLib/**"
supersededBy: ""
---
# 0005 — PDF generation, rendering, and eRechnung output
## Context
Fuchs produces letters, invoices, and reminders as PDFs. The layout is a faithful
port of the legacy VB module `fuchs_fds_pdf.vb` (letterhead, DIN address window,
admin block, four-block footer with page numbers, invoice item table, GiroCode).
The port had silently drifted — wrong letterhead image filenames (`image1.png`
instead of the shipped `image1.jpeg`, which `AddHeaderImage` skips via
`File.Exists`), a too-small bottom margin, and a reworked footer/admin block — so
generated PDFs (e.g. the `sprep` invoice preview) rendered broken.
Separately, German B2B/B2G invoicing now requires **eRechnung** (structured
electronic invoices). The company direction is that **all invoices are emitted as
eRechnung**, not just human-readable PDFs.
Rendering also depends on **Spire.PDF** (commercial, licensed) for PDF/A
conversion and rasterising PDFs to preview images.
## Decision
- **PDF layout stays a 1:1 port of the legacy `fuchs_fds_pdf.vb`.** `FuchsPdf`
(MigraDoc/PdfSharp) is the single source of the visual layout. When changing
the letter/invoice/reminder layout, compare against the legacy module and keep
the letterhead assets (`Fuchs/Data/image1-3.jpeg`, `image4.png`, `overlay.png`),
margins, sender line, label-over-value admin block, absolutely-positioned
four-block footer, and `Seite X von Y` page numbers aligned with it. Reference
the shipped asset filenames exactly — `AddHeaderImage` no-ops on a missing file,
so a wrong extension silently drops a logo.
- **Rendering pipeline:** `FuchsPdf.DocToPdfBytes` renders MigraDoc → PDF and
post-processes to PDF/A; `DocToImageCollection` / `BytesToImageCollection`
rasterise via Spire for the on-screen invoice preview (`sprep`/`sedit`). The
OCORE `OCOREFontResolver` must be installed before any PdfSharp rendering.
- **Spire license comes from a managed secret.** `FuchsPdfService` reads the
license from configuration key `SpirePdf_License` (Key Vault secret
`fuchs--SpirePdf-License`, registered in `ManagedSecretKeys`) and passes it to
`FuchsPdf.SetLicense(key)`. An embedded fallback key keeps local/dev rendering
working without Key Vault.
- **eRechnung via `eRechnungLib`.** The `eRechnungLib` submodule is the single
library for structured invoices. Invoices are to be produced as eRechnung:
build an `eRechnungLib.Model.Invoice` from the Fuchs invoice data, then
`EInvoice.CreateInvoice(model).ToZugferd(ZugferdProfile.EN16931, visualPdfBytes)`
to embed the CII XML into the FuchsPdf-rendered visual PDF (ZUGFeRD/Factur-X
hybrid PDF/A-3), or `ToXRechnung(...)` for pure UBL/CII XML. The visual PDF is
the FuchsPdf output — the two layers stay consistent (same amounts/parties).
Default `ConversionOptions` runs model + XSD validation; use `StrictValidation`
when a malformed invoice must withhold output rather than ship with findings.
## Consequences
- Layout edits must be validated against the legacy reference and the shipped
`Data/` assets; do not invent new positions/sizes. The pipeline test
`Fuchs.Tests/PdfPipelineTests.cs` exercises the full chain (PdfSharp visual PDF
→ Spire preview images → eRechnung hybrid/XML) and must stay green.
- Do **not** upgrade Spire.PDF beyond 8.10.5 (see project libraries rule). The
license must never be hard-coded in new code paths — read it from
`SpirePdf_License`.
- Wiring the app's invoice flow to emit eRechnung is the follow-up: map
`FdsInvoiceData`/`InvoiceRegistration``eRechnungLib.Model.Invoice`
(parties, lines, VAT breakdown, payment/IBAN, buyer reference, seller
electronic address) and persist/deliver the ZUGFeRD PDF and/or XRechnung XML.
- eRechnungLib depends only on open-source libraries (PDFsharp/MigraDoc; optional
SaxonCS-HE for Schematron) — no new commercial dependency for the structured
output itself.
## Alternatives considered
- **Hand-rolling ZUGFeRD/XRechnung XML** in Fuchs: rejected — EN 16931 + CIUS
validation, multiple profiles/syntaxes, and PDF/A-3 embedding are error-prone;
a dedicated, validated library is safer.
- **Rewriting the PDF layout from scratch** rather than porting the legacy module:
rejected — the letterhead is a fixed corporate design; the legacy VB is the
authoritative spec, so faithful porting avoids visual regressions.
- **Bundling a Spire license file / hard-coding the key**: rejected in favor of
the managed-secret path so the production key is centrally rotated and never
committed, with the embedded key only as a dev fallback.
+10
View File
@@ -37,6 +37,14 @@ public class Program
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
builder.AddSecretManagement();
// Apply the Spire.PDF license as early as possible — Spire evaluates its license
// lazily on the first PDF operation per process and caches the result, so it must be
// set before any Spire use (self-test, first render) or the evaluation watermark sticks
// for the whole process. Sourced from the SpirePdf-License managed secret (config key
// SpirePdf_License, plus tolerated spelling variants); falls back to the embedded key.
FuchsPdf.SetLicense(
FuchsPdfService.ResolveLicenseFromConfiguration(builder.Configuration, out _));
// Assemble connection strings from templates + resolved credentials.
// In Development, "_Dev"-suffixed credential keys are preferred so a reachable
// Key Vault can never override them with production DB credentials.
@@ -87,8 +95,10 @@ public class Program
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
builder.Services.AddHttpClient("ProcessWebMailer");
builder.Services.AddScoped<IComService, ProcessWebComService>();
builder.Services.AddHostedService<StartupSelfTestService>();
// Business services (DI migration — replaces the static helper / Active-Record pattern)
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
+81 -3
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -13,13 +14,90 @@ namespace Fuchs.Services;
/// </summary>
public class FuchsPdfService : IPdfService
{
/// <summary>
/// Canonical configuration key holding the Spire.PDF license. Sourced from the
/// <c>SpirePdf-License</c> managed secret (Key Vault name <c>fuchs--SpirePdf-License</c>):
/// the secret-management layer strips the <c>fuchs--</c> app prefix and maps <c>-</c> to
/// <c>_</c> per segment, so <c>SpirePdf-License</c> surfaces here as <c>SpirePdf_License</c>.
/// </summary>
internal const string LicenseConfigKey = "SpirePdf_License";
/// <summary>
/// Placeholder appsettings.json carries for the managed secret until Key Vault (or the
/// DPAPI cache) supplies the real value. Treated as "no license configured" so the embedded
/// fallback key is used instead of applying this literal as a bogus Spire license key —
/// mirrors the convention in <see cref="AzureBlobStorageService"/>.
/// </summary>
internal const string UnloadedSecretPlaceholder = "MANAGED_BY_KEYVAULT";
/// <summary>
/// Every config-key spelling the license can realistically surface under, tried in order.
/// The managed-secret mapping yields <see cref="LicenseConfigKey"/>; the other variants cover
/// a value provided verbatim (appsettings), a <c>:</c>-hierarchy, or an app-prefixed key.
/// </summary>
internal static readonly string[] LicenseConfigKeyCandidates =
{
LicenseConfigKey, // SpirePdf_License (managed-secret mapping)
"SpirePdf-License", // verbatim, e.g. appsettings.Development.json
"SpirePdf:License", // ':'-hierarchy variant
"SpirePdfLicense", // no separator
"fuchs:SpirePdf-License", // default KV manager on the full secret name
"Fuchs:SpirePdf_License",
};
/// <summary>
/// Resolves the Spire license value from configuration, tolerating the different key spellings
/// the secret can surface under. Returns <see langword="null"/> when none carry a value;
/// <paramref name="matchedKey"/> reports which candidate matched (or <see langword="null"/>).
/// </summary>
internal static string? ResolveLicenseFromConfiguration(IConfiguration configuration, out string? matchedKey)
{
foreach (var key in LicenseConfigKeyCandidates)
{
string? value = configuration[key];
if (!string.IsNullOrWhiteSpace(value) &&
!string.Equals(value, UnloadedSecretPlaceholder, StringComparison.Ordinal))
{
matchedKey = key;
return value;
}
}
matchedKey = null;
return null;
}
/// <summary>Config keys that look Spire-related, for diagnostics when no candidate matched.</summary>
internal static IEnumerable<string> SpireLikeConfigKeys(IConfiguration configuration) =>
configuration.AsEnumerable()
.Where(kv => kv.Value is not null &&
kv.Key.Contains("spire", StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Key)
.Distinct(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<FuchsPdfService> _logger;
public FuchsPdfService(ILogger<FuchsPdfService> logger)
public FuchsPdfService(ILogger<FuchsPdfService> logger, IConfiguration configuration)
{
_logger = logger;
FuchsPdf.SetLicense();
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
// The license is normally applied once at startup (Program.cs) before any Spire use;
// re-applying here is a harmless safety net. If the managed secret is missing, the
// embedded fallback key is used — which does NOT license current Spire.PDF and leaves
// an evaluation watermark on rendered PDFs, so surface that as a warning.
string? licenseKey = ResolveLicenseFromConfiguration(configuration, out string? matchedKey);
FuchsPdf.SetLicense(licenseKey);
if (string.IsNullOrWhiteSpace(licenseKey))
{
var spireKeys = SpireLikeConfigKeys(configuration).ToArray();
_logger.LogWarning(
"Spire.PDF license not found under any known config key ({Candidates}). " +
"Config keys containing 'spire': [{FoundKeys}]. Using the embedded fallback key — " +
"rendered PDFs may carry the Spire evaluation watermark. Ensure the Key Vault secret " +
"'fuchs--SpirePdf-License' is present and reachable, or set it in appsettings.Development.json.",
string.Join(", ", LicenseConfigKeyCandidates),
spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)");
}
else
_logger.LogInformation("Spire.PDF license applied from config key '{MatchedKey}'.", matchedKey);
}
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
+345
View File
@@ -0,0 +1,345 @@
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// One-shot startup self-test that can verify Key Vault connectivity and optionally
/// send a startup probe email. This service never throws to avoid blocking app startup.
/// </summary>
public class StartupSelfTestService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
private readonly StartupSelfTestSettings _settings;
private readonly ILogger<StartupSelfTestService> _logger;
public StartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
ILogger<StartupSelfTestService> logger)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_settings = settings.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
=> await RunOnceAsync(stoppingToken);
internal async Task RunOnceAsync(CancellationToken stoppingToken)
{
if (!_settings.Enabled)
{
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
return;
}
bool keyVaultOk = true;
bool databaseOk = true;
bool mfrOk = true;
bool mailerOk = true;
bool pdfLicenseOk = true;
try
{
if (_settings.CheckKeyVault)
{
try
{
keyVaultOk = await ProbeKeyVaultAsync(stoppingToken);
}
catch (Exception ex)
{
keyVaultOk = false;
_logger.LogWarning(ex, "Startup Key Vault check failed with an exception.");
}
}
if (_settings.CheckDatabase)
{
try
{
databaseOk = await ProbeDatabaseAsync(stoppingToken);
}
catch (Exception ex)
{
databaseOk = false;
_logger.LogWarning(ex, "Startup database check failed with an exception.");
}
}
if (_settings.CheckMfr)
{
try
{
mfrOk = await ProbeMfrAsync(stoppingToken);
}
catch (Exception ex)
{
mfrOk = false;
_logger.LogWarning(ex, "Startup MFR check failed with an exception.");
}
}
if (_settings.CheckPdfLicense)
{
try
{
pdfLicenseOk = await ProbePdfLicenseAsync(stoppingToken);
}
catch (Exception ex)
{
pdfLicenseOk = false;
_logger.LogWarning(ex, "Startup PDF license check failed with an exception.");
}
}
if (_settings.SendStartupEmail)
{
try
{
mailerOk = await SendStartupEmailAsync(stoppingToken);
}
catch (Exception ex)
{
mailerOk = false;
_logger.LogWarning(ex, "Startup mailer check failed with an exception.");
}
}
_logger.LogInformation(
"Startup self-test completed. KeyVaultOk={KeyVaultOk}, DatabaseOk={DatabaseOk}, MfrOk={MfrOk}, MailerOk={MailerOk}, PdfLicenseOk={PdfLicenseOk}",
keyVaultOk,
databaseOk,
mfrOk,
mailerOk,
pdfLicenseOk);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Startup self-test canceled.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Startup self-test failed unexpectedly.");
}
}
protected virtual async Task<bool> ProbeDatabaseAsync(CancellationToken cancellationToken)
{
string? connectionString = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(connectionString))
{
_logger.LogWarning("Startup database check skipped - ConnectionStrings:fuchs_fds_ConnectionString is empty.");
return false;
}
try
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand("SELECT 1;", connection);
object? scalar = await command.ExecuteScalarAsync(cancellationToken);
bool ok = scalar is not null && scalar.ToString() == "1";
if (!ok)
{
_logger.LogWarning("Startup database check failed - SELECT 1 returned '{Value}'.", scalar);
return false;
}
_logger.LogInformation("Startup database check succeeded.");
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup database check failed.");
return false;
}
}
protected virtual async Task<bool> ProbeMfrAsync(CancellationToken cancellationToken)
{
try
{
var factory = _serviceProvider.GetService<IMfrClientFactory>();
if (factory is null)
{
_logger.LogWarning("Startup MFR check skipped - IMfrClientFactory is not registered.");
return false;
}
using var client = factory.Create();
string entities = await client.GetEntities(throwErrorIfNotOk: true);
if (string.IsNullOrWhiteSpace(entities))
{
_logger.LogWarning("Startup MFR check failed - empty response.");
return false;
}
_logger.LogInformation("Startup MFR check succeeded.");
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup MFR check failed.");
return false;
}
}
/// <summary>
/// Verifies the Spire.PDF license: the license string must be configured (present and
/// non-empty) and Spire.PDF must actually be licensed. Spire exposes no public validity
/// API, so the licensed state is probed by creating a tiny document and checking the
/// output for the evaluation watermark it stamps when unlicensed.
/// </summary>
protected virtual async Task<bool> ProbePdfLicenseAsync(CancellationToken cancellationToken)
{
string? licenseKey = FuchsPdfService.ResolveLicenseFromConfiguration(_configuration, out string? matchedKey);
if (string.IsNullOrWhiteSpace(licenseKey))
{
var spireKeys = FuchsPdfService.SpireLikeConfigKeys(_configuration).ToArray();
_logger.LogWarning(
"Startup PDF license check failed - no license found under any known config key ({Candidates}). " +
"Config keys containing 'spire': [{FoundKeys}]. Rendered PDFs will carry the Spire evaluation watermark.",
string.Join(", ", FuchsPdfService.LicenseConfigKeyCandidates),
spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)");
return false;
}
_logger.LogInformation("Startup PDF license check - license found under config key '{MatchedKey}'.", matchedKey);
// Ensure the configured key is applied, then confirm Spire is not in evaluation mode.
FuchsPdf.SetLicense(licenseKey);
bool licensed = await Task.Run(SpirePdfIsLicensed, cancellationToken);
if (licensed)
_logger.LogInformation("Startup PDF license check succeeded - Spire.PDF is licensed.");
else
_logger.LogWarning(
"Startup PDF license check failed - Spire.PDF is in evaluation mode. The configured " +
"license key was rejected or does not cover this Spire.PDF version.");
return licensed;
}
/// <summary>
/// Returns <see langword="true"/> when Spire.PDF is licensed. Detects the evaluation edition
/// by rendering a minimal document and checking the extracted text for the watermark Spire
/// stamps on documents it creates while unlicensed.
/// </summary>
internal static bool SpirePdfIsLicensed()
{
try
{
using var doc = new Spire.Pdf.PdfDocument();
var page = doc.Pages.Add();
page.Canvas.DrawString(
"license probe",
new Spire.Pdf.Graphics.PdfFont(Spire.Pdf.Graphics.PdfFontFamily.Helvetica, 10f),
Spire.Pdf.Graphics.PdfBrushes.Black,
10f, 10f);
using var ms = new MemoryStream();
doc.SaveToStream(ms, Spire.Pdf.FileFormat.PDF);
ms.Position = 0;
using var check = new Spire.Pdf.PdfDocument();
check.LoadFromStream(ms);
string text = check.Pages[0].ExtractText();
return !text.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)
&& !text.Contains("created with Spire.PDF", StringComparison.OrdinalIgnoreCase);
}
catch
{
// A malformed/rejected license key makes Spire throw during validation on save;
// any failure to produce a clean licensed document means "not licensed".
return false;
}
}
protected virtual async Task<bool> ProbeKeyVaultAsync(CancellationToken cancellationToken)
{
string appName = _configuration["SecretManagement:AppName"] ?? "";
string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0)
{
_logger.LogWarning("Startup Key Vault check skipped - SecretManagement settings are incomplete.");
return false;
}
var secretClient = _serviceProvider.GetService<SecretClient>();
if (secretClient is null)
{
_logger.LogWarning("Startup Key Vault check skipped - SecretClient is not registered.");
return false;
}
string probeName = $"{appName}--{managedKeys[0]}";
try
{
KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, cancellationToken);
if (string.IsNullOrWhiteSpace(secret.Value))
{
_logger.LogWarning("Startup Key Vault check failed - secret '{SecretName}' is empty.", probeName);
return false;
}
_logger.LogInformation("Startup Key Vault check succeeded using '{SecretName}'.", probeName);
return true;
}
catch (RequestFailedException ex)
{
_logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}' with status {Status}.", probeName, ex.Status);
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}'.", probeName);
return false;
}
}
protected virtual async Task<bool> SendStartupEmailAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.StartupEmailRecipient))
{
_logger.LogWarning("Startup mailer check skipped - StartupEmailRecipient is empty.");
return false;
}
using var scope = _serviceProvider.CreateScope();
var comService = scope.ServiceProvider.GetRequiredService<IComService>();
string subject = $"[Startup] Fuchs Intranet started on {Environment.MachineName}";
string html = $"<p>Fuchs Intranet startup probe.</p>" +
$"<p>UTC: {DateTimeOffset.UtcNow:O}<br/>Machine: {Environment.MachineName}</p>";
bool sent = await comService.SendEmailAsync(
"startup_probe",
subject,
html,
_settings.StartupEmailRecipient,
_settings.StartupEmailRecipientName,
attachments: null);
if (!sent)
{
_logger.LogWarning("Startup mailer check failed - probe email was not accepted by IComService.");
return false;
}
_logger.LogInformation("Startup mailer check succeeded.");
return true;
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace Fuchs.Services;
/// <summary>
/// Optional one-shot startup self-test settings, bound from "Fuchs:StartupChecks".
/// Disabled by default to avoid accidental startup emails in production.
/// </summary>
public class StartupSelfTestSettings
{
public bool Enabled { get; set; } = false;
/// <summary>
/// When enabled, verifies Key Vault access by reading one managed secret.
/// </summary>
public bool CheckKeyVault { get; set; } = true;
/// <summary>
/// When enabled, verifies SQL database connectivity by executing SELECT 1.
/// </summary>
public bool CheckDatabase { get; set; } = true;
/// <summary>
/// When enabled, verifies MFR API connectivity using the configured client credentials.
/// </summary>
public bool CheckMfr { get; set; } = true;
/// <summary>
/// When enabled, verifies that a Spire.PDF license string is configured (present and
/// non-empty) and that Spire.PDF is actually licensed (not running in evaluation mode).
/// </summary>
public bool CheckPdfLicense { get; set; } = true;
/// <summary>
/// When enabled, sends a startup probe email via <see cref="IComService"/>.
/// This applies to all environments, including Production.
/// </summary>
public bool SendStartupEmail { get; set; } = false;
/// <summary>
/// Recipient address for startup probe emails.
/// </summary>
public string StartupEmailRecipient { get; set; } = "";
/// <summary>
/// Recipient display name for startup probe emails.
/// </summary>
public string StartupEmailRecipientName { get; set; } = "Startup Monitor";
}
+12 -3
View File
@@ -6,15 +6,24 @@
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
"Default": "Debug",
"Microsoft.AspNetCore": "Debug",
"Microsoft.Hosting.Lifetime": "Debug",
"fds": "Debug",
"Fuchs.Controllers": "Debug"
}
},
"Fuchs": {
"FDS_Intranet_DebugState": true,
"DevAutoLogin": true,
"DevAutoLoginEmail": "info@processweb.de",
"StartupChecks": {
"Enabled": true,
"CheckKeyVault": false,
"CheckDatabase": false,
"CheckMfr": false,
"CheckPdfLicense": true
},
"Email": {
"OverrideRecipient": "service@emails.processweb.de"
},
+13 -2
View File
@@ -13,7 +13,8 @@
"Fuchs--fuchs-captcha-TOTP",
"Fuchs--fuchs-intranet-TOTP",
"Fds--MFR-UserName",
"Fds--MFR-Password"
"Fds--MFR-Password",
"SpirePdf-License"
]
},
"Logging": {
@@ -23,6 +24,7 @@
}
},
"AllowedHosts": "*",
"SpirePdf_License": "MANAGED_BY_KEYVAULT",
"ConnectionStrings": {
"fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID={username};password='{password}';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
"fuchs_fds_username": "MANAGED_BY_KEYVAULT",
@@ -40,13 +42,22 @@
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
"Mailer": {
"BaseUrl": "https://api.processweb.de",
"AccountId": "",
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
"Token": "MANAGED_BY_KEYVAULT",
"Enabled": false
},
"Email": {
"OverrideRecipient": ""
},
"StartupChecks": {
"Enabled": false,
"CheckKeyVault": true,
"CheckDatabase": true,
"CheckMfr": true,
"SendStartupEmail": true,
"StartupEmailRecipient": "",
"StartupEmailRecipientName": "Startup Monitor"
},
"AzureStorage": {
"Enabled": false,
"InvoiceContainer": "fuchs-invoices",
+196 -50
View File
@@ -22,8 +22,23 @@ public static class FuchsPdf
public const string ProjectAbbreviation = "fuchs";
// ── Spire license ─────────────────────────────────────────────────────────
public static void SetLicense() =>
Spire.License.LicenseProvider.SetLicenseKey(
/// <summary>
/// Applies the Spire.PDF license. The key is supplied by the caller from the
/// <c>SpirePdf-License</c> managed secret (config key <c>SpirePdf_License</c>);
/// when no key is provided the embedded fallback key is used so PDF rendering
/// still works in local/dev setups without Key Vault access.
/// </summary>
public static void SetLicense(string? licenseKey = null) =>
Spire.License.LicenseProvider.SetLicenseKey(ResolveLicenseKey(licenseKey));
/// <summary>
/// Chooses the effective Spire license key: the managed-secret value when present,
/// otherwise the embedded fallback. Pure/side-effect-free for testability.
/// </summary>
internal static string ResolveLicenseKey(string? licenseKey) =>
string.IsNullOrWhiteSpace(licenseKey) ? EmbeddedLicenseKey : licenseKey;
private const string EmbeddedLicenseKey =
"I+ztXu/77JVCXwEAwVQwRISgL4qlo1lOxO6csGdd02iJsOnMzEkqjhRx6oJ5rw5fgaF5wUf83LWMWwLE8PNc" +
"/ZGUZIa8mTx9ovjM9fK2+xLk/VC3s555Qhd5+PLfgxIEsp4r6lw03P7YPvD6pvM745VQg0dd8thRoznmkWrkUf" +
"/2/MiUZyUyVrH+qyEZgkniqpuDdqoaUNx1RfsK6TyiKKB7nsiqDy9xrduuYCMgOg1wii3aU+anA/pHUYh/jMO0" +
@@ -41,7 +56,7 @@ public static class FuchsPdf
"uUg5LlJmPPXkTKHQJ/CM6EQkqIS4Foz7pBaaYRBgEz/zDujxbYUGN6LaJiANung4Zyl6k5arhHdCalRDe29avN1o" +
"vxe/5tUHQQDxq+yQ1cNChPJTFHR1bKKu0T7SW7p19qH5850rXcjtzK4+6zGYXq8HItH6UNiev27o9VUoKTv+XZiD" +
"27YE33vdwQHh5Kdc8CMMo+uaTI11uLBirUH63Na2oBkCGJjJzQk8Gc5NQs7+2DptJ/rNlOhwb/czZLB6OjH+vNCy" +
"HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA==");
"HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA==";
// ── Colors ────────────────────────────────────────────────────────────────
private static readonly Color FuchsGray = Color.FromRgb(128, 128, 128);
@@ -193,21 +208,36 @@ public static class FuchsPdf
DefineStyles_Letter(doc, Array.Empty<Style>(), tgtFont);
var section = doc.AddSection();
section.PageSetup.TopMargin = cm(1.8);
section.PageSetup.BottomMargin = cm(1.8);
section.PageSetup.PageHeight = doc.DefaultPageSetup.PageHeight;
section.PageSetup.PageWidth = doc.DefaultPageSetup.PageWidth;
// Bottom margin reserves room for the four footer blocks + page-number row,
// exactly as the legacy layout computed it (22.5mm footer block + 2× the ISO
// page-number margin + one page-number row). A too-small bottom margin was one
// cause of body text overrunning the footer.
Unit isoPageNumMargin = mm(10);
Unit pageNumRowHeight = mm(doc.Styles["PageNumStyle"]!.Font.Size.Millimeter);
section.PageSetup.TopMargin = cm(2.0);
section.PageSetup.BottomMargin = new Unit(
22.5 + 2 * isoPageNumMargin.Millimeter + pageNumRowHeight.Millimeter, UnitType.Millimeter);
section.PageSetup.LeftMargin = cm(2.5);
section.PageSetup.RightMargin = cm(2.0);
section.PageSetup.DifferentFirstPageHeaderFooter = true;
section.PageSetup.HeaderDistance = cm(0);
string dataBase = Path.Combine(AppContext.BaseDirectory, "Data");
// ── Header logos ──────────────────────────────────────────────────────
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image1.png"),
width: mm(155.5), top: cm(0.79), left: cm(2.0));
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image2.png"),
width: mm(34.5), top: cm(0.59), left: cm(15.27));
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image3.png"),
width: mm(25.4), top: cm(6.21), left: cm(17.51));
// ── Header logos (top-right corner of the letterhead) ─────────────────
// NOTE: the shipped assets are image1-3.jpeg + image4.png. Referencing the
// wrong extension made AddHeaderImage silently skip them (File.Exists == false),
// which is why the letterhead logos vanished. Sizes/positions match the legacy
// CreatePage_letter.
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image1.jpeg"),
width: mm(39.3), top: cm(1.73), left: cm(16.07));
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image2.jpeg"),
width: mm(25.4), top: cm(4.89), left: cm(17.5));
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image3.jpeg"),
width: mm(26.0), top: cm(6.21), left: cm(17.51));
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image4.png"),
width: mm(25.4), top: cm(7.79), left: cm(17.5));
@@ -243,12 +273,16 @@ public static class FuchsPdf
{
var tf = section.Headers.FirstPage.AddTextFrame();
tf.RelativeVertical = RelativeVertical.Page;
tf.RelativeHorizontal = RelativeHorizontal.Page;
tf.Top = cm(4.65); tf.Left = cm(2.0);
tf.Width = cm(8.5); tf.Height = cm(0.6);
tf.RelativeHorizontal = RelativeHorizontal.Margin;
tf.Left = ShapePosition.Left;
tf.Top = cm(5.3);
tf.Width = cm(14); tf.Height = mm(12.5);
var p = tf.AddParagraph();
p.Style = "AddressBoxSender";
p.AddText($"{tb.SenderLine1} \u25cf {tb.SenderLine2}");
if (!string.IsNullOrEmpty(tb.SenderLine1))
p.AddFormattedText(tb.SenderLine1, TextFormat.Bold);
p.AddText(" " + tb.SenderLine2);
tf.WrapFormat.Style = WrapStyle.Through;
}
// ── Recipient address box ─────────────────────────────────────────────
@@ -258,6 +292,7 @@ public static class FuchsPdf
tf.RelativeHorizontal = RelativeHorizontal.Page;
tf.Top = cm(5.6); tf.Left = cm(2.0);
tf.Width = cm(9); tf.Height = cm(4);
tf.MarginLeft = cm(0.5); tf.MarginRight = cm(0.5); tf.MarginBottom = cm(0.5);
tf.MarginTop = cm(0.2);
if (tb.Address.Length > 0)
{
@@ -267,26 +302,28 @@ public static class FuchsPdf
}
}
// ── Admin info block (right side) ─────────────────────────────────────
// ── Admin info block (right column, label over value) ─────────────────
{
var tf = section.Headers.FirstPage.AddTextFrame();
tf.RelativeVertical = RelativeVertical.Page;
tf.RelativeHorizontal = RelativeHorizontal.Page;
tf.Top = cm(5.6); tf.Left = cm(13.0);
tf.Width = cm(5.5); tf.Height = cm(5.5);
void Row(string label, string value)
tf.Top = mm(52.5); tf.Left = cm(12.87);
tf.Width = cm(5); tf.Height = cm(12);
bool first = true;
void Block(string label, string value)
{
var p = tf.AddParagraph(); p.Style = "AdminInfo";
p.AddFormattedText(label + ": ", TextFormat.Bold);
var p = tf.AddParagraph(); p.Style = "AdminBlock";
if (first) { p.Format.SpaceBefore = 0; first = false; }
p.AddFormattedText(label, "AdminBlockHead");
p.AddLineBreak();
p.AddText(value);
}
Row(tb.AdminDatumLabel, tb.AdminDatum);
if (!string.IsNullOrEmpty(tb.AdminRef))
Row("Nummer", tb.AdminRef);
if (!string.IsNullOrEmpty(tb.ProvisionPeriod))
Row(tb.AdminProvLabel, tb.ProvisionPeriod);
Row("Sachbearbeiter", tb.AdminUser);
Row("E-Mail", tb.AdminUserEmail);
Block("Bearbeiter", (tb.AdminUser ?? "").ne(" "));
Block("Email", (tb.AdminUserEmail ?? "").ne("-"));
Block(tb.AdminDatumLabel, (tb.AdminDatum ?? "").ne("-"));
Block(tb.AdminProvLabel, (tb.ProvisionPeriod ?? "").ne("-"));
Block("Nummer", (tb.AdminRef ?? "").ne("-"));
tf.WrapFormat.Style = WrapStyle.Through;
}
// ── Ort und Zeit ──────────────────────────────────────────────────────
@@ -304,10 +341,25 @@ public static class FuchsPdf
p.Format.Alignment = ParagraphAlignment.Right;
}
// ── Footer (all pages) ────────────────────────────────────────────────
AddLetterFooter(section.Footers.Primary, tb, tgtFont);
AddLetterFooter(section.Footers.FirstPage, tb, tgtFont);
AddLetterFooter(section.Footers.EvenPage, tb, tgtFont);
// ── Footer blocks + page numbers (first page + all following pages) ───
AddLetterFooterBlocks(section, section.Footers.Primary, tb);
AddLetterFooterBlocks(section, section.Footers.FirstPage, tb);
AddPageNumber(section, section.Footers.Primary, isoPageNumMargin, pageNumRowHeight);
AddPageNumber(section, section.Footers.FirstPage, isoPageNumMargin, pageNumRowHeight);
// MigraDoc drops SpaceBefore on the very first body paragraph of a page. The letter
// body must start ~10 cm down (below the floating address window / admin block), which
// the invoice title / subject achieve via a large SpaceBefore — but only if they are
// not the first paragraph. This tiny empty anchor absorbs the first-paragraph
// suppression so the following content's SpaceBefore (the letterhead offset) is honored.
{
var anchor = section.AddParagraph();
anchor.Format.Font.Size = 1;
anchor.Format.SpaceBefore = 0;
anchor.Format.SpaceAfter = 0;
anchor.Format.LineSpacingRule = LineSpacingRule.Exactly;
anchor.Format.LineSpacing = pt(1);
}
// ── Subject + body ────────────────────────────────────────────────────
if (!string.IsNullOrEmpty(tb.Subject))
@@ -339,8 +391,10 @@ public static class FuchsPdf
normal.Font.Name = tgtFont;
normal.Font.Size = 11;
AddStyle(doc, "PageNumStyle", "Normal", s => s.Font.Size = 9);
AddStyle(doc, "PageNumStyle", "Normal", s => s.Font.Size = 10);
AddStyle(doc, "BodyText", "Normal", s => { s.Font.Size = 11; s.ParagraphFormat.LineSpacing = 1.15; s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Multiple; });
// Base style for MigraDoc tables (invoice/reminder item grids reference "Table").
AddStyle(doc, "Table", "Normal", s => { s.Font.Name = tgtFont; s.Font.Size = 11; s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Single; });
AddStyle(doc, "AddressBox", "Normal", s => { s.Font.Size = 10; s.Font.Name = tgtFont; });
AddStyle(doc, "AddressBoxSender", "Normal", s => { s.Font.Size = 7; s.Font.Name = tgtFont; s.Font.Color = FuchsGray; });
AddStyle(doc, "AdminInfo", "Normal", s => s.Font.Size = 9);
@@ -367,6 +421,51 @@ public static class FuchsPdf
{
if (!SystemFontExists(tgtFont)) tgtFont = "Arial";
var normal = doc.Styles["Normal"]!;
normal.Font.Name = tgtFont;
normal.Font.Color = Colors.Black;
AddStyle(doc, "BodyText", "Normal", s => { s.Font.Name = tgtFont; s.Font.Size = baseSize; });
// ── Letterhead frame styles (ported 1:1 from legacy fuchs_fds_pdf.vb) ──
AddStyle(doc, "AdminBlockHead", "Normal", s =>
s.Font = new Font(tgtFont, 9) { Color = FuchsGray, Bold = true });
AddStyle(doc, "AdminBlock", "Normal", s =>
{
s.Font = new Font(tgtFont, 9) { Color = Colors.Black, Bold = false };
s.ParagraphFormat.SpaceBefore = cm(0.25);
s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
s.ParagraphFormat.LineSpacing = pt(9 * 1.2);
});
AddStyle(doc, "FooterBlock", "Normal", s =>
{
s.Font = new Font(tgtFont, 7) { Color = FuchsBlau };
s.ParagraphFormat.Alignment = ParagraphAlignment.Left;
s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
s.ParagraphFormat.LineSpacing = pt(7.5);
});
AddStyle(doc, "AddressBoxSender", "Normal", s =>
{
s.Font = new Font(tgtFont, 7.5) { Color = FuchsBlau };
s.ParagraphFormat.Alignment = ParagraphAlignment.Left;
s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
s.ParagraphFormat.LineSpacing = pt(7.5);
});
AddStyle(doc, "AddressBox", "Normal", s =>
{
s.Font = new Font(tgtFont, 10) { Color = Colors.Black };
s.ParagraphFormat.Alignment = ParagraphAlignment.Left;
s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
s.ParagraphFormat.LineSpacing = pt(12);
});
AddStyle(doc, "PageNumStyle", "Normal", s =>
{
s.Font = new Font(tgtFont, 10) { Color = Colors.Black };
s.ParagraphFormat.Alignment = ParagraphAlignment.Right;
s.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
s.ParagraphFormat.LineSpacing = pt(10);
});
AddStyle(doc, "SubjectBig", "Normal", s => { s.Font.Name = tgtFont; s.Font.Size = 13; s.Font.Bold = true; });
AddStyle(doc, "HorizontalRule", "Normal", s =>
{
@@ -618,9 +717,20 @@ public static class FuchsPdf
// ── ApplyReminder ─────────────────────────────────────────────────────────
public static void ApplyReminder(Document doc, FdsTextBlocks tb, FdsReminderData rem, bool draft = false)
{
Apply_Invoice_Styles(doc);
var sec = doc.Sections.Cast<Section>().First();
string rtype = rem.ReminderType;
// Title — carries the letterhead top offset so the body starts below the address
// window / admin block (see the anchor note in CreatePage_Letter).
{
var p = sec.AddParagraph();
p.Style = "SubjectBig";
p.Format.SpaceBefore = cm(8.65);
p.Format.SpaceAfter = cm(0.5);
p.AddText(rem.ReminderTitle.ne("Zahlungserinnerung"));
}
// Opening text
if (tb.ReminderTexts_before.TryGetValue(rtype, out var intro))
{
@@ -706,9 +816,24 @@ public static class FuchsPdf
// ── PDF rendering helpers ─────────────────────────────────────────────────
/// <summary>
/// Ensures PdfSharp's global font resolver is the OCORE one before any MigraDoc rendering.
/// 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 void EnsureFontResolver()
{
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null ||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
{
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
}
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
public static byte[] DocToPdfBytes(Document doc)
{
EnsureFontResolver();
var renderer = new PdfDocumentRenderer() { Document = doc };
renderer.RenderDocument();
using var ms = new MemoryStream();
@@ -771,30 +896,51 @@ public static class FuchsPdf
img.WrapFormat.Style = WrapStyle.Through;
}
private static void AddLetterFooter(HeaderFooter footer, FdsTextBlocks tb, string font)
/// <summary>
/// Draws the four bottom-of-page footer text blocks (company / liability / contact / bank)
/// as absolutely positioned text frames, matching the legacy fuchs_fds_pdf.vb layout.
/// </summary>
private static void AddLetterFooterBlocks(Section section, HeaderFooter footer, FdsTextBlocks tb)
{
// Horizontal rule
var rule = footer.AddParagraph(); rule.Style = "FooterText";
rule.Format.Borders.Top.Width = 0.5;
rule.Format.Borders.Top.Color = FuchsGray;
rule.Format.SpaceBefore = 4;
// Four-column footer table
var tbl = footer.AddTable();
tbl.Format.Font.Name = font; tbl.Format.Font.Size = 8;
tbl.Format.Font.Color = FuchsGray;
tbl.Borders.Visible = false;
double[] widths = { 4.2, 4.8, 4.2, 4.8 };
foreach (double w in widths) tbl.AddColumn(cm(w));
var row = tbl.AddRow(); row.HeightRule = RowHeightRule.Auto;
string[][] blocks = { tb.FooterBlock1, tb.FooterBlock2, tb.FooterBlock3, tb.FooterBlock4 };
double topMm = section.PageSetup.PageHeight.Millimeter - 25;
for (int col = 0; col < 4; col++)
{
var p = row.Cells[col].AddParagraph();
var tf = footer.AddTextFrame();
tf.RelativeHorizontal = RelativeHorizontal.Page;
tf.RelativeVertical = RelativeVertical.Page;
tf.Left = cm(2.5 + col * 4.25);
tf.Top = mm(topMm);
tf.Width = cm(4.5);
tf.Height = cm(2);
tf.MarginTop = 0; tf.MarginBottom = 0; tf.MarginLeft = 0; tf.MarginRight = 0;
var p = tf.AddParagraph();
p.Style = "FooterBlock";
foreach (string line in blocks[col]) { p.AddText(line); p.AddLineBreak(); }
}
}
/// <summary>Adds the right-aligned "Seite X von Y" page-number frame below the bottom margin.</summary>
private static void AddPageNumber(Section section, HeaderFooter footer, Unit isoMargin, Unit rowHeight)
{
var tf = footer.AddTextFrame();
tf.RelativeHorizontal = RelativeHorizontal.Margin;
tf.RelativeVertical = RelativeVertical.Page;
tf.Left = ShapePosition.Right;
tf.Top = mm(section.PageSetup.PageHeight.Millimeter
- section.PageSetup.BottomMargin.Millimeter + isoMargin.Millimeter);
tf.Width = cm(4.5);
tf.Height = rowHeight;
tf.MarginTop = 0; tf.MarginBottom = 0; tf.MarginLeft = 0; tf.MarginRight = 0;
var p = tf.AddParagraph();
p.Style = "PageNumStyle";
p.Format.Alignment = ParagraphAlignment.Right;
p.AddText("Seite ");
p.AddPageField();
p.AddText(" von ");
p.AddNumPagesField();
}
/// <summary>
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
/// matching the legacy fuchs_fds_pdf.vb invoice/reminder layout. No-op on failure.
File diff suppressed because one or more lines are too long