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
+1
View File
@@ -15,6 +15,7 @@
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
- Project structure (relative to `Fuchs/`):
- `Controllers/``IntranetController` partials (no area)
- `code/` — business logic, PDF, email, widgets, data models
+3
View File
@@ -10,3 +10,6 @@
[submodule "OCORE_Charting"]
path = OCORE_Charting
url = https://git.processweb.de/Stefan/OCORE_Charting.git
[submodule "eRechnungLib"]
path = eRechnungLib
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
+8
View File
@@ -0,0 +1,8 @@
{
"chat.tools.terminal.autoApprove": {
"dotnet run": true,
"dotnet test": true,
"dotnet build": true,
"npx gulp": true
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
## Project Overview
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}``IntranetController.Index`; `/do/{fn?}/{id?}/{code?}``IntranetController.Do` (dispatches by `fn` to `Do_Process_*`).
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project.
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project.
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
+1 -1
View File
@@ -11,7 +11,7 @@
## Project Overview
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}` -> `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` -> `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`).
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project.
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project.
## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
+1
View File
@@ -28,6 +28,7 @@
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
</ItemGroup>
<ItemGroup>
+370
View File
@@ -0,0 +1,370 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using eRechnungLib;
using eRechnungLib.Model;
using eRechnungLib.Model.CodeLists;
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;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the invoice PDF pipeline end to end:
/// 1. a visual invoice PDF is produced with PdfSharp/MigraDoc,
/// 2. it is rasterised to preview images via Spire (the licensed path used by <c>sprep</c>),
/// 3. it is turned into a formally valid eRechnung (ZUGFeRD/Factur-X hybrid + XRechnung XML)
/// via eRechnungLib — the direction the project is moving in (all invoices as eRechnung).
/// Both an intentionally succeeding and an intentionally failing conversion path are covered.
/// </summary>
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"]!;
normal.Font.Name = "Arial";
var sec = doc.AddSection();
var title = sec.AddParagraph("Rechnung Nr. RE-2026-0001");
title.Format.Font.Size = 14;
title.Format.Font.Bold = true;
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();
}
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
private static Invoice BuildValidInvoice(string number = "RE-2026-0001")
{
var seller = new TradeParty
{
Name = "Sebastian Fuchs Bad und Heizung GmbH & Co. KG",
Address = new PostalAddress
{
Line1 = "Germaniastraße 15",
City = "Düsseldorf",
PostalCode = "40223",
Country = CountryCode.Germany,
},
Contact = new TradeContact { Name = "Sebastian Fuchs", Email = "info@sanitaerfuchs.de", Telephone = "0211 3107222" },
ElectronicAddress = new Identifier("DE286366012", "0204"),
};
seller.TaxRegistrations.Add(new TaxRegistration("DE286366012", TaxRegistrationScheme.Vat));
var buyer = new TradeParty
{
Name = "Beispiel Kunde AG",
Address = new PostalAddress
{
Line1 = "Kundenweg 2",
City = "München",
PostalCode = "80331",
Country = CountryCode.Germany,
},
};
var invoice = new Invoice
{
InvoiceNumber = number,
IssueDate = new DateOnly(2026, 6, 1),
CurrencyCode = CurrencyCode.Eur,
BuyerReference = "04011000-12345-34",
Seller = seller,
Buyer = buyer,
Payment = new PaymentInstructions
{
MeansCode = PaymentMeansCode.SepaCreditTransfer,
RemittanceInformation = number,
},
PaymentTerms = new PaymentTerms
{
Description = "Zahlbar innerhalb von 14 Tagen netto.",
DueDate = new DateOnly(2026, 6, 15),
},
};
invoice.Payment.CreditTransfers.Add(new CreditTransferAccount
{
AccountId = "DE52301502000002091478",
AccountName = seller.Name,
BankId = "WELADED1KSD",
});
invoice.Lines.Add(new InvoiceLine
{
Id = "1",
Quantity = 1m,
UnitCode = UnitCode.One,
NetPrice = 200m,
VatCategory = VatCategoryCode.StandardRate,
VatRate = 19m,
Item = new TradeItem { Name = "Beratungsleistung", Description = "Beratung nach Aufwand" },
});
invoice.Lines.Add(new InvoiceLine
{
Id = "2",
Quantity = 1m,
UnitCode = UnitCode.One,
NetPrice = 500m,
VatCategory = VatCategoryCode.StandardRate,
VatRate = 19m,
Item = new TradeItem { Name = "Entwicklung", SellerItemId = "DEV-01" },
});
InvoiceCalculator.Recalculate(invoice);
return invoice;
}
// ── Stage 1 ───────────────────────────────────────────────────────────────
[Fact]
public void Stage1_PdfSharp_produces_a_valid_pdf()
{
byte[] pdf = BuildDummyPdfWithPdfSharp();
Assert.True(pdf.Length > 1000);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(pdf, 0, 4));
}
// ── Stage 2 (Spire rasterisation — the sprep preview path) ─────────────────
[Fact]
public async Task Stage2_Spire_rasterises_the_pdf_to_preview_images()
{
FuchsPdf.SetLicense();
byte[] pdf = BuildDummyPdfWithPdfSharp();
var images = await FuchsPdf.BytesToImageCollection(pdf);
Assert.True(images.TotalPages >= 1);
Assert.NotEmpty(images.ImgB64Array);
Assert.All(images.ImgB64Array, b64 => Assert.False(string.IsNullOrWhiteSpace(b64)));
}
// ── Stage 3 (eRechnung XML) ────────────────────────────────────────────────
[Theory]
[InlineData(XRechnungSyntax.Ubl)]
[InlineData(XRechnungSyntax.Cii)]
public void Stage3_eRechnung_XRechnung_is_valid(XRechnungSyntax syntax)
{
var result = EInvoice.CreateInvoice(BuildValidInvoice())
.ToXRechnung(syntax, XRechnungVersion.V4_0);
Assert.True(result.Success);
Assert.True(result.Validation.IsValid, result.Validation.ToString());
Assert.Contains("RE-2026-0001", Encoding.UTF8.GetString(result.Value!));
}
// ── Stage 3 (ZUGFeRD hybrid embedded into the PdfSharp visual PDF) ─────────
[Fact]
public void Stage3_eRechnung_Zugferd_embeds_xml_into_supplied_visual_pdf()
{
byte[] visualPdf = BuildDummyPdfWithPdfSharp();
var result = EInvoice.CreateInvoice(BuildValidInvoice())
.ToZugferd(ZugferdProfile.EN16931, visualPdf);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
using var ms = new MemoryStream(result.Value!);
var pdfDoc = PdfReader.Open(ms, PdfDocumentOpenMode.Import);
// Factur-X associated-file array must be present on the catalog.
Assert.NotNull(pdfDoc.Internals.Catalog.Elements.GetArray("/AF"));
}
// ── Full chain: PdfSharp → Spire images → eRechnung hybrid ─────────────────
[Fact]
public async Task FullChain_pdfsharp_spire_eRechnung()
{
FuchsPdf.SetLicense();
// 1. Visual PDF via PdfSharp.
byte[] visualPdf = BuildDummyPdfWithPdfSharp();
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(visualPdf, 0, 4));
// 2. Preview images via Spire.
var images = await FuchsPdf.BytesToImageCollection(visualPdf);
Assert.True(images.TotalPages >= 1);
Assert.NotEmpty(images.ImgB64Array);
// 3. eRechnung (ZUGFeRD/Factur-X) embedding the CII XML into the visual PDF.
var hybrid = EInvoice.CreateInvoice(BuildValidInvoice()).ToZugferd(ZugferdProfile.EN16931, visualPdf);
Assert.True(hybrid.Success);
Assert.True(hybrid.Validation.IsValid, hybrid.Validation.ToString());
using var ms = new MemoryStream(hybrid.Value!);
var pdfDoc = PdfReader.Open(ms, PdfDocumentOpenMode.Import);
var names = pdfDoc.Internals.Catalog.Elements.GetDictionary("/Names");
var embeddedFiles = names!.Elements.GetDictionary("/EmbeddedFiles");
var nameArray = embeddedFiles!.Elements.GetArray("/Names");
Assert.Contains(nameArray!.Elements, e => e.ToString()!.Contains("factur-x.xml"));
}
// ── Intentionally failing conversion path ──────────────────────────────────
[Fact]
public void eRechnung_strict_validation_withholds_output_on_invalid_invoice()
{
var invoice = BuildValidInvoice("RE-2026-0009");
invoice.BuyerReference = null; // violates BR-DE-15 for XRechnung
var result = EInvoice.CreateInvoice(invoice)
.ToXRechnung(XRechnungSyntax.Ubl, XRechnungVersion.V4_0,
new ConversionOptions { StrictValidation = true });
Assert.False(result.Success);
Assert.Null(result.Value);
Assert.Contains(result.Validation.Errors, m => m.RuleId == "BR-DE-15");
}
// ── Spire license selection (managed secret vs embedded fallback) ──────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ResolveLicenseKey_falls_back_to_embedded_when_secret_absent(string? provided)
{
string key = FuchsPdf.ResolveLicenseKey(provided);
Assert.False(string.IsNullOrWhiteSpace(key));
Assert.True(key.Length > 100); // the embedded key, not the (empty) input
}
[Fact]
public void ResolveLicenseKey_uses_managed_secret_when_present()
{
const string secret = "MANAGED-SECRET-LICENSE-VALUE";
Assert.Equal(secret, FuchsPdf.ResolveLicenseKey(secret));
}
[Fact]
public void LicenseConfigKey_matches_the_managed_secret_name_in_appsettings()
{
// The Key Vault secret is "fuchs--SpirePdf-License"; the secret-management layer strips
// the app prefix, splits "--" into ":" and maps "-" to "_" per segment. So the managed
// key "SpirePdf-License" must surface under the config key the service reads.
const string managedSecretName = "SpirePdf-License";
string expectedConfigKey = string.Join(':',
managedSecretName.Split("--").Select(seg => seg.Replace("-", "_")));
Assert.Equal(FuchsPdfService.LicenseConfigKey, expectedConfigKey);
// And that managed secret is actually registered in the real appsettings.json.
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.Build();
var managedKeys = config.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
Assert.Contains(managedSecretName, managedKeys);
}
[Theory]
[InlineData("SpirePdf_License")] // managed-secret mapping (fuchs--SpirePdf-License → '-' to '_')
[InlineData("SpirePdf-License")] // verbatim, e.g. appsettings.Development.json
[InlineData("SpirePdf:License")] // ':'-hierarchy variant
[InlineData("fuchs:SpirePdf-License")]
public void ResolveLicenseFromConfiguration_FindsLicenseUnderEachKnownKeyVariant(string key)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { [key] = "the-license-value" })
.Build();
string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey);
Assert.Equal("the-license-value", value);
Assert.Equal(key, matchedKey);
}
[Fact]
public void ResolveLicenseFromConfiguration_ReturnsNull_WhenNoCandidateHasAValue()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpirePdf_License"] = " ", // whitespace-only is treated as absent
["Unrelated:Key"] = "x"
})
.Build();
string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey);
Assert.Null(value);
Assert.Null(matchedKey);
}
[Fact]
public void ResolveLicenseFromConfiguration_ReturnsNull_WhenValueIsUnloadedManagedSecretPlaceholder()
{
// appsettings.json ships "SpirePdf_License": "MANAGED_BY_KEYVAULT" so the key always
// exists; until Key Vault/cache overrides it, the literal must be treated as "no license"
// so the embedded fallback is used rather than applying the placeholder as a bogus key.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[FuchsPdfService.LicenseConfigKey] = FuchsPdfService.UnloadedSecretPlaceholder
})
.Build();
string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey);
Assert.Null(value);
Assert.Null(matchedKey);
}
[Fact]
public void ResolveLicenseFromConfiguration_SkipsPlaceholder_AndReturnsRealValueFromAnotherCandidate()
{
// The canonical key still carries the unresolved placeholder while a real license was
// supplied verbatim (e.g. appsettings.Development.json) under a different candidate key.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[FuchsPdfService.LicenseConfigKey] = FuchsPdfService.UnloadedSecretPlaceholder,
["SpirePdf-License"] = "the-real-license"
})
.Build();
string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey);
Assert.Equal("the-real-license", value);
Assert.Equal("SpirePdf-License", matchedKey);
}
[Fact]
public void SpireLikeConfigKeys_ReportsSpireRelatedKeysForDiagnostics()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["fuchs:SpirePdf-License"] = "value",
["Other:Setting"] = "value"
})
.Build();
var keys = FuchsPdfService.SpireLikeConfigKeys(config).ToArray();
Assert.Contains("fuchs:SpirePdf-License", keys);
Assert.DoesNotContain("Other:Setting", keys);
}
}
+308
View File
@@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Fuchs.Tests;
public class StartupSelfTestServiceTests
{
private static StartupSelfTestSettings CreateSettings(
bool enabled,
bool checkKeyVault = true,
bool checkDatabase = true,
bool checkMfr = true,
bool sendStartupEmail = false,
string startupRecipient = "",
bool checkPdfLicense = false) => new()
{
Enabled = enabled,
CheckKeyVault = checkKeyVault,
CheckDatabase = checkDatabase,
CheckMfr = checkMfr,
CheckPdfLicense = checkPdfLicense,
SendStartupEmail = sendStartupEmail,
StartupEmailRecipient = startupRecipient,
StartupEmailRecipientName = "Monitor"
};
private static IConfiguration CreateConfiguration() =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["SecretManagement:AppName"] = "fuchs",
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token"
}).Build();
[Fact]
public async Task ExecuteAsync_Disabled_DoesNotRunAnyChecks()
{
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: false)),
NullLogger<StartupSelfTestService>.Instance)
{
KeyVaultResult = true,
DatabaseResult = true,
MfrResult = true,
MailerResult = true
};
await service.RunForTestAsync(CancellationToken.None);
Assert.Equal(0, service.KeyVaultCalls);
Assert.Equal(0, service.DatabaseCalls);
Assert.Equal(0, service.MfrCalls);
Assert.Equal(0, service.MailerCalls);
}
[Fact]
public async Task ExecuteAsync_EnabledWithAllChecks_CallsAllProbes()
{
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: true, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: true)),
NullLogger<StartupSelfTestService>.Instance)
{
KeyVaultResult = true,
DatabaseResult = true,
MfrResult = true,
MailerResult = true,
PdfLicenseResult = true
};
await service.RunForTestAsync(CancellationToken.None);
Assert.Equal(1, service.KeyVaultCalls);
Assert.Equal(1, service.DatabaseCalls);
Assert.Equal(1, service.MfrCalls);
Assert.Equal(1, service.MailerCalls);
Assert.Equal(1, service.PdfLicenseCalls);
}
[Fact]
public async Task ExecuteAsync_PdfLicenseCheckDisabled_DoesNotProbePdfLicense()
{
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: false, checkDatabase: false, checkMfr: false, checkPdfLicense: false)),
NullLogger<StartupSelfTestService>.Instance)
{
PdfLicenseResult = true
};
await service.RunForTestAsync(CancellationToken.None);
Assert.Equal(0, service.PdfLicenseCalls);
}
[Fact]
public async Task ProbePdfLicenseAsync_MissingLicenseString_ReturnsFalse()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
using var service = new ProbeExposingStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
config,
Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)),
NullLogger<StartupSelfTestService>.Instance);
bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ProbePdfLicenseAsync_EmptyLicenseString_ReturnsFalse()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[FuchsPdfService.LicenseConfigKey] = " "
})
.Build();
using var service = new ProbeExposingStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
config,
Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)),
NullLogger<StartupSelfTestService>.Instance);
bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ProbePdfLicenseAsync_InvalidLicenseString_ReportsUnlicensed()
{
// A syntactically-present but invalid key leaves Spire.PDF in evaluation mode, which the
// probe must detect (the evaluation watermark appears on the rendered document).
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[FuchsPdfService.LicenseConfigKey] = "not-a-valid-spire-license-key"
})
.Build();
using var service = new ProbeExposingStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
config,
Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)),
NullLogger<StartupSelfTestService>.Instance);
try
{
bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None);
Assert.False(ok);
}
finally
{
// Restore the embedded (valid-format) key so other Spire-using tests aren't left
// with a malformed license that makes Spire throw on save.
FuchsPdf.SetLicense();
}
}
[Fact]
public void SpirePdfIsLicensed_InEvaluationMode_ReturnsFalse()
{
// The embedded fallback key does not license current Spire.PDF, so Spire runs as the
// evaluation edition and stamps a watermark, which the detection reports as unlicensed.
FuchsPdf.SetLicense();
Assert.False(StartupSelfTestService.SpirePdfIsLicensed());
}
[Fact]
public async Task ExecuteAsync_EnabledWithMailerOnly_CallsOnlyMailerCheck()
{
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: false, checkDatabase: false, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test")),
NullLogger<StartupSelfTestService>.Instance)
{
KeyVaultResult = true,
DatabaseResult = true,
MfrResult = true,
MailerResult = false
};
await service.RunForTestAsync(CancellationToken.None);
Assert.Equal(0, service.KeyVaultCalls);
Assert.Equal(0, service.DatabaseCalls);
Assert.Equal(0, service.MfrCalls);
Assert.Equal(1, service.MailerCalls);
}
[Fact]
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
{
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: true, sendStartupEmail: true, startupRecipient: "ops@example.test")),
NullLogger<StartupSelfTestService>.Instance)
{
KeyVaultException = new InvalidOperationException("probe failed"),
DatabaseResult = true,
MfrResult = true,
MailerResult = true
};
var exception = await Record.ExceptionAsync(async () =>
{
await service.RunForTestAsync(CancellationToken.None);
});
Assert.Null(exception);
Assert.Equal(1, service.KeyVaultCalls);
Assert.Equal(1, service.DatabaseCalls);
Assert.Equal(1, service.MfrCalls);
Assert.Equal(1, service.MailerCalls);
}
private sealed class TestableStartupSelfTestService : StartupSelfTestService
{
public int KeyVaultCalls { get; private set; }
public int DatabaseCalls { get; private set; }
public int MfrCalls { get; private set; }
public int MailerCalls { get; private set; }
public int PdfLicenseCalls { get; private set; }
public bool KeyVaultResult { get; set; }
public bool DatabaseResult { get; set; }
public bool MfrResult { get; set; }
public bool MailerResult { get; set; }
public bool PdfLicenseResult { get; set; }
public Exception? KeyVaultException { get; set; }
public TestableStartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
: base(serviceProvider, configuration, settings, logger)
{
}
protected override Task<bool> ProbeKeyVaultAsync(CancellationToken cancellationToken)
{
KeyVaultCalls++;
if (KeyVaultException is not null) throw KeyVaultException;
return Task.FromResult(KeyVaultResult);
}
protected override Task<bool> SendStartupEmailAsync(CancellationToken cancellationToken)
{
MailerCalls++;
return Task.FromResult(MailerResult);
}
protected override Task<bool> ProbeDatabaseAsync(CancellationToken cancellationToken)
{
DatabaseCalls++;
return Task.FromResult(DatabaseResult);
}
protected override Task<bool> ProbeMfrAsync(CancellationToken cancellationToken)
{
MfrCalls++;
return Task.FromResult(MfrResult);
}
protected override Task<bool> ProbePdfLicenseAsync(CancellationToken cancellationToken)
{
PdfLicenseCalls++;
return Task.FromResult(PdfLicenseResult);
}
public Task RunForTestAsync(CancellationToken cancellationToken)
=> RunOnceAsync(cancellationToken);
}
/// <summary>Exposes the real (non-overridden) PDF license probe for direct testing.</summary>
private sealed class ProbeExposingStartupSelfTestService : StartupSelfTestService
{
public ProbeExposingStartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
: base(serviceProvider, configuration, settings, logger)
{
}
public Task<bool> InvokeProbePdfLicenseAsync(CancellationToken cancellationToken)
=> ProbePdfLicenseAsync(cancellationToken);
}
}
@@ -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
@@ -31,7 +31,7 @@ BEGIN
--WITH nfo as (SELECT * FROM [dbo].[ctm__generic] where [typ] = 'udp_info')
WITH nfo as (SELECT *, ROW_NUMBER() OVER (ORDER BY [category],[display_order],[key]) as '#' FROM [dbo].[fds__admin_reportcatalog])
,report_objects as (SELECT [object_id], [name] FROM [site_fuchs].[sys].[all_objects] WHERE [type_desc] = 'SQL_STORED_PROCEDURE' AND [schema_id] = 1 and (([name] like 'fds[_][_]r[_]%' or [name] like 'fds[_][_]xls[_]%') and [name] COLLATE SQL_Latin1_General_CP1_CI_AS not in ('ctm_r_base')) )
,report_objects as (SELECT [object_id], [name] FROM [sys].[all_objects] WHERE [type_desc] = 'SQL_STORED_PROCEDURE' AND [schema_id] = 1 and (([name] like 'fds[_][_]r[_]%' or [name] like 'fds[_][_]xls[_]%') and [name] COLLATE SQL_Latin1_General_CP1_CI_AS not in ('ctm_r_base')) )
INSERT INTO @PROCEDURES ([object_id], [typ], [ctype], [name], [label], [description], [tags_csv], [categories_csv], [link], [functions], [refresh], [auth], [help_url], [display_order])
SELECT o.[object_id]
, [typ] = ISNULL(SUBSTRING([name], PATINDEX('%[_]%[_]%', [name]) + 1, CHARINDEX('_', [name], PATINDEX('%[_]%[_]%', [name]) + 1) - PATINDEX('%[_]%[_]%', [name]) - 1),'s')
+17
View File
@@ -8,6 +8,23 @@
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/eRechnungLib/" />
<Folder Name="/eRechnungLib/src/">
<Project Path="eRechnungLib/src/eRechnungLib.Validation.Saxon/eRechnungLib.Validation.Saxon.csproj">
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
</Project>
<Project Path="eRechnungLib/src/eRechnungLib/eRechnungLib.csproj">
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
</Project>
</Folder>
<Folder Name="/eRechnungLib/tests/">
<Project Path="eRechnungLib/tests/eRechnungLib.Tests/eRechnungLib.Tests.csproj">
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
</Project>
</Folder>
<Project Path="../../WebProjectComponents/MT940Parser/MT940Parser/MT940Parser.csproj">
<BuildType Solution="db-dev.processweb.de|Any CPU" Project="Debug" />
<BuildType Solution="server02.processweb.de|Any CPU" Project="Debug" />
+1 -1
Submodule OCORE updated: 3cf8cd1dd5...d760efc077
Submodule
+1
Submodule eRechnungLib added at 3d37d8b082