Refactor stored procedure and update project structure
Playwright Tests / test (push) Has been cancelled
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:
@@ -15,6 +15,7 @@
|
|||||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
- Test: `dotnet test 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/`):
|
- Project structure (relative to `Fuchs/`):
|
||||||
- `Controllers/` — `IntranetController` partials (no area)
|
- `Controllers/` — `IntranetController` partials (no area)
|
||||||
- `code/` — business logic, PDF, email, widgets, data models
|
- `code/` — business logic, PDF, email, widgets, data models
|
||||||
|
|||||||
@@ -10,3 +10,6 @@
|
|||||||
[submodule "OCORE_Charting"]
|
[submodule "OCORE_Charting"]
|
||||||
path = OCORE_Charting
|
path = OCORE_Charting
|
||||||
url = https://git.processweb.de/Stefan/OCORE_Charting.git
|
url = https://git.processweb.de/Stefan/OCORE_Charting.git
|
||||||
|
[submodule "eRechnungLib"]
|
||||||
|
path = eRechnungLib
|
||||||
|
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"dotnet run": true,
|
||||||
|
"dotnet test": true,
|
||||||
|
"dotnet build": true,
|
||||||
|
"npx gulp": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
- **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_*`).
|
- 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 & Test (workflow)
|
||||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
- **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_*`).
|
- 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 & Test (workflow)
|
||||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
|
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
default:
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public partial class IntranetController
|
|||||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
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":
|
case "setupd":
|
||||||
@@ -50,7 +50,7 @@ public partial class IntranetController
|
|||||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
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":
|
case "setvat":
|
||||||
@@ -72,7 +72,7 @@ public partial class IntranetController
|
|||||||
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
|
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
|
||||||
if (!string.IsNullOrEmpty(sqlEx))
|
if (!string.IsNullOrEmpty(sqlEx))
|
||||||
_logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID);
|
_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":
|
case "sis":
|
||||||
@@ -94,7 +94,7 @@ public partial class IntranetController
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID);
|
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":
|
case "pget":
|
||||||
@@ -156,11 +156,11 @@ public partial class IntranetController
|
|||||||
using (var mfr = _mfrFactory.Create())
|
using (var mfr = _mfrFactory.Create())
|
||||||
await mfr.Update__entitytable(EntityTypes.Invoice,
|
await mfr.Update__entitytable(EntityTypes.Invoice,
|
||||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
|
|
||||||
default:
|
default:
|
||||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
_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}",
|
_logger.LogInformation("HandleInvoicePget reset complete for tgtid={TgtId} invoices={InvCount} serviceRequests={SrqCount} user={User}",
|
||||||
tgtid, invIds.Count, srqIds.Count, UserAccountID);
|
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)
|
private async Task<IActionResult> HandleInvoiceGet(string fn, string id, string code)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public partial class IntranetController
|
|||||||
await _events.ReminderIssueAsync(
|
await _events.ReminderIssueAsync(
|
||||||
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
|
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
|
||||||
UserAccountID, Form("id"));
|
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":
|
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.",
|
$"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.",
|
||||||
UserAccountID, remId);
|
UserAccountID, remId);
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ public partial class IntranetController
|
|||||||
UserAccountID, remId);
|
UserAccountID, remId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ public partial class IntranetController
|
|||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
|
|
||||||
case "sconf": return await HandleRequestSconf(fn, id, code);
|
case "sconf": return await HandleRequestSconf(fn, id, code);
|
||||||
case "idoc": return await HandleRequestIdoc(fn, id, code);
|
case "idoc": return await HandleRequestIdoc(fn, id, code);
|
||||||
case "resend": return await HandleRequestResend(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)
|
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(
|
var dt = await getSQLDatatable_async(
|
||||||
"SELECT * FROM [dbo].[fds__getRequestTreeIds](@srqid);",
|
"SELECT * FROM [dbo].[fds__getRequestTreeIds](@srqid);",
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
@@ -181,15 +187,19 @@ public partial class IntranetController
|
|||||||
if (iid > 0 && !ids.Contains(iid)) ids.Add(iid);
|
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>
|
var schemaDic = new Dictionary<string, fds.FdsMfrClient.DatabaseSchema>
|
||||||
{
|
{
|
||||||
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
|
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
|
||||||
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
|
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
|
||||||
};
|
};
|
||||||
using var mfr = _mfrFactory.Create();
|
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);
|
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)
|
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.",
|
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
|
||||||
UserAccountID, invId);
|
UserAccountID, invId);
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
}
|
}
|
||||||
@@ -381,7 +391,7 @@ public partial class IntranetController
|
|||||||
UserAccountID, invId);
|
UserAccountID, invId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
{
|
{
|
||||||
IActionResult? result = fn.ToLower() switch
|
IActionResult? result = fn.ToLower() switch
|
||||||
{
|
{
|
||||||
"ping" => Ok(),
|
"ping" => await JSONAsync(new { ok = true }),
|
||||||
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
|
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
|
||||||
"todos" => new PhysicalFileResult(
|
"todos" => new PhysicalFileResult(
|
||||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
|
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);
|
_logger.LogWarning("No handler matched fn={Fn}", fn);
|
||||||
else
|
else
|
||||||
_logger.LogDebug("Do completed fn={Fn}/{Id} result={ResultType}", fn, id, result.GetType().Name);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -255,7 +255,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
UserAccountID, HttpContext.Connection.RemoteIpAddress);
|
UserAccountID, HttpContext.Connection.RemoteIpAddress);
|
||||||
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
|
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
|
||||||
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
|
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Password helpers ──────────────────────────────────────────────────────
|
// ── 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);
|
_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)
|
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);
|
_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)
|
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);
|
_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":
|
case "changepassword":
|
||||||
string? npw = Request.Form["npw"];
|
string? npw = Request.Form["npw"];
|
||||||
@@ -400,10 +400,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
},
|
},
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
_logger.LogDebug("Password changed successfully for user={User}", UserAccountID);
|
_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);
|
_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)
|
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}",
|
_logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}",
|
||||||
UserAccountID, UserIdent.Authorization);
|
UserAccountID, UserIdent.Authorization);
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleMfrUpdate(string fn, string id, string code)
|
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();
|
using var mfrSingle = _mfrFactory.Create();
|
||||||
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
|
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
|
||||||
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
|
_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"]))
|
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();
|
using var mfr = _mfrFactory.Create();
|
||||||
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
|
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
|
||||||
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
|
_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);
|
_logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID);
|
||||||
return BadRequest400();
|
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.
|
||||||
@@ -37,6 +37,14 @@ public class Program
|
|||||||
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
||||||
builder.AddSecretManagement();
|
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.
|
// Assemble connection strings from templates + resolved credentials.
|
||||||
// In Development, "_Dev"-suffixed credential keys are preferred so a reachable
|
// In Development, "_Dev"-suffixed credential keys are preferred so a reachable
|
||||||
// Key Vault can never override them with production DB credentials.
|
// 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
|
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
|
||||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
// (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<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||||
|
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
||||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||||
|
builder.Services.AddHostedService<StartupSelfTestService>();
|
||||||
|
|
||||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||||
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
|
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Fuchs.Observability;
|
using Fuchs.Observability;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MigraDoc.DocumentObjectModel;
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
|
||||||
@@ -13,13 +14,90 @@ namespace Fuchs.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class FuchsPdfService : IPdfService
|
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;
|
private readonly ILogger<FuchsPdfService> _logger;
|
||||||
|
|
||||||
public FuchsPdfService(ILogger<FuchsPdfService> logger)
|
public FuchsPdfService(ILogger<FuchsPdfService> logger, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
FuchsPdf.SetLicense();
|
// The license is normally applied once at startup (Program.cs) before any Spire use;
|
||||||
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
|
// 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)
|
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -6,15 +6,24 @@
|
|||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Debug",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Debug",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Debug",
|
||||||
|
"fds": "Debug",
|
||||||
|
"Fuchs.Controllers": "Debug"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Fuchs": {
|
"Fuchs": {
|
||||||
"FDS_Intranet_DebugState": true,
|
"FDS_Intranet_DebugState": true,
|
||||||
"DevAutoLogin": true,
|
"DevAutoLogin": true,
|
||||||
"DevAutoLoginEmail": "info@processweb.de",
|
"DevAutoLoginEmail": "info@processweb.de",
|
||||||
|
"StartupChecks": {
|
||||||
|
"Enabled": true,
|
||||||
|
"CheckKeyVault": false,
|
||||||
|
"CheckDatabase": false,
|
||||||
|
"CheckMfr": false,
|
||||||
|
"CheckPdfLicense": true
|
||||||
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"OverrideRecipient": "service@emails.processweb.de"
|
"OverrideRecipient": "service@emails.processweb.de"
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-2
@@ -13,7 +13,8 @@
|
|||||||
"Fuchs--fuchs-captcha-TOTP",
|
"Fuchs--fuchs-captcha-TOTP",
|
||||||
"Fuchs--fuchs-intranet-TOTP",
|
"Fuchs--fuchs-intranet-TOTP",
|
||||||
"Fds--MFR-UserName",
|
"Fds--MFR-UserName",
|
||||||
"Fds--MFR-Password"
|
"Fds--MFR-Password",
|
||||||
|
"SpirePdf-License"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"SpirePdf_License": "MANAGED_BY_KEYVAULT",
|
||||||
"ConnectionStrings": {
|
"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_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",
|
"fuchs_fds_username": "MANAGED_BY_KEYVAULT",
|
||||||
@@ -40,13 +42,22 @@
|
|||||||
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
|
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
|
||||||
"Mailer": {
|
"Mailer": {
|
||||||
"BaseUrl": "https://api.processweb.de",
|
"BaseUrl": "https://api.processweb.de",
|
||||||
"AccountId": "",
|
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
|
||||||
"Token": "MANAGED_BY_KEYVAULT",
|
"Token": "MANAGED_BY_KEYVAULT",
|
||||||
"Enabled": false
|
"Enabled": false
|
||||||
},
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"OverrideRecipient": ""
|
"OverrideRecipient": ""
|
||||||
},
|
},
|
||||||
|
"StartupChecks": {
|
||||||
|
"Enabled": false,
|
||||||
|
"CheckKeyVault": true,
|
||||||
|
"CheckDatabase": true,
|
||||||
|
"CheckMfr": true,
|
||||||
|
"SendStartupEmail": true,
|
||||||
|
"StartupEmailRecipient": "",
|
||||||
|
"StartupEmailRecipientName": "Startup Monitor"
|
||||||
|
},
|
||||||
"AzureStorage": {
|
"AzureStorage": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"InvoiceContainer": "fuchs-invoices",
|
"InvoiceContainer": "fuchs-invoices",
|
||||||
|
|||||||
+196
-50
@@ -22,8 +22,23 @@ public static class FuchsPdf
|
|||||||
public const string ProjectAbbreviation = "fuchs";
|
public const string ProjectAbbreviation = "fuchs";
|
||||||
|
|
||||||
// ── Spire license ─────────────────────────────────────────────────────────
|
// ── Spire license ─────────────────────────────────────────────────────────
|
||||||
public static void SetLicense() =>
|
/// <summary>
|
||||||
Spire.License.LicenseProvider.SetLicenseKey(
|
/// 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" +
|
"I+ztXu/77JVCXwEAwVQwRISgL4qlo1lOxO6csGdd02iJsOnMzEkqjhRx6oJ5rw5fgaF5wUf83LWMWwLE8PNc" +
|
||||||
"/ZGUZIa8mTx9ovjM9fK2+xLk/VC3s555Qhd5+PLfgxIEsp4r6lw03P7YPvD6pvM745VQg0dd8thRoznmkWrkUf" +
|
"/ZGUZIa8mTx9ovjM9fK2+xLk/VC3s555Qhd5+PLfgxIEsp4r6lw03P7YPvD6pvM745VQg0dd8thRoznmkWrkUf" +
|
||||||
"/2/MiUZyUyVrH+qyEZgkniqpuDdqoaUNx1RfsK6TyiKKB7nsiqDy9xrduuYCMgOg1wii3aU+anA/pHUYh/jMO0" +
|
"/2/MiUZyUyVrH+qyEZgkniqpuDdqoaUNx1RfsK6TyiKKB7nsiqDy9xrduuYCMgOg1wii3aU+anA/pHUYh/jMO0" +
|
||||||
@@ -41,7 +56,7 @@ public static class FuchsPdf
|
|||||||
"uUg5LlJmPPXkTKHQJ/CM6EQkqIS4Foz7pBaaYRBgEz/zDujxbYUGN6LaJiANung4Zyl6k5arhHdCalRDe29avN1o" +
|
"uUg5LlJmPPXkTKHQJ/CM6EQkqIS4Foz7pBaaYRBgEz/zDujxbYUGN6LaJiANung4Zyl6k5arhHdCalRDe29avN1o" +
|
||||||
"vxe/5tUHQQDxq+yQ1cNChPJTFHR1bKKu0T7SW7p19qH5850rXcjtzK4+6zGYXq8HItH6UNiev27o9VUoKTv+XZiD" +
|
"vxe/5tUHQQDxq+yQ1cNChPJTFHR1bKKu0T7SW7p19qH5850rXcjtzK4+6zGYXq8HItH6UNiev27o9VUoKTv+XZiD" +
|
||||||
"27YE33vdwQHh5Kdc8CMMo+uaTI11uLBirUH63Na2oBkCGJjJzQk8Gc5NQs7+2DptJ/rNlOhwb/czZLB6OjH+vNCy" +
|
"27YE33vdwQHh5Kdc8CMMo+uaTI11uLBirUH63Na2oBkCGJjJzQk8Gc5NQs7+2DptJ/rNlOhwb/czZLB6OjH+vNCy" +
|
||||||
"HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA==");
|
"HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA==";
|
||||||
|
|
||||||
// ── Colors ────────────────────────────────────────────────────────────────
|
// ── Colors ────────────────────────────────────────────────────────────────
|
||||||
private static readonly Color FuchsGray = Color.FromRgb(128, 128, 128);
|
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);
|
DefineStyles_Letter(doc, Array.Empty<Style>(), tgtFont);
|
||||||
|
|
||||||
var section = doc.AddSection();
|
var section = doc.AddSection();
|
||||||
section.PageSetup.TopMargin = cm(1.8);
|
section.PageSetup.PageHeight = doc.DefaultPageSetup.PageHeight;
|
||||||
section.PageSetup.BottomMargin = cm(1.8);
|
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.LeftMargin = cm(2.5);
|
||||||
section.PageSetup.RightMargin = cm(2.0);
|
section.PageSetup.RightMargin = cm(2.0);
|
||||||
section.PageSetup.DifferentFirstPageHeaderFooter = true;
|
section.PageSetup.DifferentFirstPageHeaderFooter = true;
|
||||||
|
section.PageSetup.HeaderDistance = cm(0);
|
||||||
|
|
||||||
string dataBase = Path.Combine(AppContext.BaseDirectory, "Data");
|
string dataBase = Path.Combine(AppContext.BaseDirectory, "Data");
|
||||||
|
|
||||||
// ── Header logos ──────────────────────────────────────────────────────
|
// ── Header logos (top-right corner of the letterhead) ─────────────────
|
||||||
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image1.png"),
|
// NOTE: the shipped assets are image1-3.jpeg + image4.png. Referencing the
|
||||||
width: mm(155.5), top: cm(0.79), left: cm(2.0));
|
// wrong extension made AddHeaderImage silently skip them (File.Exists == false),
|
||||||
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image2.png"),
|
// which is why the letterhead logos vanished. Sizes/positions match the legacy
|
||||||
width: mm(34.5), top: cm(0.59), left: cm(15.27));
|
// CreatePage_letter.
|
||||||
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image3.png"),
|
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image1.jpeg"),
|
||||||
width: mm(25.4), top: cm(6.21), left: cm(17.51));
|
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"),
|
AddHeaderImage(section.Headers.FirstPage, Path.Combine(dataBase, "image4.png"),
|
||||||
width: mm(25.4), top: cm(7.79), left: cm(17.5));
|
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();
|
var tf = section.Headers.FirstPage.AddTextFrame();
|
||||||
tf.RelativeVertical = RelativeVertical.Page;
|
tf.RelativeVertical = RelativeVertical.Page;
|
||||||
tf.RelativeHorizontal = RelativeHorizontal.Page;
|
tf.RelativeHorizontal = RelativeHorizontal.Margin;
|
||||||
tf.Top = cm(4.65); tf.Left = cm(2.0);
|
tf.Left = ShapePosition.Left;
|
||||||
tf.Width = cm(8.5); tf.Height = cm(0.6);
|
tf.Top = cm(5.3);
|
||||||
|
tf.Width = cm(14); tf.Height = mm(12.5);
|
||||||
var p = tf.AddParagraph();
|
var p = tf.AddParagraph();
|
||||||
p.Style = "AddressBoxSender";
|
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 ─────────────────────────────────────────────
|
// ── Recipient address box ─────────────────────────────────────────────
|
||||||
@@ -258,6 +292,7 @@ public static class FuchsPdf
|
|||||||
tf.RelativeHorizontal = RelativeHorizontal.Page;
|
tf.RelativeHorizontal = RelativeHorizontal.Page;
|
||||||
tf.Top = cm(5.6); tf.Left = cm(2.0);
|
tf.Top = cm(5.6); tf.Left = cm(2.0);
|
||||||
tf.Width = cm(9); tf.Height = cm(4);
|
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);
|
tf.MarginTop = cm(0.2);
|
||||||
if (tb.Address.Length > 0)
|
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();
|
var tf = section.Headers.FirstPage.AddTextFrame();
|
||||||
tf.RelativeVertical = RelativeVertical.Page;
|
tf.RelativeVertical = RelativeVertical.Page;
|
||||||
tf.RelativeHorizontal = RelativeHorizontal.Page;
|
tf.RelativeHorizontal = RelativeHorizontal.Page;
|
||||||
tf.Top = cm(5.6); tf.Left = cm(13.0);
|
tf.Top = mm(52.5); tf.Left = cm(12.87);
|
||||||
tf.Width = cm(5.5); tf.Height = cm(5.5);
|
tf.Width = cm(5); tf.Height = cm(12);
|
||||||
void Row(string label, string value)
|
bool first = true;
|
||||||
|
void Block(string label, string value)
|
||||||
{
|
{
|
||||||
var p = tf.AddParagraph(); p.Style = "AdminInfo";
|
var p = tf.AddParagraph(); p.Style = "AdminBlock";
|
||||||
p.AddFormattedText(label + ": ", TextFormat.Bold);
|
if (first) { p.Format.SpaceBefore = 0; first = false; }
|
||||||
|
p.AddFormattedText(label, "AdminBlockHead");
|
||||||
|
p.AddLineBreak();
|
||||||
p.AddText(value);
|
p.AddText(value);
|
||||||
}
|
}
|
||||||
Row(tb.AdminDatumLabel, tb.AdminDatum);
|
Block("Bearbeiter", (tb.AdminUser ?? "").ne(" "));
|
||||||
if (!string.IsNullOrEmpty(tb.AdminRef))
|
Block("Email", (tb.AdminUserEmail ?? "").ne("-"));
|
||||||
Row("Nummer", tb.AdminRef);
|
Block(tb.AdminDatumLabel, (tb.AdminDatum ?? "").ne("-"));
|
||||||
if (!string.IsNullOrEmpty(tb.ProvisionPeriod))
|
Block(tb.AdminProvLabel, (tb.ProvisionPeriod ?? "").ne("-"));
|
||||||
Row(tb.AdminProvLabel, tb.ProvisionPeriod);
|
Block("Nummer", (tb.AdminRef ?? "").ne("-"));
|
||||||
Row("Sachbearbeiter", tb.AdminUser);
|
tf.WrapFormat.Style = WrapStyle.Through;
|
||||||
Row("E-Mail", tb.AdminUserEmail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ort und Zeit ──────────────────────────────────────────────────────
|
// ── Ort und Zeit ──────────────────────────────────────────────────────
|
||||||
@@ -304,10 +341,25 @@ public static class FuchsPdf
|
|||||||
p.Format.Alignment = ParagraphAlignment.Right;
|
p.Format.Alignment = ParagraphAlignment.Right;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Footer (all pages) ────────────────────────────────────────────────
|
// ── Footer blocks + page numbers (first page + all following pages) ───
|
||||||
AddLetterFooter(section.Footers.Primary, tb, tgtFont);
|
AddLetterFooterBlocks(section, section.Footers.Primary, tb);
|
||||||
AddLetterFooter(section.Footers.FirstPage, tb, tgtFont);
|
AddLetterFooterBlocks(section, section.Footers.FirstPage, tb);
|
||||||
AddLetterFooter(section.Footers.EvenPage, tb, tgtFont);
|
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 ────────────────────────────────────────────────────
|
// ── Subject + body ────────────────────────────────────────────────────
|
||||||
if (!string.IsNullOrEmpty(tb.Subject))
|
if (!string.IsNullOrEmpty(tb.Subject))
|
||||||
@@ -339,8 +391,10 @@ public static class FuchsPdf
|
|||||||
normal.Font.Name = tgtFont;
|
normal.Font.Name = tgtFont;
|
||||||
normal.Font.Size = 11;
|
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; });
|
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, "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, "AddressBoxSender", "Normal", s => { s.Font.Size = 7; s.Font.Name = tgtFont; s.Font.Color = FuchsGray; });
|
||||||
AddStyle(doc, "AdminInfo", "Normal", s => s.Font.Size = 9);
|
AddStyle(doc, "AdminInfo", "Normal", s => s.Font.Size = 9);
|
||||||
@@ -367,6 +421,51 @@ public static class FuchsPdf
|
|||||||
{
|
{
|
||||||
if (!SystemFontExists(tgtFont)) tgtFont = "Arial";
|
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, "SubjectBig", "Normal", s => { s.Font.Name = tgtFont; s.Font.Size = 13; s.Font.Bold = true; });
|
||||||
AddStyle(doc, "HorizontalRule", "Normal", s =>
|
AddStyle(doc, "HorizontalRule", "Normal", s =>
|
||||||
{
|
{
|
||||||
@@ -618,9 +717,20 @@ public static class FuchsPdf
|
|||||||
// ── ApplyReminder ─────────────────────────────────────────────────────────
|
// ── ApplyReminder ─────────────────────────────────────────────────────────
|
||||||
public static void ApplyReminder(Document doc, FdsTextBlocks tb, FdsReminderData rem, bool draft = false)
|
public static void ApplyReminder(Document doc, FdsTextBlocks tb, FdsReminderData rem, bool draft = false)
|
||||||
{
|
{
|
||||||
|
Apply_Invoice_Styles(doc);
|
||||||
var sec = doc.Sections.Cast<Section>().First();
|
var sec = doc.Sections.Cast<Section>().First();
|
||||||
string rtype = rem.ReminderType;
|
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
|
// Opening text
|
||||||
if (tb.ReminderTexts_before.TryGetValue(rtype, out var intro))
|
if (tb.ReminderTexts_before.TryGetValue(rtype, out var intro))
|
||||||
{
|
{
|
||||||
@@ -706,9 +816,24 @@ public static class FuchsPdf
|
|||||||
|
|
||||||
// ── PDF rendering helpers ─────────────────────────────────────────────────
|
// ── 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>
|
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
|
||||||
public static byte[] DocToPdfBytes(Document doc)
|
public static byte[] DocToPdfBytes(Document doc)
|
||||||
{
|
{
|
||||||
|
EnsureFontResolver();
|
||||||
var renderer = new PdfDocumentRenderer() { Document = doc };
|
var renderer = new PdfDocumentRenderer() { Document = doc };
|
||||||
renderer.RenderDocument();
|
renderer.RenderDocument();
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
@@ -771,30 +896,51 @@ public static class FuchsPdf
|
|||||||
img.WrapFormat.Style = WrapStyle.Through;
|
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 };
|
string[][] blocks = { tb.FooterBlock1, tb.FooterBlock2, tb.FooterBlock3, tb.FooterBlock4 };
|
||||||
|
double topMm = section.PageSetup.PageHeight.Millimeter - 25;
|
||||||
for (int col = 0; col < 4; col++)
|
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(); }
|
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>
|
/// <summary>
|
||||||
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
|
/// 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.
|
/// 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 * 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])
|
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])
|
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]
|
SELECT o.[object_id]
|
||||||
, [typ] = ISNULL(SUBSTRING([name], PATINDEX('%[_]%[_]%', [name]) + 1, CHARINDEX('_', [name], PATINDEX('%[_]%[_]%', [name]) + 1) - PATINDEX('%[_]%[_]%', [name]) - 1),'s')
|
, [typ] = ISNULL(SUBSTRING([name], PATINDEX('%[_]%[_]%', [name]) + 1, CHARINDEX('_', [name], PATINDEX('%[_]%[_]%', [name]) + 1) - PATINDEX('%[_]%[_]%', [name]) - 1),'s')
|
||||||
|
|||||||
@@ -8,6 +8,23 @@
|
|||||||
<Platform Name="x64" />
|
<Platform Name="x64" />
|
||||||
<Platform Name="x86" />
|
<Platform Name="x86" />
|
||||||
</Configurations>
|
</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">
|
<Project Path="../../WebProjectComponents/MT940Parser/MT940Parser/MT940Parser.csproj">
|
||||||
<BuildType Solution="db-dev.processweb.de|Any CPU" Project="Debug" />
|
<BuildType Solution="db-dev.processweb.de|Any CPU" Project="Debug" />
|
||||||
<BuildType Solution="server02.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
Reference in New Issue
Block a user