Compare commits
30
Commits
3035ef1719
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c38ee3d55 | ||
|
|
4b28672d71 | ||
|
|
7a9eb94a13 | ||
|
|
4bb6cce9f3 | ||
|
|
235c9587f0 | ||
|
|
00e72c96d4 | ||
|
|
d94974ce06 | ||
|
|
628802db19 | ||
|
|
5ccd85c38f | ||
|
|
9b2c99f697 | ||
|
|
a45ca014ca | ||
|
|
c812d94d99 | ||
|
|
8a0ebeeb1e | ||
|
|
f6079af0de | ||
|
|
49e3ed2673 | ||
|
|
f724b9b59d | ||
|
|
5f85b75c22 | ||
|
|
5c0fdc6c1d | ||
|
|
83d1c28b29 | ||
|
|
42997c4f49 | ||
|
|
af445c015e | ||
|
|
e53d8962ad | ||
|
|
59a2b86c09 | ||
|
|
4abf81cd7d | ||
|
|
daac828c19 | ||
|
|
c98be7b23f | ||
|
|
aaf062fd77 | ||
|
|
882e97509a | ||
|
|
1a3bf30442 | ||
|
|
c3395bc83c |
@@ -1,16 +1,21 @@
|
|||||||
# Copilot Instructions
|
# Copilot Instructions
|
||||||
|
|
||||||
> ## ⚠️ Instruction Sync
|
> ## ⚠️ Instruction Sync
|
||||||
> This file (`.github/copilot-instructions.md`) and the Claude Code instructions
|
> This file (`.github/copilot-instructions.md`), the Claude Code instructions
|
||||||
> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**.
|
> (`/CLAUDE.md`), and the Codex instructions (`/CODEX.md`) are **three views of
|
||||||
> Whenever you change one, make the equivalent change in the other in the same
|
> the same project rules and must stay in sync**.
|
||||||
> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared
|
> Whenever you change one, make the equivalent change in the other two in the same
|
||||||
|
> commit. `CLAUDE.md` and `CODEX.md` may add tool-specific workflow notes, but the shared
|
||||||
> project facts (architecture, coding standards, configuration, libraries,
|
> project facts (architecture, coding standards, configuration, libraries,
|
||||||
> secrets, observability) must match.
|
> secrets, observability) must match.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
|
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
|
||||||
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
|
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
|
||||||
|
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||||
|
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||||
|
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||||
|
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
|
||||||
- Project structure (relative to `Fuchs/`):
|
- 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
|
||||||
@@ -31,6 +36,7 @@
|
|||||||
- Connection strings are stored under the standard `"ConnectionStrings"` key and read via `IConfiguration.GetConnectionString(...)`.
|
- Connection strings are stored under the standard `"ConnectionStrings"` key and read via `IConfiguration.GetConnectionString(...)`.
|
||||||
- `FuchsOcmsIntranet.Initialize(configuration)` must be called at app start (in `Program.cs`) before DI registration; `Fuchs_intranet` receives `IConfiguration` via its constructor.
|
- `FuchsOcmsIntranet.Initialize(configuration)` must be called at app start (in `Program.cs`) before DI registration; `Fuchs_intranet` receives `IConfiguration` via its constructor.
|
||||||
- `appsettings.Development.json` (git-ignored) can override secrets for local development.
|
- `appsettings.Development.json` (git-ignored) can override secrets for local development.
|
||||||
|
- `Fuchs:Email:OverrideRecipient` (bound via `IOptions<FuchsEmailSettings>`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set this only in `appsettings.Development.json` — it must stay empty in Production (`appsettings.json` documents the safe empty default).
|
||||||
|
|
||||||
## Libraries
|
## Libraries
|
||||||
- Do not upgrade Spire.PDF beyond version 8.10.5.
|
- Do not upgrade Spire.PDF beyond version 8.10.5.
|
||||||
@@ -41,8 +47,8 @@
|
|||||||
|
|
||||||
## Services & Dependency Injection
|
## Services & Dependency Injection
|
||||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
|
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
|
||||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||||
|
|
||||||
@@ -65,7 +71,18 @@
|
|||||||
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
|
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- xUnit in `Fuchs.Tests`. For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`). DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||||
|
- For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`).
|
||||||
|
- Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`.
|
||||||
|
- When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern).
|
||||||
|
- Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value.
|
||||||
|
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||||
|
- DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
||||||
|
|
||||||
|
## Decisions & Concepts
|
||||||
|
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
|
||||||
|
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
|
||||||
|
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
|
||||||
|
|
||||||
## Azure Key Vault — Secret Naming
|
## Azure Key Vault — Secret Naming
|
||||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|
||||||
|
# Scratch / build-verification output
|
||||||
|
/tmp/
|
||||||
|
|
||||||
# SSDT / SQL database project caches (regenerated)
|
# SSDT / SQL database project caches (regenerated)
|
||||||
*.dbmdl
|
*.dbmdl
|
||||||
*.jfm
|
*.jfm
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
[submodule "OCORE"]
|
[submodule "OCORE"]
|
||||||
path = OCORE
|
path = OCORE
|
||||||
url = https://git.processweb.de/Stefan/OCORE.git
|
url = https://git.processweb.de/Stefan/OCORE.git
|
||||||
|
branch = main
|
||||||
[submodule "OCORE_web"]
|
[submodule "OCORE_web"]
|
||||||
path = OCORE_web
|
path = OCORE_web
|
||||||
url = https://git.processweb.de/Stefan/OCORE_web.git
|
url = https://git.processweb.de/Stefan/OCORE_web.git
|
||||||
|
branch = main
|
||||||
[submodule "OCORE_web_pdf"]
|
[submodule "OCORE_web_pdf"]
|
||||||
path = OCORE_web_pdf
|
path = OCORE_web_pdf
|
||||||
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
|
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
|
||||||
|
branch = main
|
||||||
[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
|
||||||
|
branch = main
|
||||||
|
[submodule "eRechnungLib"]
|
||||||
|
path = eRechnungLib
|
||||||
|
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
|
||||||
|
branch = main
|
||||||
|
|||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Fuchs: Debug ASP.NET Core",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build Fuchs",
|
||||||
|
"program": "${workspaceFolder}/Fuchs/bin/Debug/net10.0/Fuchs.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"justMyCode": true,
|
||||||
|
"serverReadyAction": {
|
||||||
|
"action": "openExternally",
|
||||||
|
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
|
||||||
|
"uriFormat": "%s"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
|
||||||
|
},
|
||||||
|
"sourceFileMap": {
|
||||||
|
"/Views": "${workspaceFolder}/Fuchs/Views"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"dotnet run": true,
|
||||||
|
"dotnet test": true,
|
||||||
|
"dotnet build": true,
|
||||||
|
"npx gulp": true,
|
||||||
|
"ForEach-Object": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+129
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "build Fuchs",
|
||||||
|
"type": "process",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Fuchs/Fuchs.csproj",
|
||||||
|
"--configuration",
|
||||||
|
"Debug",
|
||||||
|
"/p:RestoreIgnoreFailedSources=true",
|
||||||
|
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
|
||||||
|
],
|
||||||
|
"problemMatcher": "$msCompile",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: all",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: min",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"min"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: copy",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"copy"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: min:js",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"min:js"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: min:css",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"min:css"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: min:scss",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"min:scss"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: min:html",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"min:html"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gulp: watch",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"gulp",
|
||||||
|
"watch"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/Fuchs"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+25
-10
@@ -51,34 +51,49 @@ public sealed class CamtParser
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses all CAMT XML files found inside a ZIP archive and returns
|
/// Parses all CAMT XML files found inside a ZIP archive and returns
|
||||||
/// the combined list of statements. Non-XML entries and malformed XML
|
/// the combined list of statements. Non-XML entries and malformed XML
|
||||||
/// entries are silently skipped. Used for camt.052 deliveries where the
|
/// entries are skipped. Used for camt.052 deliveries where the
|
||||||
/// bank wraps one or more intraday reports in a single ZIP file.
|
/// bank wraps one or more intraday reports in a single ZIP file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<CamtStatement> ParseZip(byte[] bytes)
|
public List<CamtStatement> ParseZip(byte[] bytes) => ParseZip(bytes, out _);
|
||||||
{
|
|
||||||
using var ms = new MemoryStream(bytes);
|
|
||||||
return ParseZip(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ParseZip(byte[])"/>
|
/// <inheritdoc cref="ParseZip(byte[])"/>
|
||||||
public List<CamtStatement> ParseZip(Stream stream)
|
public List<CamtStatement> ParseZip(Stream stream) => ParseZip(stream, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="ParseZip(byte[])"/>, but also reports which entries were
|
||||||
|
/// skipped and why (non-.xml name, non-XML content, malformed XML), so callers
|
||||||
|
/// can log a reason instead of silently losing part of a delivery.
|
||||||
|
/// </summary>
|
||||||
|
public List<CamtStatement> ParseZip(byte[] bytes, out List<string> skippedEntries)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(bytes);
|
||||||
|
return ParseZip(ms, out skippedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ParseZip(byte[], out List{string})"/>
|
||||||
|
public List<CamtStatement> ParseZip(Stream stream, out List<string> skippedEntries)
|
||||||
{
|
{
|
||||||
var result = new List<CamtStatement>();
|
var result = new List<CamtStatement>();
|
||||||
|
var skipped = new List<string>();
|
||||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue; // non-XML entries (e.g. checksums, manifests) are expected, not worth reporting
|
||||||
using var entryStream = entry.Open();
|
using var entryStream = entry.Open();
|
||||||
using var buffer = new MemoryStream();
|
using var buffer = new MemoryStream();
|
||||||
entryStream.CopyTo(buffer);
|
entryStream.CopyTo(buffer);
|
||||||
var entryBytes = buffer.ToArray();
|
var entryBytes = buffer.ToArray();
|
||||||
if (!LooksLikeXml(entryBytes))
|
if (!LooksLikeXml(entryBytes))
|
||||||
|
{
|
||||||
|
skipped.Add($"{entry.Name}: not XML content");
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
try { result.AddRange(Parse(entryBytes)); }
|
try { result.AddRange(Parse(entryBytes)); }
|
||||||
catch (FormatException) { /* skip malformed XML entries */ }
|
catch (FormatException ex) { skipped.Add($"{entry.Name}: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
skippedEntries = skipped;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
# CLAUDE.md — Project instructions for Claude Code
|
# CLAUDE.md — Project instructions for Claude Code
|
||||||
|
|
||||||
> ## ⚠️ Instruction Sync
|
> ## ⚠️ Instruction Sync
|
||||||
> This file and **`.github/copilot-instructions.md`** are two views of the same
|
> This file, **`CODEX.md`**, and **`.github/copilot-instructions.md`** are three
|
||||||
> project rules and **must stay in sync**. When you change a shared rule
|
> views of the same project rules and **must stay in sync**. When you change a shared rule
|
||||||
> (architecture, coding standards, configuration, libraries, secrets,
|
> (architecture, coding standards, configuration, libraries, secrets,
|
||||||
> observability, testing), make the equivalent change in **both files in the
|
> observability, testing), make the equivalent change in **all three files in the
|
||||||
> same commit**. This file may add Claude Code / workflow specifics; the shared
|
> same commit**. This file may add Claude Code / workflow specifics; the shared
|
||||||
> project facts must match `copilot-instructions.md`.
|
> project facts must match `CODEX.md` and `copilot-instructions.md`.
|
||||||
|
|
||||||
## 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`.
|
||||||
|
- 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`.
|
||||||
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||||
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||||
@@ -29,13 +30,14 @@
|
|||||||
- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`.
|
- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`.
|
||||||
- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration.
|
- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration.
|
||||||
- `appsettings.Development.json` (git-ignored) overrides secrets locally.
|
- `appsettings.Development.json` (git-ignored) overrides secrets locally.
|
||||||
|
- `Fuchs:Email:OverrideRecipient` (`IOptions<FuchsEmailSettings>`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set only in `appsettings.Development.json` — must stay empty in Production.
|
||||||
|
|
||||||
## Libraries
|
## Libraries
|
||||||
- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only.
|
- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only.
|
||||||
|
|
||||||
## Services & Dependency Injection
|
## Services & Dependency Injection
|
||||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||||
- Services: `IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||||
|
|
||||||
@@ -57,13 +59,26 @@
|
|||||||
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- xUnit in `Fuchs.Tests`. For each service/handler change, add tests for **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled). Cover pure logic for DB-bound paths that can't be unit-tested.
|
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||||
|
- For each service/handler change, cover **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled).
|
||||||
|
- Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`.
|
||||||
|
- When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern).
|
||||||
|
- Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value.
|
||||||
|
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||||
|
- Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||||
|
|
||||||
## Secrets (Azure Key Vault)
|
## Secrets (Azure Key Vault)
|
||||||
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
||||||
|
|
||||||
|
## Decisions & Concepts
|
||||||
|
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
|
||||||
|
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
|
||||||
|
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
|
||||||
|
|
||||||
## Documentation map
|
## Documentation map
|
||||||
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
||||||
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
||||||
|
- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above.
|
||||||
|
- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above.
|
||||||
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
||||||
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# CODEX.md — Project instructions for Codex
|
||||||
|
|
||||||
|
> ## Instruction Sync
|
||||||
|
> This file, **`CLAUDE.md`**, and **`.github/copilot-instructions.md`** are three
|
||||||
|
> views of the same project rules and **must stay in sync**. When you change a shared rule
|
||||||
|
> (architecture, coding standards, configuration, libraries, secrets,
|
||||||
|
> observability, testing), make the equivalent change in **all three files in the
|
||||||
|
> same commit**. This file may add Codex / workflow specifics; the shared
|
||||||
|
> project facts must match `CLAUDE.md` and `copilot-instructions.md`.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
||||||
|
- Routes: `/{fn?}/{id?}/{code?}` -> `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` -> `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`).
|
||||||
|
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project.
|
||||||
|
|
||||||
|
## Build & Test (workflow)
|
||||||
|
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||||
|
- 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`.
|
||||||
|
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||||
|
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
|
||||||
|
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
- C# only. Modern, performance-oriented .NET 10 (async/await, LINQ, DI).
|
||||||
|
- Keep files <= 400 (max 600) lines; refactor larger files into focused classes.
|
||||||
|
- PascalCase types/methods, camelCase locals/params.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`.
|
||||||
|
- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration.
|
||||||
|
- `appsettings.Development.json` (git-ignored) overrides secrets locally.
|
||||||
|
- `Fuchs:Email:OverrideRecipient` (`IOptions<FuchsEmailSettings>`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set only in `appsettings.Development.json` — must stay empty in Production.
|
||||||
|
|
||||||
|
## Libraries
|
||||||
|
- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only.
|
||||||
|
|
||||||
|
## Services & Dependency Injection
|
||||||
|
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||||
|
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||||
|
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||||
|
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||||
|
|
||||||
|
## MFR ERP integration
|
||||||
|
- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its contract (base URLs, auth, OData conventions, pagination, error/retry, deep-create + document-upload) is in **`MFR_RESTClient/Docs/mfr_interface_description.md`** — read it before changing the client.
|
||||||
|
- HTTP Basic auth; configurable timeout; idempotent GETs retry on transient errors (429/5xx, network/timeout) with backoff. Create clients via `IMfrClientFactory`. Active project is `MFR_RESTClient.csproj` (legacy `.vbproj` removed).
|
||||||
|
|
||||||
|
## Database
|
||||||
|
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
|
||||||
|
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
|
||||||
|
|
||||||
|
## Bank statement parsing (MT940 + CAMT)
|
||||||
|
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
||||||
|
- `BankingService.ParseToDatatable` auto-detects (XML -> CAMT, else MT940) -> `fds__tt__bankingtransactions`. `bam/up` + the frontend accept both formats. `CAMTParser` is namespace-agnostic. Keep both column mappings aligned with the banking schema.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
|
||||||
|
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings).
|
||||||
|
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||||
|
- For each service/handler change, cover **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled).
|
||||||
|
- Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`.
|
||||||
|
- When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern).
|
||||||
|
- Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value.
|
||||||
|
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||||
|
- Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||||
|
|
||||||
|
## Secrets (Azure Key Vault)
|
||||||
|
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (-> `:`), underscores -> `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
||||||
|
|
||||||
|
## Decisions & Concepts
|
||||||
|
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
|
||||||
|
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
|
||||||
|
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
|
||||||
|
|
||||||
|
## Documentation map
|
||||||
|
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
||||||
|
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
||||||
|
- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above.
|
||||||
|
- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above.
|
||||||
|
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
||||||
|
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
||||||
Binary file not shown.
@@ -1,131 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<configSections>
|
|
||||||
<sectionGroup name="applicationSettings" type="System.Configuration.ApplicationSettingsGroup, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
|
||||||
<section name="fds.My.MySettings" type="System.Configuration.ClientSettingsSection, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
|
|
||||||
</sectionGroup>
|
|
||||||
</configSections>
|
|
||||||
|
|
||||||
<connectionStrings>
|
|
||||||
<add name="fuchs_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
|
||||||
<add name="fuchs_fds_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_fds;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
|
||||||
</connectionStrings>
|
|
||||||
<startup>
|
|
||||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
|
||||||
</startup>
|
|
||||||
<applicationSettings>
|
|
||||||
<fds.My.MySettings>
|
|
||||||
<setting name="ExecutionFrequency_Minutes" serializeAs="String">
|
|
||||||
<value>15</value>
|
|
||||||
</setting>
|
|
||||||
<setting name="DebugDetails" serializeAs="String">
|
|
||||||
<value>True</value>
|
|
||||||
</setting>
|
|
||||||
<setting name="MFR_UserName" serializeAs="String">
|
|
||||||
<value>system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</value>
|
|
||||||
</setting>
|
|
||||||
<setting name="MFR_Password" serializeAs="String">
|
|
||||||
<value>0oT4G3H2</value>
|
|
||||||
</setting>
|
|
||||||
<setting name="MFR_host" serializeAs="String">
|
|
||||||
<value>portal.mobilefieldreport.com</value>
|
|
||||||
</setting>
|
|
||||||
</fds.My.MySettings>
|
|
||||||
</applicationSettings>
|
|
||||||
<runtime>
|
|
||||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.2" newVersion="7.0.0.2" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Net.Http.Formatting" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-5.2.7.0" newVersion="5.2.7.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.Azure.Services.AppAuthentication" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-1.6.2.0" newVersion="1.6.2.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.IdentityModel.Clients.ActiveDirectory" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-5.3.0.0" newVersion="5.3.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Numerics.Vectors" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.1.4.0" newVersion="4.1.4.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.IdentityModel.JsonWebTokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.IO.RecyclableMemoryStream" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-2.3.2.0" newVersion="2.3.2.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="BouncyCastle.Crypto" publicKeyToken="0e99375e54769942" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-1.9.0.0" newVersion="1.9.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="MimeKit" publicKeyToken="bede1c8a46c66814" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Text.Json" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.3" newVersion="7.0.0.3" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.Bcl.AsyncInterfaces" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Text.Encoding.CodePages" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Microsoft.Web.Infrastructure" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-2.0.0.0" newVersion="2.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.ComponentModel.Annotations" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
</assemblyBinding>
|
|
||||||
</runtime>
|
|
||||||
</configuration>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
|
||||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
|
||||||
<ProjectGuid>{7A56E271-A6BE-4C34-A859-DADEBC4C7A54}</ProjectGuid>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<StartupObject>Sub Main</StartupObject>
|
|
||||||
<RootNamespace>fds</RootNamespace>
|
|
||||||
<AssemblyName>Fuchs_DataService</AssemblyName>
|
|
||||||
<FileAlignment>512</FileAlignment>
|
|
||||||
<MyType>Console</MyType>
|
|
||||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
|
||||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
|
||||||
<Deterministic>true</Deterministic>
|
|
||||||
<IsWebBootstrapper>false</IsWebBootstrapper>
|
|
||||||
<PublishUrl>publish\</PublishUrl>
|
|
||||||
<Install>true</Install>
|
|
||||||
<InstallFrom>Disk</InstallFrom>
|
|
||||||
<UpdateEnabled>false</UpdateEnabled>
|
|
||||||
<UpdateMode>Foreground</UpdateMode>
|
|
||||||
<UpdateInterval>7</UpdateInterval>
|
|
||||||
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
|
|
||||||
<UpdatePeriodically>false</UpdatePeriodically>
|
|
||||||
<UpdateRequired>false</UpdateRequired>
|
|
||||||
<MapFileExtensions>true</MapFileExtensions>
|
|
||||||
<ApplicationRevision>0</ApplicationRevision>
|
|
||||||
<ApplicationVersion>1.0.0.%2a</ApplicationVersion>
|
|
||||||
<UseApplicationTrust>false</UseApplicationTrust>
|
|
||||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<DebugType>full</DebugType>
|
|
||||||
<DefineDebug>true</DefineDebug>
|
|
||||||
<DefineTrace>true</DefineTrace>
|
|
||||||
<OutputPath>bin\Debug\</OutputPath>
|
|
||||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
|
||||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
|
||||||
<Prefer32Bit>false</Prefer32Bit>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<DefineDebug>false</DefineDebug>
|
|
||||||
<DefineTrace>true</DefineTrace>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<OutputPath>bin\Release\</OutputPath>
|
|
||||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
|
||||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<OptionExplicit>On</OptionExplicit>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<OptionCompare>Binary</OptionCompare>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<OptionStrict>Off</OptionStrict>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<OptionInfer>On</OptionInfer>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="Microsoft.Web.Infrastructure, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\Microsoft.Web.Infrastructure.2.0.0\lib\net40\Microsoft.Web.Infrastructure.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="SevenZipSharp, Version=1.6.1.23, Culture=neutral, PublicKeyToken=c8ff6ba0184838bb, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\Squid-Box.SevenZipSharp.1.6.1.23\lib\net472\SevenZipSharp.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System" />
|
|
||||||
<Reference Include="System.Configuration" />
|
|
||||||
<Reference Include="System.Configuration.Install" />
|
|
||||||
<Reference Include="System.Data" />
|
|
||||||
<Reference Include="System.Deployment" />
|
|
||||||
<Reference Include="System.Runtime.InteropServices.RuntimeInformation, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
|
|
||||||
<Private>True</Private>
|
|
||||||
<Private>True</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System.ServiceProcess" />
|
|
||||||
<Reference Include="System.Web" />
|
|
||||||
<Reference Include="System.Web.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\Microsoft.AspNet.Razor.3.2.9\lib\net45\System.Web.Razor.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System.Xml" />
|
|
||||||
<Reference Include="System.Core" />
|
|
||||||
<Reference Include="System.Xml.Linq" />
|
|
||||||
<Reference Include="System.Data.DataSetExtensions" />
|
|
||||||
<Reference Include="System.Net.Http" />
|
|
||||||
<Reference Include="Topshelf, Version=4.3.0.0, Culture=neutral, PublicKeyToken=b800c4cfcdeea87b, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\..\..\NugetPackages\Topshelf.4.3.0\lib\net452\Topshelf.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Import Include="Microsoft.VisualBasic" />
|
|
||||||
<Import Include="System" />
|
|
||||||
<Import Include="System.Collections" />
|
|
||||||
<Import Include="System.Collections.Generic" />
|
|
||||||
<Import Include="System.Data" />
|
|
||||||
<Import Include="System.Diagnostics" />
|
|
||||||
<Import Include="System.Linq" />
|
|
||||||
<Import Include="System.Xml.Linq" />
|
|
||||||
<Import Include="System.Threading.Tasks" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="fds_zip.vb" />
|
|
||||||
<Compile Include="fds_debug.vb" />
|
|
||||||
<Compile Include="fds_mfr.vb" />
|
|
||||||
<Compile Include="fds_shared.vb" />
|
|
||||||
<Compile Include="My Project\AssemblyInfo.vb" />
|
|
||||||
<Compile Include="My Project\Application.Designer.vb">
|
|
||||||
<AutoGen>True</AutoGen>
|
|
||||||
<DependentUpon>Application.myapp</DependentUpon>
|
|
||||||
<DesignTime>True</DesignTime>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="My Project\Resources.Designer.vb">
|
|
||||||
<AutoGen>True</AutoGen>
|
|
||||||
<DesignTime>True</DesignTime>
|
|
||||||
<DependentUpon>Resources.resx</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="My Project\Settings.Designer.vb">
|
|
||||||
<AutoGen>True</AutoGen>
|
|
||||||
<DependentUpon>Settings.settings</DependentUpon>
|
|
||||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
|
||||||
</Compile>
|
|
||||||
<Compile Include="fds_main.vb" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="My Project\Resources.resx">
|
|
||||||
<Generator>VbMyResourcesResXFileCodeGenerator</Generator>
|
|
||||||
<LastGenOutput>Resources.Designer.vb</LastGenOutput>
|
|
||||||
<CustomToolNamespace>My.Resources</CustomToolNamespace>
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="install.bat" />
|
|
||||||
<None Include="My Project\Application.myapp">
|
|
||||||
<Generator>MyApplicationCodeGenerator</Generator>
|
|
||||||
<LastGenOutput>Application.Designer.vb</LastGenOutput>
|
|
||||||
</None>
|
|
||||||
<None Include="My Project\Settings.settings">
|
|
||||||
<Generator>SettingsSingleFileGenerator</Generator>
|
|
||||||
<CustomToolNamespace>My</CustomToolNamespace>
|
|
||||||
<LastGenOutput>Settings.Designer.vb</LastGenOutput>
|
|
||||||
</None>
|
|
||||||
<None Include="App.config" />
|
|
||||||
<None Include="packages.config" />
|
|
||||||
<Content Include="un-install.bat" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCMS\OCMS.vbproj">
|
|
||||||
<Project>{ac8cba60-d786-48fd-a9f0-8b045a7bd505}</Project>
|
|
||||||
<Name>OCMS</Name>
|
|
||||||
</ProjectReference>
|
|
||||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.vbproj">
|
|
||||||
<Project>{00c70b53-516d-4d56-ad25-6757094b4335}</Project>
|
|
||||||
<Name>MFR_RESTClient</Name>
|
|
||||||
</ProjectReference>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="7z.dll">
|
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<BootstrapperPackage Include=".NETFramework,Version=v4.8">
|
|
||||||
<Visible>False</Visible>
|
|
||||||
<ProductName>Microsoft .NET Framework 4.8 %28x86 and x64%29</ProductName>
|
|
||||||
<Install>true</Install>
|
|
||||||
</BootstrapperPackage>
|
|
||||||
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
|
|
||||||
<Visible>False</Visible>
|
|
||||||
<ProductName>.NET Framework 3.5 SP1</ProductName>
|
|
||||||
<Install>false</Install>
|
|
||||||
</BootstrapperPackage>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup />
|
|
||||||
<Import Project="$(MSBuildToolsPath)\Microsoft.VisualBasic.targets" />
|
|
||||||
</Project>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
'------------------------------------------------------------------------------
|
|
||||||
' <auto-generated>
|
|
||||||
' This code was generated by a tool.
|
|
||||||
' Runtime Version:4.0.30319.42000
|
|
||||||
'
|
|
||||||
' Changes to this file may cause incorrect behavior and will be lost if
|
|
||||||
' the code is regenerated.
|
|
||||||
' </auto-generated>
|
|
||||||
'------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Option Strict On
|
|
||||||
Option Explicit On
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<MyApplicationData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
||||||
<MySubMain>false</MySubMain>
|
|
||||||
<SingleInstance>false</SingleInstance>
|
|
||||||
<ShutdownMode>0</ShutdownMode>
|
|
||||||
<EnableVisualStyles>true</EnableVisualStyles>
|
|
||||||
<AuthenticationMode>0</AuthenticationMode>
|
|
||||||
<ApplicationType>2</ApplicationType>
|
|
||||||
<SaveMySettingsOnExit>true</SaveMySettingsOnExit>
|
|
||||||
</MyApplicationData>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
Imports System
|
|
||||||
Imports System.Reflection
|
|
||||||
Imports System.Runtime.InteropServices
|
|
||||||
|
|
||||||
' General Information about an assembly is controlled through the following
|
|
||||||
' set of attributes. Change these attribute values to modify the information
|
|
||||||
' associated with an assembly.
|
|
||||||
|
|
||||||
' Review the values of the assembly attributes
|
|
||||||
|
|
||||||
<Assembly: AssemblyTitle("Fuchs_DataService")>
|
|
||||||
<Assembly: AssemblyDescription("")>
|
|
||||||
<Assembly: AssemblyCompany("")>
|
|
||||||
<Assembly: AssemblyProduct("Fuchs_DataService")>
|
|
||||||
<Assembly: AssemblyCopyright("Copyright © 2021")>
|
|
||||||
<Assembly: AssemblyTrademark("")>
|
|
||||||
|
|
||||||
<Assembly: ComVisible(False)>
|
|
||||||
|
|
||||||
'The following GUID is for the ID of the typelib if this project is exposed to COM
|
|
||||||
<Assembly: Guid("b4650e09-34ae-4c0f-b973-63439b8a22f0")>
|
|
||||||
|
|
||||||
' Version information for an assembly consists of the following four values:
|
|
||||||
'
|
|
||||||
' Major Version
|
|
||||||
' Minor Version
|
|
||||||
' Build Number
|
|
||||||
' Revision
|
|
||||||
'
|
|
||||||
' You can specify all the values or you can default the Build and Revision Numbers
|
|
||||||
' by using the '*' as shown below:
|
|
||||||
' <Assembly: AssemblyVersion("1.0.*")>
|
|
||||||
|
|
||||||
<Assembly: AssemblyVersion("1.0.0.0")>
|
|
||||||
<Assembly: AssemblyFileVersion("1.0.0.0")>
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
'------------------------------------------------------------------------------
|
|
||||||
' <auto-generated>
|
|
||||||
' This code was generated by a tool.
|
|
||||||
' Runtime Version:4.0.30319.42000
|
|
||||||
'
|
|
||||||
' Changes to this file may cause incorrect behavior and will be lost if
|
|
||||||
' the code is regenerated.
|
|
||||||
' </auto-generated>
|
|
||||||
'------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Option Strict On
|
|
||||||
Option Explicit On
|
|
||||||
|
|
||||||
Imports System
|
|
||||||
|
|
||||||
Namespace My.Resources
|
|
||||||
|
|
||||||
'This class was auto-generated by the StronglyTypedResourceBuilder
|
|
||||||
'class via a tool like ResGen or Visual Studio.
|
|
||||||
'To add or remove a member, edit your .ResX file then rerun ResGen
|
|
||||||
'with the /str option, or rebuild your VS project.
|
|
||||||
'''<summary>
|
|
||||||
''' A strongly-typed resource class, for looking up localized strings, etc.
|
|
||||||
'''</summary>
|
|
||||||
<Global.System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0"), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
|
||||||
Global.Microsoft.VisualBasic.HideModuleNameAttribute()> _
|
|
||||||
Friend Module Resources
|
|
||||||
|
|
||||||
Private resourceMan As Global.System.Resources.ResourceManager
|
|
||||||
|
|
||||||
Private resourceCulture As Global.System.Globalization.CultureInfo
|
|
||||||
|
|
||||||
'''<summary>
|
|
||||||
''' Returns the cached ResourceManager instance used by this class.
|
|
||||||
'''</summary>
|
|
||||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
|
||||||
Friend ReadOnly Property ResourceManager() As Global.System.Resources.ResourceManager
|
|
||||||
Get
|
|
||||||
If Object.ReferenceEquals(resourceMan, Nothing) Then
|
|
||||||
Dim temp As Global.System.Resources.ResourceManager = New Global.System.Resources.ResourceManager("fds.Resources", GetType(Resources).Assembly)
|
|
||||||
resourceMan = temp
|
|
||||||
End If
|
|
||||||
Return resourceMan
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
'''<summary>
|
|
||||||
''' Overrides the current thread's CurrentUICulture property for all
|
|
||||||
''' resource lookups using this strongly typed resource class.
|
|
||||||
'''</summary>
|
|
||||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
|
||||||
Friend Property Culture() As Global.System.Globalization.CultureInfo
|
|
||||||
Get
|
|
||||||
Return resourceCulture
|
|
||||||
End Get
|
|
||||||
Set
|
|
||||||
resourceCulture = value
|
|
||||||
End Set
|
|
||||||
End Property
|
|
||||||
End Module
|
|
||||||
End Namespace
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<root>
|
|
||||||
<!--
|
|
||||||
Microsoft ResX Schema
|
|
||||||
|
|
||||||
Version 2.0
|
|
||||||
|
|
||||||
The primary goals of this format is to allow a simple XML format
|
|
||||||
that is mostly human readable. The generation and parsing of the
|
|
||||||
various data types are done through the TypeConverter classes
|
|
||||||
associated with the data types.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
... ado.net/XML headers & schema ...
|
|
||||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
|
||||||
<resheader name="version">2.0</resheader>
|
|
||||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
|
||||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
|
||||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
|
||||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
|
||||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
|
||||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
|
||||||
</data>
|
|
||||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
|
||||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
|
||||||
<comment>This is a comment</comment>
|
|
||||||
</data>
|
|
||||||
|
|
||||||
There are any number of "resheader" rows that contain simple
|
|
||||||
name/value pairs.
|
|
||||||
|
|
||||||
Each data row contains a name, and value. The row also contains a
|
|
||||||
type or mimetype. Type corresponds to a .NET class that support
|
|
||||||
text/value conversion through the TypeConverter architecture.
|
|
||||||
Classes that don't support this are serialized and stored with the
|
|
||||||
mimetype set.
|
|
||||||
|
|
||||||
The mimetype is used for serialized objects, and tells the
|
|
||||||
ResXResourceReader how to depersist the object. This is currently not
|
|
||||||
extensible. For a given mimetype the value must be set accordingly:
|
|
||||||
|
|
||||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
|
||||||
that the ResXResourceWriter will generate, however the reader can
|
|
||||||
read any of the formats listed below.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.binary.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.soap.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
|
||||||
value : The object must be serialized into a byte array
|
|
||||||
: using a System.ComponentModel.TypeConverter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
-->
|
|
||||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
|
||||||
<xsd:element name="root" msdata:IsDataSet="true">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:choice maxOccurs="unbounded">
|
|
||||||
<xsd:element name="metadata">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="assembly">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:attribute name="alias" type="xsd:string" />
|
|
||||||
<xsd:attribute name="name" type="xsd:string" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="data">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="resheader">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:choice>
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:schema>
|
|
||||||
<resheader name="resmimetype">
|
|
||||||
<value>text/microsoft-resx</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="version">
|
|
||||||
<value>2.0</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="reader">
|
|
||||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="writer">
|
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
</root>
|
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
'------------------------------------------------------------------------------
|
|
||||||
' <auto-generated>
|
|
||||||
' This code was generated by a tool.
|
|
||||||
' Runtime Version:4.0.30319.42000
|
|
||||||
'
|
|
||||||
' Changes to this file may cause incorrect behavior and will be lost if
|
|
||||||
' the code is regenerated.
|
|
||||||
' </auto-generated>
|
|
||||||
'------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Option Strict On
|
|
||||||
Option Explicit On
|
|
||||||
|
|
||||||
|
|
||||||
Namespace My
|
|
||||||
|
|
||||||
<Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
|
||||||
Global.System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.8.1.0"), _
|
|
||||||
Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
|
||||||
Partial Friend NotInheritable Class MySettings
|
|
||||||
Inherits Global.System.Configuration.ApplicationSettingsBase
|
|
||||||
|
|
||||||
Private Shared defaultInstance As MySettings = CType(Global.System.Configuration.ApplicationSettingsBase.Synchronized(New MySettings()),MySettings)
|
|
||||||
|
|
||||||
#Region "My.Settings Auto-Save Functionality"
|
|
||||||
#If _MyType = "WindowsForms" Then
|
|
||||||
Private Shared addedHandler As Boolean
|
|
||||||
|
|
||||||
Private Shared addedHandlerLockObject As New Object
|
|
||||||
|
|
||||||
<Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
|
||||||
Private Shared Sub AutoSaveSettings(sender As Global.System.Object, e As Global.System.EventArgs)
|
|
||||||
If My.Application.SaveMySettingsOnExit Then
|
|
||||||
My.Settings.Save()
|
|
||||||
End If
|
|
||||||
End Sub
|
|
||||||
#End If
|
|
||||||
#End Region
|
|
||||||
|
|
||||||
Public Shared ReadOnly Property [Default]() As MySettings
|
|
||||||
Get
|
|
||||||
|
|
||||||
#If _MyType = "WindowsForms" Then
|
|
||||||
If Not addedHandler Then
|
|
||||||
SyncLock addedHandlerLockObject
|
|
||||||
If Not addedHandler Then
|
|
||||||
AddHandler My.Application.Shutdown, AddressOf AutoSaveSettings
|
|
||||||
addedHandler = True
|
|
||||||
End If
|
|
||||||
End SyncLock
|
|
||||||
End If
|
|
||||||
#End If
|
|
||||||
Return defaultInstance
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Configuration.DefaultSettingValueAttribute("15")> _
|
|
||||||
Public ReadOnly Property ExecutionFrequency_Minutes() As String
|
|
||||||
Get
|
|
||||||
Return CType(Me("ExecutionFrequency_Minutes"),String)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Configuration.DefaultSettingValueAttribute("True")> _
|
|
||||||
Public ReadOnly Property DebugDetails() As Boolean
|
|
||||||
Get
|
|
||||||
Return CType(Me("DebugDetails"),Boolean)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Configuration.DefaultSettingValueAttribute("system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com")> _
|
|
||||||
Public ReadOnly Property MFR_UserName() As String
|
|
||||||
Get
|
|
||||||
Return CType(Me("MFR_UserName"),String)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Configuration.DefaultSettingValueAttribute("0oT4G3H2")> _
|
|
||||||
Public ReadOnly Property MFR_Password() As String
|
|
||||||
Get
|
|
||||||
Return CType(Me("MFR_Password"),String)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
|
|
||||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Configuration.DefaultSettingValueAttribute("portal.mobilefieldreport.com")> _
|
|
||||||
Public ReadOnly Property MFR_host() As String
|
|
||||||
Get
|
|
||||||
Return CType(Me("MFR_host"),String)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
End Class
|
|
||||||
End Namespace
|
|
||||||
|
|
||||||
Namespace My
|
|
||||||
|
|
||||||
<Global.Microsoft.VisualBasic.HideModuleNameAttribute(), _
|
|
||||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
|
||||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute()> _
|
|
||||||
Friend Module MySettingsProperty
|
|
||||||
|
|
||||||
<Global.System.ComponentModel.Design.HelpKeywordAttribute("My.Settings")> _
|
|
||||||
Friend ReadOnly Property Settings() As Global.fds.My.MySettings
|
|
||||||
Get
|
|
||||||
Return Global.fds.My.MySettings.Default
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
End Module
|
|
||||||
End Namespace
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
|
||||||
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="My" GeneratedClassName="MySettings" UseMySettingsClassName="true">
|
|
||||||
<Profiles />
|
|
||||||
<Settings>
|
|
||||||
<Setting Name="ExecutionFrequency_Minutes" Type="System.String" Scope="Application">
|
|
||||||
<Value Profile="(Default)">15</Value>
|
|
||||||
</Setting>
|
|
||||||
<Setting Name="DebugDetails" Type="System.Boolean" Scope="Application">
|
|
||||||
<Value Profile="(Default)">True</Value>
|
|
||||||
</Setting>
|
|
||||||
<Setting Name="MFR_UserName" Type="System.String" Scope="Application">
|
|
||||||
<Value Profile="(Default)">system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</Value>
|
|
||||||
</Setting>
|
|
||||||
<Setting Name="MFR_Password" Type="System.String" Scope="Application">
|
|
||||||
<Value Profile="(Default)">0oT4G3H2</Value>
|
|
||||||
</Setting>
|
|
||||||
<Setting Name="MFR_host" Type="System.String" Scope="Application">
|
|
||||||
<Value Profile="(Default)">portal.mobilefieldreport.com</Value>
|
|
||||||
</Setting>
|
|
||||||
</Settings>
|
|
||||||
</SettingsFile>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
Option Explicit On
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Partial Friend Module fds_debug
|
|
||||||
|
|
||||||
<Diagnostics.DebuggerStepThrough>
|
|
||||||
Public Function LogFile(FileName As String) As IO.FileInfo
|
|
||||||
Return New IO.FileInfo(AppBaseDirectory().FullName & FileName)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
<Diagnostics.DebuggerStepThrough>
|
|
||||||
Public Function AppBaseDirectory() As IO.DirectoryInfo
|
|
||||||
Dim path As String = AppDomain.CurrentDomain.BaseDirectory + "tmp\"
|
|
||||||
Dim di As New IO.DirectoryInfo(path)
|
|
||||||
If di.Exists = True Then
|
|
||||||
Return di
|
|
||||||
ElseIf My.Computer.FileSystem.DirectoryExists(AppDomain.CurrentDomain.BaseDirectory) = True Then
|
|
||||||
di.Create()
|
|
||||||
Return di
|
|
||||||
Else : Return Nothing
|
|
||||||
End If
|
|
||||||
End Function
|
|
||||||
|
|
||||||
<Diagnostics.DebuggerStepThrough>
|
|
||||||
Public Sub DebugLog_async(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
|
||||||
If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
|
|
||||||
Try
|
|
||||||
Threading.Tasks.Task.Run(Sub() Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=SQLConnectionString, exc:=exc, data:=data, context:=context))
|
|
||||||
Catch ex As Exception
|
|
||||||
Call DebugLog_sync(CodeReference:="fds_debug DebugLog_async", SQLConnectionString:=SQLConnectionString, exc:=ex, data:="", context:=Nothing)
|
|
||||||
End Try
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
<Diagnostics.DebuggerStepThrough>
|
|
||||||
Public Sub DebugLog_sync(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
|
||||||
If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
|
|
||||||
Using con As New SqlClient.SqlConnection(SQLConnectionString)
|
|
||||||
Call DebugLog(CodeReference:=CodeReference, SQLConnection:=con, exc:=exc, data:=data, context:=context)
|
|
||||||
End Using
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
|
|
||||||
<Diagnostics.DebuggerStepThrough>
|
|
||||||
Public Sub DebugLog(CodeReference As String, SQLConnection As SqlClient.SqlConnection, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
|
||||||
If CodeReference = "" OrElse IsNothing(SQLConnection) = True Then Exit Sub
|
|
||||||
Dim note As String = Now.ToString("yyyy.MM.dd HH:mm:ss") & " - " & CodeReference
|
|
||||||
Try
|
|
||||||
Try
|
|
||||||
If IsNothing(SQLConnection) = False Then
|
|
||||||
Dim pl As New List(Of SqlClient.SqlParameter) From {
|
|
||||||
New SqlClient.SqlParameter("@CodeReference", CodeReference),
|
|
||||||
New SqlClient.SqlParameter("@ExceptionMessage", If(IsNothing(exc), DBNull.Value, exc.Message)),
|
|
||||||
New SqlClient.SqlParameter("@StackTrace", If(IsNothing(exc), DBNull.Value, exc.StackTrace.ToString)),
|
|
||||||
New SqlClient.SqlParameter("@data", If(data, DBNull.Value))
|
|
||||||
}
|
|
||||||
Try
|
|
||||||
Dim w As Integer = 0
|
|
||||||
If SQLConnection.State = ConnectionState.Broken Then SQLConnection.Close()
|
|
||||||
If SQLConnection.State = ConnectionState.Connecting Then
|
|
||||||
w = 0
|
|
||||||
While SQLConnection.State = ConnectionState.Connecting And w < 10
|
|
||||||
System.Threading.Thread.Sleep(100)
|
|
||||||
w += 1
|
|
||||||
End While
|
|
||||||
ElseIf Not SQLConnection.State = ConnectionState.Open Then
|
|
||||||
SQLConnection.Open()
|
|
||||||
End If
|
|
||||||
w = 0
|
|
||||||
While Not SQLConnection.State = ConnectionState.Open And w < 10
|
|
||||||
System.Threading.Thread.Sleep(100)
|
|
||||||
w += 1
|
|
||||||
End While
|
|
||||||
Dim cmd As New SqlClient.SqlCommand("EXECUTE [dbo].[fds__admin_logdebug] @CodeReference,@ExceptionMessage,@StackTrace,@Data;", SQLConnection)
|
|
||||||
cmd.Parameters.AddRange(pl.ToArray)
|
|
||||||
Call cmd.ExecuteNonQuery()
|
|
||||||
'SQLConnection.Close()
|
|
||||||
cmd.Parameters.Clear()
|
|
||||||
|
|
||||||
Catch sqlex As Exception
|
|
||||||
End Try
|
|
||||||
|
|
||||||
End If
|
|
||||||
Catch dbex As Exception
|
|
||||||
|
|
||||||
End Try
|
|
||||||
|
|
||||||
If IsNothing(exc) = False Then
|
|
||||||
note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
|
|
||||||
End If
|
|
||||||
If data <> "" Then
|
|
||||||
note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
|
|
||||||
End If
|
|
||||||
note &= vbCrLf
|
|
||||||
|
|
||||||
Dim DebugLogfile As IO.FileInfo = LogFile("DebugLog.txt")
|
|
||||||
If DebugLogfile.Directory.Exists = True Then
|
|
||||||
IO.File.AppendAllText(DebugLogfile.FullName, note)
|
|
||||||
End If
|
|
||||||
Catch logex As Exception
|
|
||||||
|
|
||||||
Finally
|
|
||||||
|
|
||||||
Console.Write(note)
|
|
||||||
Debug.Print(note)
|
|
||||||
End Try
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Public Sub DebugToFile(note As String, Optional filename As String = "DebugLog.txt")
|
|
||||||
Try
|
|
||||||
Dim DebugLogfile As IO.FileInfo = LogFile(filename)
|
|
||||||
If DebugLogfile.Directory.Exists = True Then
|
|
||||||
IO.File.AppendAllText(DebugLogfile.FullName, Now.ToString & ": " & note & vbCrLf)
|
|
||||||
End If
|
|
||||||
Catch ex As Exception
|
|
||||||
End Try
|
|
||||||
End Sub
|
|
||||||
Public Sub DebugToFile(CodeReference As String, exc As Exception, data As String, Optional filename As String = "DebugLog.txt")
|
|
||||||
Dim note As String = CodeReference
|
|
||||||
If IsNothing(exc) = False Then
|
|
||||||
note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
|
|
||||||
End If
|
|
||||||
If data <> "" Then
|
|
||||||
note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
|
|
||||||
End If
|
|
||||||
|
|
||||||
Call DebugToFile(note, filename:=filename)
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
End Module
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
|
|
||||||
Imports Topshelf
|
|
||||||
Imports json = Newtonsoft.Json.JsonConvert
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Public Class fds_service
|
|
||||||
Implements Topshelf.ServiceControl
|
|
||||||
|
|
||||||
Dim WithEvents _timer As System.Timers.Timer
|
|
||||||
|
|
||||||
Public Sub New()
|
|
||||||
Me._timer = New System.Timers.Timer(My.Settings.ExecutionFrequency_Minutes * 60 * 1000) With {.AutoReset = True}
|
|
||||||
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
|
|
||||||
Public Function Start(hostControl As HostControl) As Boolean Implements ServiceControl.Start
|
|
||||||
Me._timer.Start()
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
Public Function StartImmediately(hostControl As HostControl) As Boolean
|
|
||||||
Me._timer.Start()
|
|
||||||
System.Threading.Tasks.Task.Run(Sub()
|
|
||||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service - timer started with interval " & _timer.Interval.ToString, filename:="DebugDetail.txt")
|
|
||||||
Call update_mfr() 'start right away and do not wait until first intervall period is over
|
|
||||||
End Sub)
|
|
||||||
'do not wait and immediately return
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
|
|
||||||
Public Function [Stop](hostControl As HostControl) As Boolean Implements ServiceControl.Stop
|
|
||||||
Me._timer.Stop()
|
|
||||||
If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - timer stopped", filename:="DebugDetail.txt"))
|
|
||||||
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
Public Sub timerElapsed() Handles _timer.Elapsed
|
|
||||||
Call update_mfr()
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Friend Shared Sub update_mfr()
|
|
||||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary - timer elapsed", filename:="DebugDetail.txt")
|
|
||||||
'call update to data if necessary
|
|
||||||
Try
|
|
||||||
Dim t As Threading.Tasks.Task = Threading.Tasks.Task.Run(Async Function()
|
|
||||||
'Await UpdateIfNecessary_Single_async(et:=MFR_RESTClient.generic._generic.EntityTypes.Report, DebugDetails:=My.Settings.DebugDetails)
|
|
||||||
Await UpdateIfNecessary_async(DebugDetails:=My.Settings.DebugDetails)
|
|
||||||
Await UpdateRequested_async(DebugDetails:=My.Settings.DebugDetails)
|
|
||||||
Await GetInvoiceFiles_async(DebugDetails:=My.Settings.DebugDetails)
|
|
||||||
'Await getDatevZip()
|
|
||||||
End Function)
|
|
||||||
t.Wait()
|
|
||||||
Catch ex As Exception
|
|
||||||
Call DebugLog("fds__data_service update_mfr UpdateIfNecessary", SQLConnection:=Nothing, exc:=ex)
|
|
||||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary", exc:=ex, data:="", filename:="DebugDetail.txt")
|
|
||||||
End Try
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
End Class
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Public Module fds_main
|
|
||||||
|
|
||||||
Sub Main()
|
|
||||||
Dim clArgs() As String = Environment.GetCommandLineArgs()
|
|
||||||
|
|
||||||
If (New String() {"digital-pc", "digital-dpc"}).Contains(Environment.MachineName.ToLower) = False Then
|
|
||||||
|
|
||||||
HostFactory.Run(Sub(x)
|
|
||||||
x.Service(Of fds_service)(AddressOf ServiceConfiguratorCallback)
|
|
||||||
x.EnablePauseAndContinue()
|
|
||||||
x.StartAutomatically()
|
|
||||||
x.RunAsLocalSystem()
|
|
||||||
x.SetDescription("MFR Data Sync")
|
|
||||||
x.SetDisplayName("MFR Data Sync")
|
|
||||||
x.SetServiceName("MFR Data Sync")
|
|
||||||
End Sub)
|
|
||||||
Else
|
|
||||||
Call fds_service.update_mfr()
|
|
||||||
'Call DEv()
|
|
||||||
End If
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Private Sub ServiceConfiguratorCallback(s As ServiceConfigurators.ServiceConfigurator(Of fds_service))
|
|
||||||
s.ConstructUsing(Function(name) New fds_service())
|
|
||||||
s.WhenStarted(Function(tc, Host)
|
|
||||||
Return tc.Start(Host)
|
|
||||||
End Function)
|
|
||||||
s.WhenStopped(Function(tc, Host)
|
|
||||||
Return tc.Stop(Host)
|
|
||||||
End Function)
|
|
||||||
s.BeforeStoppingService(Sub(HostStopContext)
|
|
||||||
If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - beforestop", filename:="DebugDetail.txt"))
|
|
||||||
End Sub)
|
|
||||||
s.WhenPaused(Function(tc, Host)
|
|
||||||
Return tc.Stop(Host)
|
|
||||||
End Function)
|
|
||||||
s.WhenContinued(Function(tc, Host)
|
|
||||||
Return tc.StartImmediately(Host)
|
|
||||||
End Function)
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Public Sub DEv()
|
|
||||||
Using MFR As New fds_MFR_Client()
|
|
||||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/$metadata"))
|
|
||||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Companies?$top=5&$expand=Contacts,Tags,ServiceObjects,MainContact"))
|
|
||||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/ServiceObjects?$expand=WarehouseManager,CustomValueSteps,Company,Product,Tags,ChildServiceObject,Contacts,Items"))
|
|
||||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Contacts/$count"))
|
|
||||||
'Diagnostics.Debug.Print(MFR.getEntities())
|
|
||||||
Dim fle As Byte()
|
|
||||||
Try
|
|
||||||
fle = MFR.GetFile("https://portal.mobilefieldreport.com/mfr/Report/19584712737/Content/")
|
|
||||||
System.IO.File.WriteAllBytes("C:\Users\sailo\Desktop\Test.pdf", fle)
|
|
||||||
Catch ex As Exception
|
|
||||||
|
|
||||||
End Try
|
|
||||||
End Using
|
|
||||||
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
End Module
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Partial Friend Module fds_debug
|
|
||||||
|
|
||||||
|
|
||||||
Public Sub DebugLog(CodeReference As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing, Optional execute_async As Boolean = True)
|
|
||||||
If execute_async = True Then
|
|
||||||
Call DebugLog_async(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
|
|
||||||
Else
|
|
||||||
Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
|
|
||||||
End If
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
End Module
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,226 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Friend Module fds_shared
|
|
||||||
|
|
||||||
Friend Function SQLConnectionString() As String
|
|
||||||
Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString
|
|
||||||
End Function
|
|
||||||
Friend Function FDSConnectionString() As String
|
|
||||||
Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_fds_ConnectionString").ConnectionString
|
|
||||||
End Function
|
|
||||||
Friend Function SqlCon() As SqlClient.SqlConnection
|
|
||||||
Return New SqlClient.SqlConnection(Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Public Function RandomString(rs_length As Byte) As String
|
|
||||||
Dim r As New Random()
|
|
||||||
Dim s As String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
Dim sb As New Text.StringBuilder
|
|
||||||
For i As Byte = 1 To rs_length
|
|
||||||
Dim idx As Integer = r.Next(0, s.Length)
|
|
||||||
sb.Append(s.Substring(idx, 1))
|
|
||||||
Next
|
|
||||||
Return sb.ToString()
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
'''' <summary>
|
|
||||||
'''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
|
||||||
'''' </summary>
|
|
||||||
'''' <param name="source">
|
|
||||||
'''' The input <see cref="DataRow" />.
|
|
||||||
'''' </param>
|
|
||||||
'''' <param name="delimiter">
|
|
||||||
'''' The delimiter placed between field values. the default value is a comma.
|
|
||||||
'''' </param>
|
|
||||||
'''' <returns>
|
|
||||||
'''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
|
||||||
'''' </returns>
|
|
||||||
'<Runtime.CompilerServices.Extension>
|
|
||||||
'Public Function ToCsv(source As DataRow,
|
|
||||||
' Optional delimiter As String = ",") As String
|
|
||||||
' Return String.Join(delimiter, source.ItemArray)
|
|
||||||
'End Function
|
|
||||||
|
|
||||||
''' <summary>
|
|
||||||
''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
|
||||||
''' </summary>
|
|
||||||
''' <param name="source">
|
|
||||||
''' The input <see cref="DataRow" />.
|
|
||||||
''' </param>
|
|
||||||
''' <param name="quoteStrings">
|
|
||||||
''' <b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
|
||||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.
|
|
||||||
''' </param>
|
|
||||||
''' <param name="delimiter">
|
|
||||||
''' The delimiter placed between field values. the default value is a comma.
|
|
||||||
''' </param>
|
|
||||||
''' <returns>
|
|
||||||
''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
|
||||||
''' </returns>
|
|
||||||
<Runtime.CompilerServices.Extension>
|
|
||||||
Public Function ToCsv(source As DataRow,
|
|
||||||
quoteStrings As Boolean,
|
|
||||||
cultureinfo As Globalization.CultureInfo,
|
|
||||||
Optional delimiter As String = ",") As String
|
|
||||||
Dim fieldValues = source.ItemArray
|
|
||||||
|
|
||||||
|
|
||||||
Dim rx As New Text.RegularExpressions.Regex("(\"")")
|
|
||||||
'Wrap any String values in double-quotes and also escape any double-quotes in the String with another double-quote.
|
|
||||||
'replace array by converted array
|
|
||||||
fieldValues = fieldValues.Select(Function(o)
|
|
||||||
If IsNothing(o) OrElse IsDBNull(o) Then
|
|
||||||
Return ""
|
|
||||||
ElseIf o.GetType = GetType(String) Then
|
|
||||||
If quoteStrings = True Then
|
|
||||||
Return Microsoft.VisualBasic.ChrW(34) & rx.Replace(o.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34)
|
|
||||||
Else
|
|
||||||
Return o.ToString
|
|
||||||
End If
|
|
||||||
Else
|
|
||||||
Select Case o.GetType
|
|
||||||
Case GetType(Decimal)
|
|
||||||
Return DirectCast(o, Decimal).ToString(cultureinfo)
|
|
||||||
Case GetType(Single)
|
|
||||||
Return DirectCast(o, Single).ToString(cultureinfo)
|
|
||||||
Case GetType(Double)
|
|
||||||
Return DirectCast(o, Double).ToString(cultureinfo)
|
|
||||||
Case GetType(Boolean)
|
|
||||||
Return DirectCast(o, Boolean).ToString(cultureinfo)
|
|
||||||
Case GetType(System.DateTime)
|
|
||||||
Return DirectCast(o, DateTime).ToUniversalTime.ToString("U")
|
|
||||||
Case Else
|
|
||||||
Return o.ToString()
|
|
||||||
End Select
|
|
||||||
End If
|
|
||||||
End Function).ToArray()
|
|
||||||
|
|
||||||
|
|
||||||
Return String.Join(delimiter, fieldValues)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
''' <summary>
|
|
||||||
''' Returns a delimited <see cref="String" /> containing the field values from the rows a <see cref="DataTable" />.
|
|
||||||
''' </summary>
|
|
||||||
''' <param name="source">The input <see cref="DataTable" />.</param>
|
|
||||||
''' <param name="includeHeaders"><b>True</b> to include a row of column headers; otherwise, <b>False</b></param>
|
|
||||||
''' <param name="quoteStrings"><b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
|
||||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.</param>
|
|
||||||
''' <param name="rowDelimiter">The delimiter placed between rows. the default value is a line break comprising a carriage return and a line feed.</param>
|
|
||||||
''' <param name="fieldDelimiter">The delimiter placed between field values. the default value is a comma.</param>
|
|
||||||
''' <param name="cultureinfo">The culture that is used to convert float-point numbers like <see cref="Decimal" /> or <see cref="Double"/> to string. <br/>This falls back to InvariantCulture, if not provided.</param>
|
|
||||||
''' <param name="quoteHeader"><b>True</b> to wrap <see cref="String"/> column header names in double-quotes; otherwise, <b>False</b>.<br />
|
|
||||||
''' If no value is provided, the settings falls back to <b>quoteStrings</b> parameter.</param>
|
|
||||||
''' <returns>A <see cref="String"/> containing the field values from the rows of the table separated by the specified delimiters.</returns>
|
|
||||||
<Runtime.CompilerServices.Extension>
|
|
||||||
Public Function ToCsv(source As DataTable,
|
|
||||||
includeHeaders As Boolean,
|
|
||||||
quoteStrings As Boolean,
|
|
||||||
Optional rowDelimiter As String = ControlChars.CrLf,
|
|
||||||
Optional fieldDelimiter As String = ",",
|
|
||||||
Optional cultureinfo As Globalization.CultureInfo = Nothing,
|
|
||||||
Optional quoteHeader As Boolean? = Nothing) As String
|
|
||||||
If quoteHeader.HasValue = False Then quoteHeader = quoteStrings
|
|
||||||
cultureinfo = If(cultureinfo, Globalization.CultureInfo.InvariantCulture) 'fallback if not provided
|
|
||||||
Dim rows = source.Rows.
|
|
||||||
Cast(Of DataRow)().
|
|
||||||
Select(Function(row) row.ToCsv(quoteStrings:=quoteStrings, cultureinfo:=cultureinfo, delimiter:=fieldDelimiter))
|
|
||||||
|
|
||||||
If includeHeaders = True Then
|
|
||||||
Dim rx As New Text.RegularExpressions.Regex("(\"")")
|
|
||||||
Dim headers = String.Join(fieldDelimiter,
|
|
||||||
source.Columns.
|
|
||||||
Cast(Of DataColumn)().
|
|
||||||
Select(Function(column) If(quoteHeader.Value,
|
|
||||||
Microsoft.VisualBasic.ChrW(34) & rx.Replace(column.ColumnName.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34),
|
|
||||||
column.ColumnName)))
|
|
||||||
|
|
||||||
rows = {headers}.Concat(rows)
|
|
||||||
End If
|
|
||||||
|
|
||||||
Return String.Join(rowDelimiter, rows)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
''' <summary>
|
|
||||||
''' Returns a text-file containing the string, created by streamwriter.
|
|
||||||
''' </summary>
|
|
||||||
''' <param name="input">The input <see cref="String"/>.</param>
|
|
||||||
''' <param name="encoding">The encoding used with streamwriter for the textfile. This falls back to <see cref="System.Text.Encoding.utf8"/>, if not provided.</param>
|
|
||||||
''' <returns>A file as byte-array.</returns>
|
|
||||||
<Runtime.CompilerServices.Extension>
|
|
||||||
Public Function ToByteArray(input As String, Optional encoding As System.Text.Encoding = Nothing) As Byte()
|
|
||||||
Dim content As Byte() = Nothing
|
|
||||||
Using ms As New IO.MemoryStream
|
|
||||||
Using sw As New IO.StreamWriter(ms, encoding:=If(encoding, System.Text.Encoding.UTF8))
|
|
||||||
sw.Write(input)
|
|
||||||
sw.Flush()
|
|
||||||
ms.Position = 0
|
|
||||||
content = ms.ToArray()
|
|
||||||
End Using
|
|
||||||
End Using
|
|
||||||
Return content
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
Public Function WriteStreamToDisk(ByVal StreamToWrite As IO.Stream, ByVal FilePath As String) As Boolean
|
|
||||||
'Dim tmpFilePath As String = Left(FilePath, Len(FilePath) - 4) & ".tmp"
|
|
||||||
Dim cnt = 0
|
|
||||||
restart:
|
|
||||||
Try
|
|
||||||
If My.Computer.FileSystem.FileExists(FilePath) Then My.Computer.FileSystem.DeleteFile(FilePath)
|
|
||||||
Using FleStream As System.IO.FileStream = New System.IO.FileStream(FilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.Delete)
|
|
||||||
ReadWriteStream(StreamToWrite, FleStream, True)
|
|
||||||
End Using
|
|
||||||
Catch ex As Exception
|
|
||||||
System.Diagnostics.Debug.WriteLine($"{"WriteStreamToDisk - " & ex.Message}")
|
|
||||||
cnt += 1
|
|
||||||
If cnt = 6 Then
|
|
||||||
Return False
|
|
||||||
Exit Function
|
|
||||||
Else
|
|
||||||
Threading.Thread.Sleep(500)
|
|
||||||
GoTo restart
|
|
||||||
End If
|
|
||||||
End Try
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
|
|
||||||
Public Function ReadWriteStream(ByVal readStream As IO.Stream, ByVal writeStream As IO.Stream, ByVal closeWriteStream As Boolean) As Boolean
|
|
||||||
Try
|
|
||||||
Dim Length As Integer = 256
|
|
||||||
Dim buffer(Length - 1) As Byte
|
|
||||||
readStream.Seek(0, System.IO.SeekOrigin.Begin)
|
|
||||||
Dim bytesRead As Integer = readStream.Read(buffer, 0, Length)
|
|
||||||
'write the required bytes
|
|
||||||
While (bytesRead > 0)
|
|
||||||
writeStream.Write(buffer, 0, bytesRead)
|
|
||||||
bytesRead = readStream.Read(buffer, 0, Length)
|
|
||||||
End While
|
|
||||||
readStream.Close()
|
|
||||||
If closeWriteStream = True Then writeStream.Close()
|
|
||||||
Return True
|
|
||||||
Catch ex As Exception
|
|
||||||
System.Diagnostics.Debug.WriteLine($"{"ReadWriteStream - " & ex.Message}")
|
|
||||||
Call OCMS.debug_log("files_folders ReadWriteStream", ex)
|
|
||||||
Return False
|
|
||||||
End Try
|
|
||||||
End Function
|
|
||||||
|
|
||||||
<System.Diagnostics.DebuggerStepThrough()>
|
|
||||||
<Runtime.CompilerServices.Extension()>
|
|
||||||
Public Function NameBase(ByVal FI As System.IO.FileInfo) As String
|
|
||||||
Return FI.Name.Substring(startIndex:=0, length:=FI.Name.Length - FI.Extension.Length)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
<System.Diagnostics.DebuggerStepThrough()>
|
|
||||||
<Runtime.CompilerServices.Extension()>
|
|
||||||
Public Function MimeType(ByVal FI As System.IO.FileInfo) As String
|
|
||||||
Return System.Web.MimeMapping.GetMimeMapping(FI.Name)
|
|
||||||
End Function
|
|
||||||
End Module
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
Imports SevenZip 'Squid-Box.SevenZipSharp
|
|
||||||
Imports System.IO
|
|
||||||
|
|
||||||
Namespace Global.fds
|
|
||||||
Public Class Archive
|
|
||||||
Implements IDisposable
|
|
||||||
|
|
||||||
'Private Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long
|
|
||||||
'Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
|
|
||||||
|
|
||||||
'Public Enum Timeunit As Long
|
|
||||||
' Milliseconds = 0
|
|
||||||
' Seconds = 1000
|
|
||||||
' Minutes = 60000
|
|
||||||
'End Enum
|
|
||||||
'Public Sub Wait(ByVal No As Integer, ByVal unit As Timeunit)
|
|
||||||
' Dim tme As Long = CLng(No * unit)
|
|
||||||
' Sleep(tme)
|
|
||||||
'End Sub
|
|
||||||
|
|
||||||
Public Event Saving()
|
|
||||||
Public Event FileSaved()
|
|
||||||
Public Event FileStreamCreated()
|
|
||||||
|
|
||||||
Private _ArchiveFile As FileInfo
|
|
||||||
Private _ArchivePassword As String
|
|
||||||
Private _ArchiveFormat As OutArchiveFormat
|
|
||||||
Public TempPath As String = System.AppDomain.CurrentDomain.BaseDirectory
|
|
||||||
Public Property ArchiveFileStream As IO.Stream
|
|
||||||
|
|
||||||
Private ZipOut As SevenZipExtractor
|
|
||||||
Private ZipIn As SevenZipCompressor
|
|
||||||
Public ZipAppend As Boolean = True
|
|
||||||
|
|
||||||
Public ExitOK As Boolean = False
|
|
||||||
Public ZipInOK As Boolean = False
|
|
||||||
|
|
||||||
Public Sub New(ByVal ArchiveFile As FileInfo, Optional ByVal ArchivePassword As String = "", Optional ByVal INIT As Boolean = True, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip)
|
|
||||||
Me._ArchiveFormat = Type
|
|
||||||
Me._ArchiveFile = New FileInfo(ArchiveFile.FullName.Replace(ArchiveFile.Extension, If(Type = OutArchiveFormat.SevenZip, ".7z", ArchiveFile.Extension)))
|
|
||||||
Me._ArchivePassword = ArchivePassword
|
|
||||||
If INIT = True Then Call InitZipIn(Type)
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Private Sub InitZipIn(ByVal Type As OutArchiveFormat)
|
|
||||||
Dim assemblydirectory As IO.DirectoryInfo
|
|
||||||
If Zipping.SevenZipPath = "" Then
|
|
||||||
Try
|
|
||||||
assemblydirectory = New IO.DirectoryInfo(New Uri(System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase)).LocalPath)
|
|
||||||
Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
|
|
||||||
Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
|
|
||||||
Finally
|
|
||||||
If Zipping.SevenZipPath = "" Then
|
|
||||||
assemblydirectory = New IO.DirectoryInfo(System.AppDomain.CurrentDomain.BaseDirectory)
|
|
||||||
Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
|
|
||||||
Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
|
|
||||||
End If
|
|
||||||
End Try
|
|
||||||
If Zipping.SevenZipPath = "" Then
|
|
||||||
OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", error:="SevenZipPath not found")
|
|
||||||
End If
|
|
||||||
End If
|
|
||||||
SevenZipCompressor.SetLibraryPath(SevenZipPath)
|
|
||||||
|
|
||||||
Me.ZipIn = New SevenZipCompressor
|
|
||||||
With Me.ZipIn
|
|
||||||
If Type = OutArchiveFormat.SevenZip AndAlso Me._ArchiveFile.Extension.ToLower.Contains("7z") = True Then
|
|
||||||
.ArchiveFormat = OutArchiveFormat.SevenZip
|
|
||||||
Else
|
|
||||||
.ArchiveFormat = Type
|
|
||||||
End If
|
|
||||||
|
|
||||||
.CompressionLevel = SevenZip.CompressionLevel.Ultra
|
|
||||||
Select Case .ArchiveFormat
|
|
||||||
Case OutArchiveFormat.SevenZip
|
|
||||||
.CompressionMethod = SevenZip.CompressionMethod.Lzma2
|
|
||||||
Case OutArchiveFormat.Zip, OutArchiveFormat.GZip
|
|
||||||
.CompressionMethod = CompressionMethod.Deflate
|
|
||||||
Case Else
|
|
||||||
.CompressionMethod = CompressionMethod.Default
|
|
||||||
End Select
|
|
||||||
|
|
||||||
If ZipAppend = True Then
|
|
||||||
.CompressionMode = SevenZip.CompressionMode.Append
|
|
||||||
Else
|
|
||||||
.CompressionMode = SevenZip.CompressionMode.Create
|
|
||||||
End If
|
|
||||||
.DirectoryStructure = False
|
|
||||||
End With
|
|
||||||
Me.ZipInOK = True
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Public Sub Extract(ByVal DataArchiveFilePath As FileInfo, ByVal TgtDirectory As DirectoryInfo, Optional ByVal Type As OutArchiveFormat = Nothing)
|
|
||||||
If DataArchiveFilePath.Exists Then
|
|
||||||
If IsNothing(Type) = True AndAlso DataArchiveFilePath.Extension.ToLower.Contains("7z") = True Then
|
|
||||||
Type = OutArchiveFormat.SevenZip
|
|
||||||
ElseIf IsNothing(Type) = True Then
|
|
||||||
Type = OutArchiveFormat.Zip
|
|
||||||
End If
|
|
||||||
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Type)
|
|
||||||
|
|
||||||
If Me._ArchivePassword = "" Then
|
|
||||||
Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName)
|
|
||||||
Else
|
|
||||||
Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName, Me._ArchivePassword)
|
|
||||||
End If
|
|
||||||
|
|
||||||
Try
|
|
||||||
If Me.ZipOut.ArchiveFileData(0).Encrypted = False And Not Me._ArchivePassword = "" Then
|
|
||||||
Me._ArchivePassword = ""
|
|
||||||
End If
|
|
||||||
Catch ex As Exception
|
|
||||||
System.Diagnostics.Debug.WriteLine($"{"zip Extract - " & ex.Message}")
|
|
||||||
OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", ex:=ex, data:=New With {.DataArchiveFilePath = DataArchiveFilePath.FullName, .TgtDirectory = TgtDirectory.FullName})
|
|
||||||
Exit Sub
|
|
||||||
End Try
|
|
||||||
|
|
||||||
If Not ZipOut Is Nothing Then
|
|
||||||
Me.ZipOut.ExtractArchive(TgtDirectory.FullName)
|
|
||||||
Me.ZipOut.Dispose()
|
|
||||||
End If
|
|
||||||
End If
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
Public Function FileInfo_to_Filepaths_Converter() As Converter(Of FileInfo, String)
|
|
||||||
Return New Converter(Of FileInfo, String)(Function(filepath As FileInfo) filepath.FullName())
|
|
||||||
End Function
|
|
||||||
Public Function Filepaths_to_FileInfo_Converter() As Converter(Of String, FileInfo)
|
|
||||||
Return New Converter(Of String, FileInfo)(Function(filepath As String) New FileInfo(filepath))
|
|
||||||
End Function
|
|
||||||
|
|
||||||
Public Function Compress(ByVal FilePaths As List(Of String), Optional ByVal ArchiveFilePath As String = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
|
|
||||||
Return Compress(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()), ArchiveFile:=If(IsNothing(ArchiveFilePath), Nothing, New FileInfo(ArchiveFilePath)), ArchivePass:=ArchivePass, Type:=Type)
|
|
||||||
End Function
|
|
||||||
Public Function Compress(ByVal Files As List(Of FileInfo), Optional ByVal ArchiveFile As FileInfo = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
|
|
||||||
If Files.Count = 0 Then Return True
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Type)
|
|
||||||
|
|
||||||
If IsNothing(ArchiveFile) = True Then ArchiveFile = Me._ArchiveFile
|
|
||||||
If If(IsNothing(ArchivePass), "", ArchivePass) = "" Then ArchivePass = Me._ArchivePassword
|
|
||||||
|
|
||||||
If ArchiveFile.Exists() AndAlso Me.ZipAppend = True Then
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
|
||||||
Else
|
|
||||||
If ArchiveFile.Exists = True Then ArchiveFile.Delete()
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
|
||||||
End If
|
|
||||||
|
|
||||||
Try
|
|
||||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
|
||||||
Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
|
||||||
If ArchivePass = "" Then
|
|
||||||
Me.ZipIn.CompressFiles(ArchiveFile.FullName, FilePaths)
|
|
||||||
Else
|
|
||||||
Me.ZipIn.EncryptHeaders = True
|
|
||||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
|
||||||
Me.ZipIn.CompressFilesEncrypted(ArchiveFile.FullName, ArchivePass, FilePaths)
|
|
||||||
End If
|
|
||||||
RaiseEvent FileSaved()
|
|
||||||
|
|
||||||
'Debug.Print("Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = True
|
|
||||||
'Disposing...
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Me.ZipInOK = False
|
|
||||||
Catch ex As Exception
|
|
||||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = False
|
|
||||||
End Try
|
|
||||||
Return Me.ExitOK AndAlso ArchiveFile.Exists
|
|
||||||
End Function
|
|
||||||
Public Function Compress(ByVal FilePath As String) As Boolean
|
|
||||||
Dim FL As New List(Of String) From {
|
|
||||||
FilePath
|
|
||||||
}
|
|
||||||
Call Compress(FL)
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
Public Function CompressToStream(ByVal FilePath As String) As Boolean
|
|
||||||
Dim FL As New List(Of String) From {
|
|
||||||
FilePath
|
|
||||||
}
|
|
||||||
Call CompressToStream(FL)
|
|
||||||
Return True
|
|
||||||
End Function
|
|
||||||
|
|
||||||
Public Function CompressToStream(ByVal FilePaths As List(Of String)) As Boolean
|
|
||||||
Return CompressToStream(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()))
|
|
||||||
End Function
|
|
||||||
Public Function CompressToStream(ByVal Files As List(Of FileInfo)) As Boolean
|
|
||||||
If Files.Count = 0 Then Return True
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
|
||||||
|
|
||||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
|
||||||
Me._ArchiveFileStream = New MemoryStream
|
|
||||||
Else
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
|
||||||
End If
|
|
||||||
|
|
||||||
Try
|
|
||||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
|
||||||
Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
|
||||||
If Me._ArchivePassword = "" Then
|
|
||||||
Me.ZipIn.CompressFiles(Me._ArchiveFileStream, FilePaths)
|
|
||||||
Else
|
|
||||||
Me.ZipIn.EncryptHeaders = True
|
|
||||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
|
||||||
Me.ZipIn.CompressFilesEncrypted(Me._ArchiveFileStream, Me._ArchivePassword, FilePaths)
|
|
||||||
End If
|
|
||||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
|
||||||
RaiseEvent FileStreamCreated()
|
|
||||||
|
|
||||||
'Debug.Print("Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = True
|
|
||||||
'Disposing...
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Me.ZipInOK = False
|
|
||||||
Catch ex As Exception
|
|
||||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = False
|
|
||||||
End Try
|
|
||||||
Return Me.ExitOK
|
|
||||||
End Function
|
|
||||||
|
|
||||||
Public Function CompressToStream(ByVal Files As List(Of FileInfo), ByRef TargetStream As IO.Stream) As Boolean
|
|
||||||
If Files.Count = 0 Then Return True
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
|
||||||
|
|
||||||
If IsNothing(TargetStream) = True Then
|
|
||||||
TargetStream = New MemoryStream
|
|
||||||
End If
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
|
||||||
Dim FilePaths As String() = New String() {}
|
|
||||||
Try
|
|
||||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
|
||||||
FilePaths = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
|
||||||
If Me._ArchivePassword = "" Then
|
|
||||||
Me.ZipIn.CompressFiles(TargetStream, FilePaths)
|
|
||||||
Else
|
|
||||||
Me.ZipIn.EncryptHeaders = True
|
|
||||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
|
||||||
Me.ZipIn.CompressFilesEncrypted(TargetStream, Me._ArchivePassword, FilePaths)
|
|
||||||
End If
|
|
||||||
TargetStream.Seek(0, SeekOrigin.Begin)
|
|
||||||
|
|
||||||
'Debug.Print("Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = True
|
|
||||||
'Disposing...
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Me.ZipInOK = False
|
|
||||||
Catch ex As Exception
|
|
||||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
|
||||||
OCMS.debug_log("IntranetController zip", ex, data:=New With {.filepaths = FilePaths})
|
|
||||||
Me.ExitOK = False
|
|
||||||
End Try
|
|
||||||
Return Me.ExitOK
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
Public Function CompressToStream(ByVal Files As Dictionary(Of String, IO.Stream)) As Boolean
|
|
||||||
If Files.Count = 0 Then Return True
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
|
||||||
|
|
||||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
|
||||||
Me._ArchiveFileStream = New MemoryStream
|
|
||||||
Else
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
|
||||||
End If
|
|
||||||
|
|
||||||
Try
|
|
||||||
If Me._ArchivePassword = "" Then
|
|
||||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream)
|
|
||||||
Else
|
|
||||||
Me.ZipIn.EncryptHeaders = True
|
|
||||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
|
||||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream, password:=Me._ArchivePassword)
|
|
||||||
End If
|
|
||||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
|
||||||
RaiseEvent FileStreamCreated()
|
|
||||||
|
|
||||||
'Debug.Print("Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = True
|
|
||||||
'Disposing...
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Me.ZipInOK = False
|
|
||||||
Catch ex As Exception
|
|
||||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = False
|
|
||||||
End Try
|
|
||||||
Return Me.ExitOK
|
|
||||||
End Function
|
|
||||||
Public Function CompressToStream(ByVal Files As Dictionary(Of String, Byte()), Optional targetstream As IO.Stream = Nothing) As Boolean
|
|
||||||
If Files.Count = 0 Then Return True
|
|
||||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
|
||||||
|
|
||||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
|
||||||
Me._ArchiveFileStream = New MemoryStream
|
|
||||||
Else
|
|
||||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
|
||||||
End If
|
|
||||||
|
|
||||||
Try
|
|
||||||
Dim FilesStreams As New Dictionary(Of String, IO.Stream)
|
|
||||||
For Each fy As String In Files.Keys
|
|
||||||
FilesStreams.Add(fy, New IO.MemoryStream(Files(fy)))
|
|
||||||
Next
|
|
||||||
|
|
||||||
If Me._ArchivePassword = "" Then
|
|
||||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream))
|
|
||||||
Else
|
|
||||||
Me.ZipIn.EncryptHeaders = True
|
|
||||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
|
||||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream), password:=Me._ArchivePassword)
|
|
||||||
End If
|
|
||||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
|
||||||
RaiseEvent FileStreamCreated()
|
|
||||||
|
|
||||||
'Debug.Print("Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = True
|
|
||||||
'Disposing...
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Me.ZipInOK = False
|
|
||||||
Catch ex As Exception
|
|
||||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
|
||||||
Me.ExitOK = False
|
|
||||||
End Try
|
|
||||||
Return Me.ExitOK
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
Public Function WriteArchiveStreamToDisk(Optional ByVal ArchiveFile As IO.FileInfo = Nothing) As Boolean
|
|
||||||
Try
|
|
||||||
If Me._ArchiveFile.Exists() Then Me._ArchiveFile.Delete()
|
|
||||||
Catch ex As Exception
|
|
||||||
End Try
|
|
||||||
If IsNothing(ArchiveFile) = False Then 'Wenn ein DateiPfad hier übergeben wurde...
|
|
||||||
WriteStreamToDisk(Me._ArchiveFileStream, ArchiveFile.FullName)
|
|
||||||
Else ' sonst wird der interne genommen
|
|
||||||
WriteStreamToDisk(Me._ArchiveFileStream, Me._ArchiveFile.FullName)
|
|
||||||
End If
|
|
||||||
Return Me._ArchiveFile.Exists
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'Private ArchiveMail As EMail = Nothing
|
|
||||||
'Public Function SendZip(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
|
|
||||||
' Try
|
|
||||||
' If ArchiveMail Is Nothing Then Exit Function
|
|
||||||
' If Not Subject = Nothing Then
|
|
||||||
' ArchiveMail.Subject = Subject
|
|
||||||
' End If
|
|
||||||
' If Not BodyText = Nothing Then
|
|
||||||
' ArchiveMail.Body = BodyText
|
|
||||||
' End If
|
|
||||||
' 'Send with archive as attachment
|
|
||||||
' Return ArchiveMail.Send(_ArchivePath.Path)
|
|
||||||
' Catch ex As Exception
|
|
||||||
' Return False
|
|
||||||
' End Try
|
|
||||||
'End Function
|
|
||||||
'Public Function SendZipStream(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
|
|
||||||
' Try
|
|
||||||
' If ArchiveMail Is Nothing Then Exit Function
|
|
||||||
' If Not Subject = Nothing Then
|
|
||||||
' ArchiveMail.Subject = Subject
|
|
||||||
' End If
|
|
||||||
' If Not BodyText = Nothing Then
|
|
||||||
' ArchiveMail.Body = BodyText
|
|
||||||
' End If
|
|
||||||
' 'Send with archive as attachment
|
|
||||||
' Return ArchiveMail.Send(_ArchivePath.Name, _ArchiveFileStream)
|
|
||||||
' Catch ex As Exception
|
|
||||||
' Return False
|
|
||||||
' End Try
|
|
||||||
'End Function
|
|
||||||
'Public Sub SetMailSettings(ByVal SMTP As MailServer_Settings, ByVal MAIL As Mail_Settings)
|
|
||||||
' ArchiveMail = New EMail(SMTP, MAIL)
|
|
||||||
'End Sub
|
|
||||||
|
|
||||||
|
|
||||||
#Region "IDisposable Support"
|
|
||||||
Private disposedValue As Boolean ' To detect redundant calls
|
|
||||||
|
|
||||||
' IDisposable
|
|
||||||
Protected Overridable Sub Dispose(disposing As Boolean)
|
|
||||||
If Not disposedValue Then
|
|
||||||
If disposing Then
|
|
||||||
' TODO: dispose managed state (managed objects).
|
|
||||||
Try
|
|
||||||
If IsNothing(Me._ArchiveFileStream) = False Then Me._ArchiveFileStream.Dispose()
|
|
||||||
If IsNothing(Me.ZipOut) = False Then Me.ZipOut.Dispose()
|
|
||||||
Me.ZipIn = Nothing
|
|
||||||
Catch ex As Exception
|
|
||||||
|
|
||||||
End Try
|
|
||||||
End If
|
|
||||||
|
|
||||||
' TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
|
|
||||||
' TODO: set large fields to null.
|
|
||||||
End If
|
|
||||||
disposedValue = True
|
|
||||||
End Sub
|
|
||||||
|
|
||||||
' TODO: override Finalize() only if Dispose(disposing As Boolean) above has code to free unmanaged resources.
|
|
||||||
'Protected Overrides Sub Finalize()
|
|
||||||
' ' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
|
|
||||||
' Dispose(False)
|
|
||||||
' MyBase.Finalize()
|
|
||||||
'End Sub
|
|
||||||
|
|
||||||
' This code added by Visual Basic to correctly implement the disposable pattern.
|
|
||||||
Public Sub Dispose() Implements IDisposable.Dispose
|
|
||||||
' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
|
|
||||||
Dispose(True)
|
|
||||||
' TODO: uncomment the following line if Finalize() is overridden above.
|
|
||||||
' GC.SuppressFinalize(Me)
|
|
||||||
End Sub
|
|
||||||
#End Region
|
|
||||||
|
|
||||||
|
|
||||||
End Class
|
|
||||||
|
|
||||||
|
|
||||||
Public Module Zipping
|
|
||||||
|
|
||||||
Public SevenZipPath As String = ""
|
|
||||||
|
|
||||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal ArchiveFile As FileInfo)
|
|
||||||
If FileToZip.Exists AndAlso IsNothing(ArchiveFile) = False AndAlso ArchiveFile.Exists Then
|
|
||||||
Dim Zip As New Archive(ArchiveFile) With {
|
|
||||||
.ZipAppend = True
|
|
||||||
}
|
|
||||||
Dim FL As New List(Of String) From {
|
|
||||||
FileToZip.FullName
|
|
||||||
}
|
|
||||||
Zip.Compress(FL)
|
|
||||||
End If
|
|
||||||
End Sub
|
|
||||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectory As DirectoryInfo, ByVal ArchiveName As String)
|
|
||||||
Dim ArchiveFile As New FileInfo(TgtArchiveDirectory.FullName & If(Strings.Right(TgtArchiveDirectory.FullName, 1) = "\", "", "\") & ArchiveName)
|
|
||||||
If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
|
|
||||||
End Sub
|
|
||||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectoryPath As String, ByVal ArchiveName As String)
|
|
||||||
Dim ArchiveFile As New FileInfo(TgtArchiveDirectoryPath & If(Strings.Right(TgtArchiveDirectoryPath, 1) = "\", "", "\") & ArchiveName)
|
|
||||||
If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
|
|
||||||
End Sub
|
|
||||||
Public Function FastZip(ByVal DirectoryToZip As DirectoryInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
|
||||||
If DirectoryToZip.Exists Then
|
|
||||||
Dim ZipFile As New FileInfo(DirectoryToZip.Name + ".7z"), cnt As Integer = 0
|
|
||||||
If Append = False Then
|
|
||||||
Do Until ZipFile.Exists = False
|
|
||||||
cnt += 1
|
|
||||||
ZipFile = New FileInfo(DirectoryToZip.Name & "_" & CStr(cnt) & ".7z")
|
|
||||||
Loop
|
|
||||||
End If
|
|
||||||
Dim Zip As New Archive(ZipFile) With {
|
|
||||||
.ZipAppend = Append
|
|
||||||
}
|
|
||||||
Zip.Compress(New List(Of FileInfo)(DirectoryToZip.GetFiles))
|
|
||||||
Return If(ZipFile.Exists, ZipFile, Nothing)
|
|
||||||
Else
|
|
||||||
Return Nothing
|
|
||||||
End If
|
|
||||||
End Function
|
|
||||||
Public Function FastZip(ByVal FileToZip As IO.FileInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
|
||||||
If FileToZip.Exists Then
|
|
||||||
Dim ZipFile As New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") + ".7z"), cnt As Integer = 0
|
|
||||||
If Append = False Then
|
|
||||||
Do Until ZipFile.Exists = False
|
|
||||||
cnt += 1
|
|
||||||
ZipFile = New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") & "_" & CStr(cnt) & ".7z")
|
|
||||||
Loop
|
|
||||||
End If
|
|
||||||
Dim Zip As New Archive(ZipFile) With {
|
|
||||||
.ZipAppend = Append
|
|
||||||
}
|
|
||||||
Dim FL As New List(Of FileInfo) From {
|
|
||||||
FileToZip
|
|
||||||
}
|
|
||||||
Zip.Compress(FL)
|
|
||||||
Return If(ZipFile.Exists, ZipFile, Nothing)
|
|
||||||
Else
|
|
||||||
Return Nothing
|
|
||||||
End If
|
|
||||||
End Function
|
|
||||||
Public Function FastZip(ByVal TgtDirectory As String, ByVal Filename As String, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
|
||||||
Return FastZip(New FileInfo(TgtDirectory & If(Strings.Right(TgtDirectory, 1) = "\", "", "\") & Filename), Append)
|
|
||||||
End Function
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
End Module
|
|
||||||
|
|
||||||
End Namespace
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Fuchs_Dataservice.exe install --autostart
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<packages>
|
|
||||||
<package id="Microsoft.AspNet.Razor" version="3.2.9" targetFramework="net48" />
|
|
||||||
<package id="Microsoft.Web.Infrastructure" version="2.0.0" targetFramework="net48" />
|
|
||||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
|
||||||
<package id="Squid-Box.SevenZipSharp" version="1.6.1.23" targetFramework="net48" />
|
|
||||||
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net48" />
|
|
||||||
<package id="Topshelf" version="4.3.0" targetFramework="net48" />
|
|
||||||
</packages>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Fuchs_Dataservice.exe uninstall
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
[assembly: SupportedOSPlatform("windows")]
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Azure;
|
||||||
|
using Azure.Storage.Blobs;
|
||||||
|
using Azure.Storage.Blobs.Models;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Azure Blob Storage secondary-archive service. Covers the
|
||||||
|
/// disabled/unconfigured path (no client), the empty-content guard, successful
|
||||||
|
/// uploads (routing to the correct per-category container and blob name), an
|
||||||
|
/// upload that throws (must be swallowed, never break invoice/reminder storage),
|
||||||
|
/// and the emitted telemetry counters.
|
||||||
|
///
|
||||||
|
/// Azure.Storage.Blobs clients (BlobServiceClient/BlobContainerClient/BlobClient)
|
||||||
|
/// expose a protected parameterless constructor and virtual members specifically
|
||||||
|
/// to support mocking with Moq — see Azure SDK unit-testing guidance.
|
||||||
|
/// </summary>
|
||||||
|
public class AzureBlobStorageServiceTests
|
||||||
|
{
|
||||||
|
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||||
|
|
||||||
|
private static AzureBlobStorageSettings CreateSettings() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
InvoiceContainer = "test-invoices",
|
||||||
|
ReminderContainer = "test-reminders"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (Mock<BlobServiceClient> service, Mock<BlobContainerClient> container, Mock<BlobClient> blob)
|
||||||
|
CreateMockedClientChain()
|
||||||
|
{
|
||||||
|
var blobClientMock = new Mock<BlobClient>();
|
||||||
|
var containerClientMock = new Mock<BlobContainerClient>();
|
||||||
|
containerClientMock.Setup(c => c.GetBlobClient(It.IsAny<string>())).Returns(blobClientMock.Object);
|
||||||
|
containerClientMock
|
||||||
|
.Setup(c => c.CreateIfNotExistsAsync(
|
||||||
|
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
|
||||||
|
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((Response<BlobContainerInfo>)null!);
|
||||||
|
|
||||||
|
var serviceClientMock = new Mock<BlobServiceClient>();
|
||||||
|
serviceClientMock.Setup(s => s.GetBlobContainerClient(It.IsAny<string>())).Returns(containerClientMock.Object);
|
||||||
|
|
||||||
|
return (serviceClientMock, containerClientMock, blobClientMock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disabled / unconfigured (feature-flag-off) ─────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadInvoicePdfAsync_NoClient_ReturnsNullWithoutThrowing()
|
||||||
|
{
|
||||||
|
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
|
||||||
|
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3 });
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boundary condition: empty content ──────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadReminderPdfAsync_EmptyContent_ReturnsNullWithoutCallingClient()
|
||||||
|
{
|
||||||
|
var (service, container, blob) = CreateMockedClientChain();
|
||||||
|
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
|
||||||
|
Uri? result = await svc.UploadReminderPdfAsync("REM1", "Zahlungserinnerung_REM1.pdf", Array.Empty<byte>());
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
container.Verify(c => c.CreateIfNotExistsAsync(
|
||||||
|
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
|
||||||
|
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success path: invoice routing + counter ─────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadInvoicePdfAsync_ClientSucceeds_ReturnsBlobUriAndUsesInvoiceContainer()
|
||||||
|
{
|
||||||
|
var expectedUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||||
|
var (service, container, blob) = CreateMockedClientChain();
|
||||||
|
blob.Setup(b => b.Uri).Returns(expectedUri);
|
||||||
|
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||||
|
|
||||||
|
long delta = 0;
|
||||||
|
using var listener = new MeterListener
|
||||||
|
{
|
||||||
|
InstrumentPublished = (inst, l) =>
|
||||||
|
{
|
||||||
|
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads")
|
||||||
|
l.EnableMeasurementEvents(inst);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3, 4 });
|
||||||
|
|
||||||
|
Assert.Equal(expectedUri, result);
|
||||||
|
service.Verify(s => s.GetBlobContainerClient("test-invoices"), Times.Once);
|
||||||
|
container.Verify(c => c.GetBlobClient("INV1/Rechnung_INV1.pdf"), Times.Once);
|
||||||
|
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
Assert.True(delta >= 1, "fuchs.blobstorage.uploads counter should have been incremented on a successful upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success path: reminder routing (different container) ──────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadReminderPdfAsync_ClientSucceeds_UsesReminderContainer()
|
||||||
|
{
|
||||||
|
var (service, container, blob) = CreateMockedClientChain();
|
||||||
|
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-reminders/REM2/Zahlungserinnerung_REM2.pdf"));
|
||||||
|
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||||
|
|
||||||
|
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
Uri? result = await svc.UploadReminderPdfAsync("REM2", "Zahlungserinnerung_REM2.pdf", new byte[] { 5, 6 });
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
service.Verify(s => s.GetBlobContainerClient("test-reminders"), Times.Once);
|
||||||
|
container.Verify(c => c.GetBlobClient("REM2/Zahlungserinnerung_REM2.pdf"), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boundary condition: blank filename falls back to "{id}.pdf" ───────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadInvoicePdfAsync_BlankFileName_FallsBackToIdPdfBlobName()
|
||||||
|
{
|
||||||
|
var (service, container, blob) = CreateMockedClientChain();
|
||||||
|
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-invoices/INV3/INV3.pdf"));
|
||||||
|
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||||
|
|
||||||
|
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
Uri? result = await svc.UploadInvoicePdfAsync("INV3", " ", new byte[] { 7 });
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
container.Verify(c => c.GetBlobClient("INV3/INV3.pdf"), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Failure path: client throws, must not propagate ────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadReminderPdfAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
|
||||||
|
{
|
||||||
|
var (service, container, blob) = CreateMockedClientChain();
|
||||||
|
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
|
||||||
|
|
||||||
|
long delta = 0;
|
||||||
|
using var listener = new MeterListener
|
||||||
|
{
|
||||||
|
InstrumentPublished = (inst, l) =>
|
||||||
|
{
|
||||||
|
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads.failed")
|
||||||
|
l.EnableMeasurementEvents(inst);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||||
|
Uri? result = await svc.UploadReminderPdfAsync("REM3", "Zahlungserinnerung_REM3.pdf", new byte[] { 9, 9 });
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,4 +129,51 @@ public class BankingParseToDatatableTests
|
|||||||
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("AccountIdentification", 50)]
|
||||||
|
[InlineData("NameOfPayer", 140)]
|
||||||
|
[InlineData("SepaRemittanceInformation", 200)]
|
||||||
|
[InlineData("DebitCreditMark", 2)]
|
||||||
|
[InlineData("UnstructuredData", 390)]
|
||||||
|
public void ParseToDatatable_DefaultSchema_EnforcesKnownColumnWidth(string column, int expectedMaxLength)
|
||||||
|
{
|
||||||
|
using var stream = ToStream(MinimalMT940);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
Assert.Equal(expectedMaxLength, table.Columns[column]!.MaxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_SchemaWithoutMaxLength_StillEnforcesKnownWidths()
|
||||||
|
{
|
||||||
|
// Mirrors the real bug: SELECT TOP(0) * FROM @tmp (a table-type variable) comes back
|
||||||
|
// from ADO.NET with MaxLength = -1 for every string column, so a schema built purely
|
||||||
|
// from that fetch cannot truncate on its own. ApplyKnownColumnWidths must patch it up
|
||||||
|
// regardless of what the caller-supplied schema carries.
|
||||||
|
var schema = new DataTable();
|
||||||
|
schema.Columns.Add("AccountIdentification", typeof(string));
|
||||||
|
schema.Columns.Add("Amount", typeof(decimal));
|
||||||
|
schema.Columns.Add("DebitCreditMark", typeof(string));
|
||||||
|
Assert.Equal(-1, schema.Columns["AccountIdentification"]!.MaxLength);
|
||||||
|
|
||||||
|
using var stream = ToStream(MinimalMT940);
|
||||||
|
var table = Svc.ParseToDatatable(stream, schemaDatatable: schema);
|
||||||
|
|
||||||
|
Assert.Equal(50, table.Columns["AccountIdentification"]!.MaxLength);
|
||||||
|
Assert.Equal(2, table.Columns["DebitCreditMark"]!.MaxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_OverlongAccountIdentification_TruncatesInsteadOfThrowing()
|
||||||
|
{
|
||||||
|
string longAccount = "DE" + new string('9', 60); // 62 chars, over the 50-char column width
|
||||||
|
string mt940 = MinimalMT940.Replace("DE12345678901234567890", longAccount);
|
||||||
|
|
||||||
|
using var stream = ToStream(mt940);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
|
||||||
|
Assert.Equal(1, table.Rows.Count);
|
||||||
|
Assert.Equal(50, ((string)table.Rows[0]["AccountIdentification"]).Length);
|
||||||
|
Assert.Equal(longAccount[..50], table.Rows[0]["AccountIdentification"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,4 +377,72 @@ public class BankingDualFormatTests
|
|||||||
Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]);
|
Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]);
|
||||||
Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]);
|
Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schema whose string columns carry the same MaxLength as the real
|
||||||
|
/// <c>fds__tt__bankingtransactions</c> table type. Building it here lets the tests
|
||||||
|
/// catch width-overflow bugs that <see cref="BankingService.BuildDefaultSchema"/> (which
|
||||||
|
/// uses unconstrained strings) cannot. Only the columns exercised below are constrained.
|
||||||
|
/// </summary>
|
||||||
|
private static DataTable DbLikeSchema()
|
||||||
|
{
|
||||||
|
var t = new DataTable();
|
||||||
|
t.Columns.Add("AccountIdentification", typeof(string)).MaxLength = 50;
|
||||||
|
t.Columns.Add("Amount", typeof(decimal));
|
||||||
|
t.Columns.Add("ValueDate", typeof(DateTime));
|
||||||
|
t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here
|
||||||
|
t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 140;
|
||||||
|
t.Columns.Add("PostingText", typeof(string)).MaxLength = 30;
|
||||||
|
t.Columns.Add("TransactionTypeIdCode", typeof(string)).MaxLength = 3;
|
||||||
|
t.Columns.Add("SepaRemittanceInformation", typeof(string)).MaxLength = 200;
|
||||||
|
t.Columns.Add("DebitCreditMark", typeof(string)).MaxLength = 2;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_Camt_WithDbWidthSchema_StillImportsRows()
|
||||||
|
{
|
||||||
|
// Regression: CAMT wrote the ISO currency ("EUR") into FundsCode (VARCHAR(1)).
|
||||||
|
// Against the real DB widths that overflowed and — swallowed per entry — dropped
|
||||||
|
// every transaction, so nothing reached the database.
|
||||||
|
using var s = ToStream(Camt053);
|
||||||
|
var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema());
|
||||||
|
|
||||||
|
Assert.Equal(1, t.Rows.Count);
|
||||||
|
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||||
|
Assert.Equal(500.00m, t.Rows[0]["Amount"]);
|
||||||
|
// Currency is no longer forced into the 1-char FundsCode column.
|
||||||
|
Assert.Equal(DBNull.Value, t.Rows[0]["FundsCode"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_Camt_OverlongFields_AreTruncatedNotDropped()
|
||||||
|
{
|
||||||
|
// Some banks put the full postal address into <Nm>, not just a name — real-world case
|
||||||
|
// that exceeds even the ISO 20022 Max140Text width this column is now sized to.
|
||||||
|
const string longName = "Ein sehr langer Zahlungspflichtiger Name der weit ueber hundertvierzig Zeichen hinausgeht GmbH Co KG, Musterstrasse 123, 12345 Musterstadt, Deutschland";
|
||||||
|
string camt = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt><Stmt>
|
||||||
|
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">500.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
|
||||||
|
<NtryDtls><TxDtls>
|
||||||
|
<RltdPties><Dbtr><Nm>{NAME}</Nm></Dbtr></RltdPties>
|
||||||
|
<RmtInf><Ustrd>Rechnung</Ustrd></RmtInf>
|
||||||
|
</TxDtls></NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt></BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""".Replace("{NAME}", longName);
|
||||||
|
|
||||||
|
using var s = ToStream(camt);
|
||||||
|
var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema());
|
||||||
|
|
||||||
|
Assert.Equal(1, t.Rows.Count);
|
||||||
|
Assert.Equal(140, ((string)t.Rows[0]["NameOfPayer"]).Length); // truncated to column width, row kept
|
||||||
|
Assert.Equal(longName[..140], t.Rows[0]["NameOfPayer"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="DocumentArchiveSyncService"/> — the one-shot startup backfill for the
|
||||||
|
/// Azure Blob Storage secondary archive. Only the disabled/feature-flag-off fast path is
|
||||||
|
/// unit-testable without a real SQL Server connection (the enabled path drives
|
||||||
|
/// fds__getInvoiceFiles_ForBlobArchive / fds__getReminderFiles_ForBlobArchive against
|
||||||
|
/// fds__invoices / fds__reminder); the enabled path is exercised manually/in integration
|
||||||
|
/// environments, consistent with InvoiceService/ReminderService which are likewise DB-bound
|
||||||
|
/// and have no unit tests of their SQL-calling members.
|
||||||
|
/// </summary>
|
||||||
|
public class DocumentArchiveSyncServiceTests
|
||||||
|
{
|
||||||
|
private static Fuchs_intranet CreateIntranet() =>
|
||||||
|
new(new ConfigurationBuilder().Build());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_FeatureDisabled_NeverTouchesBlobStorageAndCompletesImmediately()
|
||||||
|
{
|
||||||
|
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
|
||||||
|
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
|
||||||
|
using var service = new DocumentArchiveSyncService(
|
||||||
|
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
blobStorage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_FeatureDisabled_DoesNotThrowEvenWithUnconfiguredIntranet()
|
||||||
|
{
|
||||||
|
// The disabled fast path must return before any SQL access is attempted, so an
|
||||||
|
// intranet instance with no real connection string configured is still safe to use.
|
||||||
|
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
|
||||||
|
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
|
||||||
|
using var service = new DocumentArchiveSyncService(
|
||||||
|
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
|
||||||
|
|
||||||
|
var exception = await Record.ExceptionAsync(async () =>
|
||||||
|
{
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="DocumentMetadataBuilder"/> — the per-blob metadata projection used by the
|
||||||
|
/// Azure Blob Storage archive (see <see cref="AzureBlobStorageService"/> and
|
||||||
|
/// <see cref="DocumentArchiveSyncService"/>). Covers the "skip if absent from the row, keep empty
|
||||||
|
/// string if present-but-empty" contract, case-insensitive column lookup (SQL rows are frequently
|
||||||
|
/// lower-cased), and value stringification (DateTime round-trip formatting, DBNull/null handling).
|
||||||
|
/// </summary>
|
||||||
|
public class DocumentMetadataBuilderTests
|
||||||
|
{
|
||||||
|
// ── Field presence contract ──────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void Build_FieldPresentWithValue_IncludesStringifiedValue()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["InvoiceId"] = "INV-42" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
|
||||||
|
|
||||||
|
Assert.Equal("INV-42", metadata["InvoiceId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_FieldAbsentFromRow_SkipsFieldEntirely()
|
||||||
|
{
|
||||||
|
// Reminders have no file_guid column — must be skipped, not stored as empty.
|
||||||
|
var row = new Dictionary<string, object?> { ["Id"] = "REM1" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "file_guid" });
|
||||||
|
|
||||||
|
Assert.True(metadata.ContainsKey("Id"));
|
||||||
|
Assert.False(metadata.ContainsKey("file_guid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void Build_FieldPresentButNullOrEmpty_StoresEmptyStringRatherThanSkipping(object? value)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = value };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
|
||||||
|
|
||||||
|
Assert.True(metadata.ContainsKey("InvoiceTitle"));
|
||||||
|
Assert.Equal("", metadata["InvoiceTitle"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_FieldPresentButDBNull_StoresEmptyString()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = DBNull.Value };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
|
||||||
|
|
||||||
|
Assert.True(metadata.ContainsKey("InvoiceTitle"));
|
||||||
|
Assert.Equal("", metadata["InvoiceTitle"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Case-insensitive lookup ──────────────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("invoiceid")]
|
||||||
|
[InlineData("INVOICEID")]
|
||||||
|
[InlineData("InVoIcEId")]
|
||||||
|
public void Build_CaseInsensitiveRowKey_MatchesConfiguredFieldName(string rowKey)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { [rowKey] = "INV-7" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
|
||||||
|
|
||||||
|
Assert.Equal("INV-7", metadata["InvoiceId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Value stringification ────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void Build_DateTimeValue_FormatsAsRoundTripString()
|
||||||
|
{
|
||||||
|
var dt = new DateTime(2026, 3, 14, 9, 30, 0, DateTimeKind.Utc);
|
||||||
|
var row = new Dictionary<string, object?> { ["DateCreated"] = dt };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DateCreated" });
|
||||||
|
|
||||||
|
Assert.Equal(dt.ToString("O"), metadata["DateCreated"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(42, "42")]
|
||||||
|
[InlineData(true, "True")]
|
||||||
|
public void Build_NonStringValue_UsesToString(object value, string expected)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["Version"] = value };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Version" });
|
||||||
|
|
||||||
|
Assert.Equal(expected, metadata["Version"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_GuidValue_UsesDefaultGuidFormat()
|
||||||
|
{
|
||||||
|
var guid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
var row = new Dictionary<string, object?> { ["file_guid"] = guid };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "file_guid" });
|
||||||
|
|
||||||
|
Assert.Equal(guid.ToString(), metadata["file_guid"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Zahlungserinnerung für Müller", "Zahlungserinnerung f%C3%BCr M%C3%BCller")]
|
||||||
|
[InlineData("Rechnung\r\nKunde", "Rechnung%0D%0AKunde")]
|
||||||
|
public void Build_NonAsciiOrControlCharacters_PercentEncodesUtf8ForHttpHeaders(
|
||||||
|
string value, string expected)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["DocumentName"] = value };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DocumentName" });
|
||||||
|
|
||||||
|
Assert.Equal(expected, metadata["DocumentName"]);
|
||||||
|
Assert.All(metadata["DocumentName"], c => Assert.InRange(c, '\u0020', '\u007e'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field-list edge cases ────────────────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Build_WhitespaceFieldName_IsSkipped(string field)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { field, "Id" });
|
||||||
|
|
||||||
|
Assert.Single(metadata);
|
||||||
|
Assert.Equal("X1", metadata["Id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_EmptyFieldsList_ReturnsEmptyDictionary()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, Array.Empty<string>());
|
||||||
|
|
||||||
|
Assert.Empty(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_EmptyRow_SkipsAllConfiguredFields()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Version", "InvoiceId" });
|
||||||
|
|
||||||
|
Assert.Empty(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DuplicateFieldNamesInFieldList_ProducesSingleEntry()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Id" });
|
||||||
|
|
||||||
|
Assert.Single(metadata);
|
||||||
|
Assert.Equal("X1", metadata["Id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Realistic per-document-type row shapes ───────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void Build_InvoiceRowShape_ProjectsAllConfiguredInvoiceFieldsAndSkipsReminderOnlyColumn()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Id"] = "INV1",
|
||||||
|
["Version"] = 2,
|
||||||
|
["InvoiceId"] = "R-2026-001",
|
||||||
|
["InvoiceTitle"] = "Rechnung",
|
||||||
|
["DocumentName"] = "Rechnung_INV1.pdf",
|
||||||
|
["file_guid"] = Guid.Parse("11111111-1111-1111-1111-111111111111")
|
||||||
|
};
|
||||||
|
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, fields);
|
||||||
|
|
||||||
|
Assert.Equal("INV1", metadata["Id"]);
|
||||||
|
Assert.Equal("2", metadata["Version"]);
|
||||||
|
Assert.Equal("R-2026-001", metadata["InvoiceId"]);
|
||||||
|
Assert.Equal("Rechnung", metadata["InvoiceTitle"]);
|
||||||
|
Assert.Equal("Rechnung_INV1.pdf", metadata["DocumentName"]);
|
||||||
|
Assert.Equal("11111111-1111-1111-1111-111111111111", metadata["file_guid"]);
|
||||||
|
Assert.False(metadata.ContainsKey("InvId"), "Invoices have no InvId column — must be skipped, not empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ReminderRowShape_SkipsInvoiceOnlyColumns()
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Id"] = "REM1",
|
||||||
|
["Version"] = 0,
|
||||||
|
["InvId"] = "INV1",
|
||||||
|
["DocumentName"] = "Zahlungserinnerung_REM1.pdf"
|
||||||
|
};
|
||||||
|
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
|
||||||
|
|
||||||
|
var metadata = DocumentMetadataBuilder.Build(row, fields);
|
||||||
|
|
||||||
|
Assert.Equal("REM1", metadata["Id"]);
|
||||||
|
Assert.Equal("0", metadata["Version"]);
|
||||||
|
Assert.Equal("INV1", metadata["InvId"]);
|
||||||
|
Assert.Equal("Zahlungserinnerung_REM1.pdf", metadata["DocumentName"]);
|
||||||
|
Assert.False(metadata.ContainsKey("InvoiceId"));
|
||||||
|
Assert.False(metadata.ContainsKey("InvoiceTitle"));
|
||||||
|
Assert.False(metadata.ContainsKey("file_guid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using eRechnungLib.Model.CodeLists;
|
||||||
|
using eRechnungLib.Profiles;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the FdsInvoiceData → EN 16931 model mapping and the ZUGFeRD (EN 16931) hybrid
|
||||||
|
/// production: structured buyer, lines/totals, effortless B2C, and §13b reverse charge. See
|
||||||
|
/// ADR 0012.
|
||||||
|
/// </summary>
|
||||||
|
public class ERechnungMapperTests
|
||||||
|
{
|
||||||
|
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
|
||||||
|
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
|
||||||
|
{
|
||||||
|
var items = withItem
|
||||||
|
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
|
||||||
|
: "[]";
|
||||||
|
var jobj = JObject.Parse("{'req':[{'Id':'1','text':'Auftrag','items':" + items + "}]}");
|
||||||
|
var inv = new FdsInvoiceData(jobj)
|
||||||
|
{
|
||||||
|
InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Id"] = "42",
|
||||||
|
["InvoiceId"] = "R2026-0007",
|
||||||
|
["InvoiceTitle"] = "Rechnung",
|
||||||
|
["DateCreated"] = "2026-07-17 10:00:00",
|
||||||
|
["InvoiceOptions"] = invoiceOptions,
|
||||||
|
["InvoiceBalance_net"] = "100",
|
||||||
|
["InvoiceVAT_1"] = vat,
|
||||||
|
["ProvisionPeriod"] = provisionPeriod,
|
||||||
|
["SendToAddressJson"] = sendToAddressJson,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string B2BAddress =
|
||||||
|
"{'name':'Muster GmbH','street':'Hauptstr. 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildEInvoice_MapsSellerBuyerAndLines_WithConsistentTotals()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||||
|
|
||||||
|
Assert.Equal("Sebastian Fuchs GmbH & Co. KG", model.Seller.Name);
|
||||||
|
Assert.Equal("DE", model.Seller.Address.Country.Value);
|
||||||
|
Assert.Equal("Muster GmbH", model.Buyer.Name);
|
||||||
|
Assert.Equal("40223", model.Buyer.Address.PostalCode);
|
||||||
|
Assert.Equal("DE123456789", model.Buyer.VatId);
|
||||||
|
Assert.Equal("R2026-0007", model.InvoiceNumber);
|
||||||
|
|
||||||
|
var line = Assert.Single(model.Lines);
|
||||||
|
Assert.Equal(100m, line.NetAmount);
|
||||||
|
Assert.Equal(VatCategoryCode.StandardRate, line.VatCategory);
|
||||||
|
Assert.NotNull(model.Totals);
|
||||||
|
Assert.Equal(100m, model.Totals!.TaxExclusiveAmount);
|
||||||
|
Assert.Equal(19m, model.Totals.TaxTotalAmount);
|
||||||
|
Assert.Equal(119m, model.Totals.TaxInclusiveAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToZugferd_EN16931_ProducesHybridPdfWithEmbeddedCii()
|
||||||
|
{
|
||||||
|
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress));
|
||||||
|
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
|
||||||
|
// The CII XML is embedded and carries the invoice number.
|
||||||
|
string content = Encoding.Latin1.GetString(result.Value!);
|
||||||
|
Assert.Contains("CrossIndustryInvoice", content);
|
||||||
|
// No PDFA-ICC warning: the bundled sRGB profile is present, so the output intent is set.
|
||||||
|
Assert.DoesNotContain(result.Validation.Warnings, m => m.RuleId == "PDFA-ICC");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrivatePerson_NoVatId_MapsWithoutBuyerTaxRegistration_AndProducesHybrid()
|
||||||
|
{
|
||||||
|
var b2c = "{'name':'Max Mustermann','street':'Weg 2','postalCode':'50667','city':'Köln','countryCode':'DE'}";
|
||||||
|
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(b2c));
|
||||||
|
|
||||||
|
Assert.Null(einvoice.Model.Buyer.VatId);
|
||||||
|
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReverseCharge_13b_SetsCategoryAeAndExemptionReason()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, vat: "0", invoiceOptions: "§13b")).Model;
|
||||||
|
|
||||||
|
var line = Assert.Single(model.Lines);
|
||||||
|
Assert.Equal(VatCategoryCode.ReverseCharge, line.VatCategory);
|
||||||
|
Assert.Equal(0m, line.VatRate);
|
||||||
|
Assert.True(model.VatExemptionReasons.ContainsKey(VatCategoryCode.ReverseCharge));
|
||||||
|
Assert.Equal(0m, model.Totals!.TaxTotalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LumpSumInvoice_NoItems_SynthesisesSingleLineFromTotal()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, withItem: false)).Model;
|
||||||
|
var line = Assert.Single(model.Lines);
|
||||||
|
Assert.Equal(100m, line.NetAmount);
|
||||||
|
Assert.Equal(119m, model.Totals!.TaxInclusiveAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DE", "DE")]
|
||||||
|
[InlineData("Deutschland", "DE")]
|
||||||
|
[InlineData("Österreich", "AT")]
|
||||||
|
[InlineData("", "DE")]
|
||||||
|
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
|
||||||
|
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServicePeriod_SingleDate_MapsToDeliveryDate()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "18.06.2026")).Model;
|
||||||
|
Assert.NotNull(model.Delivery);
|
||||||
|
Assert.Equal(new System.DateOnly(2026, 6, 18), model.Delivery!.DeliveryDate);
|
||||||
|
Assert.Null(model.InvoicingPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServicePeriod_DateRange_MapsToInvoicingPeriod()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "01.06.2026 - 30.06.2026")).Model;
|
||||||
|
Assert.NotNull(model.InvoicingPeriod);
|
||||||
|
Assert.Equal(new System.DateOnly(2026, 6, 1), model.InvoicingPeriod!.StartDate);
|
||||||
|
Assert.Equal(new System.DateOnly(2026, 6, 30), model.InvoicingPeriod.EndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Seller_ComesFromSettings_WhenProvided()
|
||||||
|
{
|
||||||
|
var seller = new ERechnungSellerSettings { Name = "Test Handwerk GmbH", VatId = "DE999999999", Iban = "DE00" };
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress), seller).Model;
|
||||||
|
Assert.Equal("Test Handwerk GmbH", model.Seller.Name);
|
||||||
|
Assert.Equal("DE999999999", model.Seller.VatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void B2G_WithLeitwegId_SetsBuyerReference_AndSellerContact()
|
||||||
|
{
|
||||||
|
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).Model;
|
||||||
|
|
||||||
|
Assert.Equal("05111-12345-67", model.BuyerReference); // BT-10 (Leitweg-ID)
|
||||||
|
Assert.NotNull(model.Seller.ElectronicAddress); // BT-34 (XRechnung)
|
||||||
|
Assert.NotNull(model.Seller.Contact); // BG-6 (BR-DE-5/6/7)
|
||||||
|
Assert.Equal("info@sanitaerfuchs.de", model.Seller.Contact!.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void B2B_NoLeitwegId_LeavesBuyerReferenceUnset()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||||
|
Assert.True(string.IsNullOrEmpty(model.BuyerReference));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Seller_HasVatIdAndPaymentTerms_ForBrCo25AndBrCo26()
|
||||||
|
{
|
||||||
|
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||||
|
// BR-CO-26: seller VAT id (BT-31) present, in addition to the Steuernummer (BT-32).
|
||||||
|
Assert.Equal("DE286366012", model.Seller.VatId);
|
||||||
|
// BR-CO-25: payment terms (BT-20) / due date (BT-9) present for a positive amount due.
|
||||||
|
Assert.NotNull(model.PaymentTerms);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(model.PaymentTerms!.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void B2G_ToZugferdXRechnung_ProducesHybridWithXRechnungCustomization()
|
||||||
|
{
|
||||||
|
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
|
||||||
|
var result = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).ToZugferd(ZugferdProfile.XRechnung);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
string content = System.Text.Encoding.Latin1.GetString(result.Value!);
|
||||||
|
Assert.Contains("xrechnung_3.0", content); // XRechnung 3.0 CIUS customization id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the external eRechnung validator client against a stubbed HTTP endpoint: response
|
||||||
|
/// parsing, the not-configured short-circuit, and unreachable/error handling (never throws).
|
||||||
|
/// </summary>
|
||||||
|
public class ERechnungValidatorTests
|
||||||
|
{
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpStatusCode _status;
|
||||||
|
private readonly string _body;
|
||||||
|
public HttpRequestMessage? Last;
|
||||||
|
public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Last = request;
|
||||||
|
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpMessageHandler _handler;
|
||||||
|
public StubFactory(HttpMessageHandler handler) => _handler = handler;
|
||||||
|
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessWebERechnungValidator Make(HttpMessageHandler handler, bool enabled = true,
|
||||||
|
string url = "https://validator.test/api/eInvoice")
|
||||||
|
{
|
||||||
|
var settings = Options.Create(new ERechnungSettings
|
||||||
|
{
|
||||||
|
Validation = new ERechnungValidationSettings { Enabled = enabled, ServiceUrl = url }
|
||||||
|
});
|
||||||
|
return new ProcessWebERechnungValidator(new StubFactory(handler), settings,
|
||||||
|
NullLogger<ProcessWebERechnungValidator>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidatePdf_BothPass_ReturnsIsValid()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK,
|
||||||
|
"{\"isValid\":true,\"summary\":\"ok\",\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":true}}");
|
||||||
|
var result = await Make(handler).ValidatePdfAsync(new byte[] { 1, 2, 3 });
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.True(result.XmlValid);
|
||||||
|
Assert.True(result.PdfACompliant);
|
||||||
|
Assert.EndsWith("/validatepdf", handler.Last!.RequestUri!.ToString());
|
||||||
|
Assert.Equal("application/pdf", handler.Last.Content!.Headers.ContentType!.MediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{\"xml\":{\"isValid\":false,\"errorCount\":3},\"pdfa\":{\"isCompliant\":true}}", false, true)]
|
||||||
|
[InlineData("{\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":false}}", true, false)]
|
||||||
|
public async Task ValidatePdf_PartialFailure_IsNotValid(string body, bool xml, bool pdfa)
|
||||||
|
{
|
||||||
|
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Equal(xml, result.XmlValid);
|
||||||
|
Assert.Equal(pdfa, result.PdfACompliant);
|
||||||
|
Assert.True(result.HasHardError); // real PDF/A or XML errors are hard failures
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidatePdf_PdfAOk_ButXmlScenarioNotMatched_IsNotHardError()
|
||||||
|
{
|
||||||
|
// EN 16931 ZUGFeRD checked by an XRechnung-only scenario set: PDF/A compliant, XML rejected
|
||||||
|
// with zero errors → not strictly valid, but not a hard error (must not withhold the invoice).
|
||||||
|
var body = "{\"xml\":{\"isValid\":false,\"scenarioMatched\":false,\"errorCount\":0},\"pdfa\":{\"isCompliant\":true}}";
|
||||||
|
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
|
||||||
|
|
||||||
|
Assert.True(result.PdfACompliant);
|
||||||
|
Assert.False(result.ScenarioMatched);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.False(result.HasHardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidatePdf_Disabled_ReturnsNotConfigured()
|
||||||
|
{
|
||||||
|
var result = await Make(new StubHandler(HttpStatusCode.OK, "{}"), enabled: false).ValidatePdfAsync(new byte[] { 1 });
|
||||||
|
Assert.False(result.Configured);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidatePdf_ServerError_IsReachedFalse_AndDoesNotThrow()
|
||||||
|
{
|
||||||
|
var result = await Make(new StubHandler(HttpStatusCode.BadGateway, "validator down")).ValidatePdfAsync(new byte[] { 1 });
|
||||||
|
Assert.False(result.Reached);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidatePdf_Enabled_RequiresNonEmptyUrl()
|
||||||
|
{
|
||||||
|
var v = Make(new StubHandler(HttpStatusCode.OK, "{}"), url: "");
|
||||||
|
Assert.False(v.Enabled);
|
||||||
|
var result = await v.ValidatePdfAsync(new byte[] { 1 });
|
||||||
|
Assert.False(result.Configured);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="EventService"/>, the single point every server-side
|
||||||
|
/// operation goes through to notify the user. Covers the two failure methods the
|
||||||
|
/// exception safety nets rely on (<c>UserIssueAsync</c> from
|
||||||
|
/// <c>IntranetController.Do</c>'s catch-all, and <c>InvoiceIssueAsync</c> from
|
||||||
|
/// <c>HandleInvoiceGet</c>) rendering as <c>"error"</c> notifications, plus a
|
||||||
|
/// contrasting success path rendering as <c>"info"</c> — see ADR 0003.
|
||||||
|
/// </summary>
|
||||||
|
public class EventServiceTests
|
||||||
|
{
|
||||||
|
// ── Test doubles: capture the GuiNotification pushed to Clients.All ─────────
|
||||||
|
private sealed class CapturingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public string? Method { get; private set; }
|
||||||
|
public object?[]? Args { get; private set; }
|
||||||
|
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Method = method;
|
||||||
|
Args = args;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHubClients : IHubClients
|
||||||
|
{
|
||||||
|
private readonly IClientProxy _all;
|
||||||
|
public StubHubClients(IClientProxy all) => _all = all;
|
||||||
|
public IClientProxy All => _all;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy Client(string connectionId) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy Group(string groupName) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy User(string userId) => throw new System.NotImplementedException();
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new System.NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHubContext : IHubContext<NotificationHub>
|
||||||
|
{
|
||||||
|
public StubHubContext(IHubClients clients) => Clients = clients;
|
||||||
|
public IHubClients Clients { get; }
|
||||||
|
public IGroupManager Groups => throw new System.NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (EventService svc, CapturingClientProxy proxy) CreateService()
|
||||||
|
{
|
||||||
|
var proxy = new CapturingClientProxy();
|
||||||
|
var hub = new StubHubContext(new StubHubClients(proxy));
|
||||||
|
return (new EventService(hub, NullLogger<EventService>.Instance), proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiNotification Captured(CapturingClientProxy proxy)
|
||||||
|
{
|
||||||
|
Assert.Equal("notification", proxy.Method);
|
||||||
|
Assert.NotNull(proxy.Args);
|
||||||
|
var arg = Assert.Single(proxy.Args!);
|
||||||
|
return Assert.IsType<GuiNotification>(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Failure paths the exception safety nets use ─────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task UserIssueAsync_PublishesErrorNotificationWithMessage()
|
||||||
|
{
|
||||||
|
var (svc, proxy) = CreateService();
|
||||||
|
|
||||||
|
await svc.UserIssueAsync(
|
||||||
|
"Aktion fehlgeschlagen",
|
||||||
|
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"user-42",
|
||||||
|
new Dictionary<string, object?> { ["fn"] = "inv" });
|
||||||
|
|
||||||
|
var n = Captured(proxy);
|
||||||
|
Assert.Equal("error", n.Severity);
|
||||||
|
Assert.Equal("Aktion fehlgeschlagen", n.Title);
|
||||||
|
Assert.Equal(DomainEventType.UserIssue.ToString(), n.Type);
|
||||||
|
Assert.Contains("nicht abgeschlossen werden", n.Message);
|
||||||
|
Assert.Equal("inv", n.Context["fn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvoiceIssueAsync_PublishesErrorNotificationCarryingInvoiceId()
|
||||||
|
{
|
||||||
|
var (svc, proxy) = CreateService();
|
||||||
|
|
||||||
|
await svc.InvoiceIssueAsync(
|
||||||
|
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
|
||||||
|
"user-42",
|
||||||
|
"INV-1001");
|
||||||
|
|
||||||
|
var n = Captured(proxy);
|
||||||
|
Assert.Equal("error", n.Severity);
|
||||||
|
Assert.Equal(DomainEventType.InvoiceCreationFailed.ToString(), n.Type);
|
||||||
|
Assert.Equal("Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.", n.Message);
|
||||||
|
Assert.Equal("INV-1001", n.Context["id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrasting success path renders as info, not error ─────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task InvoiceMarkedSentAsync_PublishesInfoNotification()
|
||||||
|
{
|
||||||
|
var (svc, proxy) = CreateService();
|
||||||
|
|
||||||
|
await svc.InvoiceMarkedSentAsync("INV-1001", "R2026-0001", "user-42");
|
||||||
|
|
||||||
|
var n = Captured(proxy);
|
||||||
|
Assert.Equal("info", n.Severity);
|
||||||
|
Assert.Contains("R2026-0001", n.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using fds;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tests for the Fuchs_DataService library (MFR ERP sync).
|
||||||
|
//
|
||||||
|
// Since Topshelf/own-config were removed and the library is hosted in-process by
|
||||||
|
// Fuchs, these tests exercise the parts that are pure/deterministic and do not
|
||||||
|
// require a live SQL Server or MFR endpoint: DATEV header/CSV/XML formatting,
|
||||||
|
// UpdateNeed parsing, the FdsShared utility helpers, config resolution, and the
|
||||||
|
// (network-free) FdsMfrClient construction.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>DATEV header line formatting — pure string assembly, no I/O.</summary>
|
||||||
|
public class DatevHeaderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToHeaderString_Defaults_ProducesThirtySemicolonFields()
|
||||||
|
{
|
||||||
|
var header = new DatevHeader();
|
||||||
|
var parts = header.ToHeaderString().Split(';');
|
||||||
|
|
||||||
|
Assert.Equal(30, parts.Length);
|
||||||
|
Assert.Equal("\"EXTF\"", parts[0]); // Kennzeichen quoted
|
||||||
|
Assert.Equal("700", parts[1]); // Versionsnummer
|
||||||
|
Assert.Equal("1", parts[20]); // Festschreibung true → "1"
|
||||||
|
Assert.Equal("\"EUR\"", parts[21]); // WKZ quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToHeaderString_MapsNumericAndDateFields()
|
||||||
|
{
|
||||||
|
var header = new DatevHeader
|
||||||
|
{
|
||||||
|
Beraternummer = 12345,
|
||||||
|
Mandantennummer = 678,
|
||||||
|
Sachkontenlänge = 4,
|
||||||
|
WJBeginn = new DateTime(2026, 1, 1),
|
||||||
|
DatumVon = new DateTime(2026, 7, 1),
|
||||||
|
DatumBis = new DateTime(2026, 7, 31),
|
||||||
|
Bezeichnung = "fds_m260731",
|
||||||
|
};
|
||||||
|
var parts = header.ToHeaderString().Split(';');
|
||||||
|
|
||||||
|
Assert.Equal("12345", parts[10]);
|
||||||
|
Assert.Equal("678", parts[11]);
|
||||||
|
Assert.Equal("20260101", parts[12]);
|
||||||
|
Assert.Equal("4", parts[13]);
|
||||||
|
Assert.Equal("20260701", parts[14]);
|
||||||
|
Assert.Equal("20260731", parts[15]);
|
||||||
|
Assert.Equal("\"fds_m260731\"", parts[16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, "1")]
|
||||||
|
[InlineData(false, "0")]
|
||||||
|
public void ToHeaderString_Festschreibung_MapsToFlag(bool festschreibung, string expected)
|
||||||
|
{
|
||||||
|
var header = new DatevHeader { Festschreibung = festschreibung };
|
||||||
|
Assert.Equal(expected, header.ToHeaderString().Split(';')[20]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToHeaderString_DtvfKennzeichen_IsHonoured()
|
||||||
|
{
|
||||||
|
var header = new DatevHeader { Kennzeichen = DatevKennzeichen.DTVF };
|
||||||
|
Assert.Equal("\"DTVF\"", header.ToHeaderString().Split(';')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(DatevFormatkategorie.Debitoren__Kreditoren, "Debitoren/Kreditoren")]
|
||||||
|
[InlineData(DatevFormatkategorie.Buchungsstapel, "Buchungsstapel")]
|
||||||
|
[InlineData(DatevFormatkategorie.Wiederkehrende_Buchungen, "Wiederkehrende Buchungen")]
|
||||||
|
public void Formatname_TranslatesEnumUnderscores(DatevFormatkategorie kat, string expected)
|
||||||
|
{
|
||||||
|
var header = new DatevHeader { Formatkategorie = kat };
|
||||||
|
Assert.Equal(expected, header.Formatname);
|
||||||
|
// Field 3 embeds the same, quoted.
|
||||||
|
Assert.Equal($"\"{expected}\"", header.ToHeaderString().Split(';')[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DATEV CSV + document XML generation on FdsMfr (no DB / no MFR).</summary>
|
||||||
|
public class FdsMfrDatevTests
|
||||||
|
{
|
||||||
|
private static FdsMfr NewMfr() =>
|
||||||
|
new(NullLogger<FdsMfr>.Instance, NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DATEV_PrependsHeaderLine_AndSemicolonCsvWithColumnHeaders()
|
||||||
|
{
|
||||||
|
var header = new DatevHeader { Bezeichnung = "test" };
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("Konto", typeof(string));
|
||||||
|
tbl.Columns.Add("Betrag", typeof(decimal));
|
||||||
|
tbl.Rows.Add("1200", 119.50m);
|
||||||
|
|
||||||
|
var result = NewMfr().DATEV(header, tbl);
|
||||||
|
var lines = result.Split("\r\n");
|
||||||
|
|
||||||
|
Assert.Equal(header.ToHeaderString(), lines[0]); // header line first
|
||||||
|
Assert.Equal("Konto;Betrag", lines[1]); // column headers, semicolon-delimited, unquoted
|
||||||
|
Assert.Contains("1200", lines[2]);
|
||||||
|
// de-DE culture → decimal comma
|
||||||
|
Assert.Contains("119,5", lines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: only the root <archive> is in the DATEV namespace; the generator emits child
|
||||||
|
// elements in the empty namespace (existing production behavior). Child lookups therefore
|
||||||
|
// use namespace-agnostic local-name() XPath rather than the datev namespace prefix.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateDatevDocumentXml_BuildsArchiveWithNamespaceAndOneDocumentPerInput()
|
||||||
|
{
|
||||||
|
var docs = new List<DatevDocument>
|
||||||
|
{
|
||||||
|
new("guid-1", "invoice1.pdf", "RgNr: 100", "ProcessWeb_Belege", "2026/07"),
|
||||||
|
new("guid-2", "invoice2.pdf", "", "ProcessWeb_Belege", "2026/07"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var xml = NewMfr().CreateDatevDocumentXml(docs);
|
||||||
|
var doc = new XmlDocument();
|
||||||
|
doc.LoadXml(xml);
|
||||||
|
|
||||||
|
Assert.Equal("archive", doc.DocumentElement!.LocalName);
|
||||||
|
Assert.Equal("http://xml.datev.de/bedi/tps/document/v04.0", doc.DocumentElement.NamespaceURI);
|
||||||
|
Assert.Equal("ProcessWeb", doc.DocumentElement.GetAttribute("generatingSystem"));
|
||||||
|
Assert.Equal("4.0", doc.DocumentElement.GetAttribute("version"));
|
||||||
|
|
||||||
|
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='date']"));
|
||||||
|
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='description']"));
|
||||||
|
|
||||||
|
var documentNodes = doc.SelectNodes("//*[local-name()='content']/*[local-name()='document']");
|
||||||
|
Assert.Equal(2, documentNodes!.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateDatevDocumentXml_OmitsKeywordsWhenEmpty_IncludesWhenPresent()
|
||||||
|
{
|
||||||
|
var docs = new List<DatevDocument>
|
||||||
|
{
|
||||||
|
new("g-withkw", "a.pdf", "keyword-here", "ProcessWeb_Belege", "2026/07"),
|
||||||
|
new("g-nokw", "b.pdf", "", "ProcessWeb_Belege", "2026/07"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var doc = new XmlDocument();
|
||||||
|
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
|
||||||
|
|
||||||
|
var withKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-withkw']")!;
|
||||||
|
var noKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-nokw']")!;
|
||||||
|
|
||||||
|
var withKwNode = withKw.SelectSingleNode("*[local-name()='keywords']");
|
||||||
|
Assert.NotNull(withKwNode);
|
||||||
|
Assert.Equal("keyword-here", withKwNode.InnerText);
|
||||||
|
Assert.Null(noKw.SelectSingleNode("*[local-name()='keywords']"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateDatevDocumentXml_EmitsThreeRepositoryLevels()
|
||||||
|
{
|
||||||
|
var docs = new List<DatevDocument>
|
||||||
|
{
|
||||||
|
new("g1", "a.pdf", "", "ProcessWeb_Belege", "2026/07_w03"),
|
||||||
|
};
|
||||||
|
var doc = new XmlDocument();
|
||||||
|
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
|
||||||
|
|
||||||
|
var levels = doc.SelectNodes(
|
||||||
|
"//*[local-name()='document']/*[local-name()='repository']/*[local-name()='level']")!;
|
||||||
|
Assert.Equal(3, levels.Count);
|
||||||
|
Assert.Equal("ProcessWeb", ((XmlElement)levels[0]!).GetAttribute("name"));
|
||||||
|
Assert.Equal("ProcessWeb_Belege", ((XmlElement)levels[1]!).GetAttribute("name"));
|
||||||
|
Assert.Equal("2026/07_w03", ((XmlElement)levels[2]!).GetAttribute("name"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FdsMfr.UpdateNeed parsing + enum contract.</summary>
|
||||||
|
public class FdsMfrUpdateNeedTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Reset", FdsMfr.UpdateNeed.Reset)]
|
||||||
|
[InlineData("Full", FdsMfr.UpdateNeed.Full)]
|
||||||
|
[InlineData("Short", FdsMfr.UpdateNeed.Short)]
|
||||||
|
[InlineData("None", FdsMfr.UpdateNeed.None)]
|
||||||
|
public void UpdateNeedValue_ValidName_Parses(string name, FdsMfr.UpdateNeed expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, FdsMfr.UpdateNeedValue(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateNeedValue_UnknownName_Throws()
|
||||||
|
{
|
||||||
|
Assert.ThrowsAny<Exception>(() => FdsMfr.UpdateNeedValue("Bogus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateNeed_NumericValues_AreStable()
|
||||||
|
{
|
||||||
|
// These map to SQL updateneed codes — must not drift.
|
||||||
|
Assert.Equal(5, (int)FdsMfr.UpdateNeed.Reset);
|
||||||
|
Assert.Equal(2, (int)FdsMfr.UpdateNeed.Full);
|
||||||
|
Assert.Equal(1, (int)FdsMfr.UpdateNeed.Short);
|
||||||
|
Assert.Equal(0, (int)FdsMfr.UpdateNeed.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FdsShared utility helpers — pure formatting + local file I/O.</summary>
|
||||||
|
public class FdsSharedTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)0)]
|
||||||
|
[InlineData((byte)1)]
|
||||||
|
[InlineData((byte)16)]
|
||||||
|
[InlineData((byte)32)]
|
||||||
|
public void RandomString_ReturnsRequestedLength_AlphanumericOnly(byte length)
|
||||||
|
{
|
||||||
|
var s = FdsShared.RandomString(length);
|
||||||
|
Assert.Equal(length, s.Length);
|
||||||
|
Assert.All(s, c => Assert.True(char.IsLetterOrDigit(c), $"unexpected char '{c}'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToCsv_DataRow_QuotesAndEscapesStringsWhenRequested()
|
||||||
|
{
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("Name", typeof(string));
|
||||||
|
var row = tbl.NewRow();
|
||||||
|
row["Name"] = "Say \"hi\"";
|
||||||
|
tbl.Rows.Add(row);
|
||||||
|
|
||||||
|
Assert.Equal("\"Say \"\"hi\"\"\"", row.ToCsv(quoteStrings: true, CultureInfo.InvariantCulture));
|
||||||
|
Assert.Equal("Say \"hi\"", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToCsv_DataRow_NullAndDbNull_RenderAsEmpty()
|
||||||
|
{
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("A", typeof(string));
|
||||||
|
tbl.Columns.Add("B", typeof(string));
|
||||||
|
var row = tbl.NewRow();
|
||||||
|
row["A"] = DBNull.Value;
|
||||||
|
row["B"] = "x";
|
||||||
|
|
||||||
|
Assert.Equal(";x", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture, delimiter: ";"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToCsv_DataRow_FormatsDecimalWithSuppliedCulture()
|
||||||
|
{
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("V", typeof(decimal));
|
||||||
|
var row = tbl.NewRow();
|
||||||
|
row["V"] = 1234.5m;
|
||||||
|
tbl.Rows.Add(row);
|
||||||
|
|
||||||
|
Assert.Equal("1234.5", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
|
||||||
|
Assert.Equal("1234,5", row.ToCsv(quoteStrings: false, new CultureInfo("de-DE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToCsv_DataTable_IncludesHeaderRow_WhenRequested()
|
||||||
|
{
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("Konto", typeof(string));
|
||||||
|
tbl.Columns.Add("Betrag", typeof(decimal));
|
||||||
|
tbl.Rows.Add("1200", 5m);
|
||||||
|
tbl.Rows.Add("1400", 6m);
|
||||||
|
|
||||||
|
var csv = tbl.ToCsv(includeHeaders: true, quoteStrings: false, fieldDelimiter: ";");
|
||||||
|
var lines = csv.Split("\r\n");
|
||||||
|
|
||||||
|
Assert.Equal("Konto;Betrag", lines[0]);
|
||||||
|
Assert.Equal("1200;5", lines[1]);
|
||||||
|
Assert.Equal("1400;6", lines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToCsv_DataTable_CanOmitHeaders()
|
||||||
|
{
|
||||||
|
var tbl = new DataTable();
|
||||||
|
tbl.Columns.Add("X", typeof(string));
|
||||||
|
tbl.Rows.Add("a");
|
||||||
|
|
||||||
|
var csv = tbl.ToCsv(includeHeaders: false, quoteStrings: false);
|
||||||
|
Assert.Equal("a", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("archive.zip", "archive")]
|
||||||
|
[InlineData("archive.tar.gz", "archive.tar")]
|
||||||
|
[InlineData("noext", "noext")]
|
||||||
|
public void NameBase_StripsFinalExtension(string fileName, string expected)
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(Path.Combine(Path.GetTempPath(), fileName));
|
||||||
|
Assert.Equal(expected, fi.NameBase());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Hello, DATEV!")]
|
||||||
|
[InlineData("Ümläute & Straße")]
|
||||||
|
public void ToByteArray_Utf8_RoundTrips(string input)
|
||||||
|
{
|
||||||
|
// Default encoding is Encoding.UTF8, so the StreamWriter emits a leading BOM.
|
||||||
|
var bytes = input.ToByteArray();
|
||||||
|
var decoded = Encoding.UTF8.GetString(bytes).TrimStart('');
|
||||||
|
Assert.Equal(input, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToByteArray_Iso8859_1_RoundTrips()
|
||||||
|
{
|
||||||
|
const string input = "Grüße";
|
||||||
|
var latin1 = Encoding.GetEncoding("ISO-8859-1");
|
||||||
|
var bytes = input.ToByteArray(latin1);
|
||||||
|
Assert.Equal(input, latin1.GetString(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadWriteStream_CopiesFullContent()
|
||||||
|
{
|
||||||
|
var payload = Encoding.UTF8.GetBytes("stream-copy-payload-0123456789");
|
||||||
|
using var src = new MemoryStream(payload);
|
||||||
|
using var dst = new MemoryStream();
|
||||||
|
|
||||||
|
Assert.True(FdsShared.ReadWriteStream(src, dst, closeWriteStream: false));
|
||||||
|
Assert.Equal(payload, dst.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WriteStreamToDisk_PersistsStreamToFile()
|
||||||
|
{
|
||||||
|
var payload = Encoding.UTF8.GetBytes("disk-payload");
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"fds_test_{Guid.NewGuid():N}.bin");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var src = new MemoryStream(payload);
|
||||||
|
Assert.True(FdsShared.WriteStreamToDisk(src, path));
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.Equal(payload, File.ReadAllBytes(path));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FdsConfig + FdsMfrClient share process-global config state (FdsConfig._config),
|
||||||
|
/// so their tests are grouped into one collection to run sequentially and never
|
||||||
|
/// clobber each other's <see cref="FdsConfig.Initialize"/> call.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition("FdsConfig")]
|
||||||
|
public class FdsConfigCollection { }
|
||||||
|
|
||||||
|
[Collection("FdsConfig")]
|
||||||
|
public class FdsConfigTests
|
||||||
|
{
|
||||||
|
private static IConfiguration BuildConfig(Dictionary<string, string?> values) =>
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FDSConnectionString_ReturnsConfiguredValue()
|
||||||
|
{
|
||||||
|
FdsConfig.Initialize(BuildConfig(new()
|
||||||
|
{
|
||||||
|
["ConnectionStrings:fuchs_fds_ConnectionString"] = "Server=.;Database=fds;",
|
||||||
|
}));
|
||||||
|
Assert.Equal("Server=.;Database=fds;", FdsConfig.FDSConnectionString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FDSConnectionString_Missing_Throws()
|
||||||
|
{
|
||||||
|
FdsConfig.Initialize(BuildConfig(new()));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => FdsConfig.FDSConnectionString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MfrSettings_ResolveFromFdsSection()
|
||||||
|
{
|
||||||
|
FdsConfig.Initialize(BuildConfig(new()
|
||||||
|
{
|
||||||
|
["Fds:MFR_UserName"] = "system@example.com",
|
||||||
|
["Fds:MFR_Password"] = "secret",
|
||||||
|
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal("system@example.com", FdsConfig.MFR_UserName);
|
||||||
|
Assert.Equal("secret", FdsConfig.MFR_Password);
|
||||||
|
Assert.Equal("portal.mobilefieldreport.com", FdsConfig.MFR_host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MfrSettings_Absent_FallBackToEmptyString()
|
||||||
|
{
|
||||||
|
FdsConfig.Initialize(BuildConfig(new()));
|
||||||
|
Assert.Equal("", FdsConfig.MFR_UserName);
|
||||||
|
Assert.Equal("", FdsConfig.MFR_Password);
|
||||||
|
Assert.Equal("", FdsConfig.MFR_host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection("FdsConfig")]
|
||||||
|
public class FdsMfrClientTests
|
||||||
|
{
|
||||||
|
private static void InitConfig(string host) =>
|
||||||
|
FdsConfig.Initialize(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Fds:MFR_host"] = host,
|
||||||
|
["Fds:MFR_UserName"] = "u",
|
||||||
|
["Fds:MFR_Password"] = "p",
|
||||||
|
}).Build());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Construction_BuildsClientConfig_FromHost()
|
||||||
|
{
|
||||||
|
InitConfig("portal.mobilefieldreport.com");
|
||||||
|
using var client = new FdsMfrClient();
|
||||||
|
Assert.Contains("portal.mobilefieldreport.com", client.ClientConfig.BaseUrl);
|
||||||
|
Assert.EndsWith("/", client.ClientConfig.BaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsReadonly_DefaultsTrue_AndIsSettable()
|
||||||
|
{
|
||||||
|
InitConfig("portal.mobilefieldreport.com");
|
||||||
|
using var client = new FdsMfrClient();
|
||||||
|
Assert.True(client.IsReadonly);
|
||||||
|
client.IsReadonly = false;
|
||||||
|
Assert.False(client.IsReadonly);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the block projection that feeds the PDF item table: each service-request
|
||||||
|
/// group exposes its heading (the section title the editor shows) and its line items,
|
||||||
|
/// so the PDF can print a heading row per block and the flat item list still works.
|
||||||
|
/// </summary>
|
||||||
|
public class FdsInvoiceDataBlocksTests
|
||||||
|
{
|
||||||
|
private static FdsInvoiceData FromReq(string reqJson) =>
|
||||||
|
new(JObject.Parse(@"{'admin':{'type':'r'},'new':{},'sms':{},'req':" + reqJson + "}"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvoiceBlocks_ExposesHeadingFromTextThenNme_AndItems()
|
||||||
|
{
|
||||||
|
var inv = FromReq(@"[
|
||||||
|
{'Id':'1','text':'Sektion A','items':[{'id':'a','type':'material','title':'X','price_net':10,'total_net':10}]},
|
||||||
|
{'Id':'2','nme':'Sektion B','items':[{'id':'b','type':'material','title':'Y','price_net':20,'total_net':20}]}
|
||||||
|
]");
|
||||||
|
|
||||||
|
var blocks = inv.InvoiceBlocks;
|
||||||
|
|
||||||
|
Assert.Equal(2, blocks.Count);
|
||||||
|
Assert.Equal("Sektion A", blocks[0].Heading);
|
||||||
|
Assert.Equal("Sektion B", blocks[1].Heading); // falls back to nme
|
||||||
|
Assert.Single(blocks[0].Items);
|
||||||
|
Assert.Equal("X", blocks[0].Items[0]["title"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvoiceBlocks_MissingHeading_IsEmpty()
|
||||||
|
{
|
||||||
|
var inv = FromReq(@"[{'Id':'1','items':[{'id':'a','type':'material','total_net':5}]}]");
|
||||||
|
Assert.Equal("", Assert.Single(inv.InvoiceBlocks).Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvoiceItems_StillFlattensAcrossBlocks()
|
||||||
|
{
|
||||||
|
var inv = FromReq(@"[
|
||||||
|
{'Id':'1','text':'A','items':[{'id':'a','type':'material','total_net':10}]},
|
||||||
|
{'Id':'2','text':'B','items':[{'id':'b','type':'material','total_net':20},{'id':'c','type':'material','total_net':30}]}
|
||||||
|
]");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a", "b", "c" }, inv.InvoiceItems.Select(i => i["id"]!.ToString()).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -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,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
|
||||||
|
/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftCacheTests
|
||||||
|
{
|
||||||
|
private static IConfiguration Config(int idle = 30, int warn = 5) =>
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
|
||||||
|
["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
private sealed class FakeNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
public readonly List<(string token, int version)> Ready = new();
|
||||||
|
public readonly List<(string token, int secondsLeft)> Expiring = new();
|
||||||
|
public readonly List<(string token, string reason)> Closed = new();
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache storage ─────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void SetGet_RoundTrips_AndUnknownTokenIsNull()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "abc" };
|
||||||
|
cache.Set(s);
|
||||||
|
Assert.Same(s, cache.Get("abc"));
|
||||||
|
Assert.Null(cache.Get("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Remove_EvictsSession()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "x" });
|
||||||
|
Assert.NotNull(cache.Remove("x"));
|
||||||
|
Assert.Null(cache.Get("x"));
|
||||||
|
Assert.Null(cache.Remove("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(Config());
|
||||||
|
var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
|
||||||
|
cache.Set(s);
|
||||||
|
cache.Get("x");
|
||||||
|
Assert.False(s.ExpiryWarningSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expiry monitor ────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
|
||||||
|
var s = new InvoiceDraftSession { Token = "a" };
|
||||||
|
cache.Set(s);
|
||||||
|
|
||||||
|
// Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
Assert.True(s.ExpiryWarningSent);
|
||||||
|
|
||||||
|
// Another sweep while still idle must not spam a second warning.
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Expiring);
|
||||||
|
|
||||||
|
// Past the TTL → evicted and the editor is told to close with a reason.
|
||||||
|
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
Assert.Single(notifier.Closed);
|
||||||
|
Assert.Equal(("a", "expired"), notifier.Closed[0]);
|
||||||
|
Assert.Null(cache.Get("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sweep_FreshSession_DoesNothing()
|
||||||
|
{
|
||||||
|
var cfg = Config(idle: 30, warn: 5);
|
||||||
|
var cache = new InvoiceDraftCache(cfg);
|
||||||
|
var notifier = new FakeNotifier();
|
||||||
|
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||||
|
cache.Set(new InvoiceDraftSession { Token = "fresh" });
|
||||||
|
|
||||||
|
await svc.SweepAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(notifier.Expiring);
|
||||||
|
Assert.Empty(notifier.Closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the server-side aggregation of invoice draft totals (the port of the
|
||||||
|
/// former client-side <c>invSumUpdate</c> footer math). The truth now lives in the
|
||||||
|
/// backend (ADR 0006), so this is unit-testable directly. Line values are read from
|
||||||
|
/// each block's <c>itm</c> array (the editor's <c>co</c> objects: <c>vt</c>=net,
|
||||||
|
/// <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT, <c>vat</c>=rate).
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftCalculatorTests
|
||||||
|
{
|
||||||
|
private static InvoiceDraftSession SessionWith(string reqJson, bool p13b = false)
|
||||||
|
{
|
||||||
|
var s = new InvoiceDraftSession { Token = "t" };
|
||||||
|
s.Admin = new JObject { ["p13b"] = p13b };
|
||||||
|
s.New = new JObject { ["invoiceemail"] = "kunde@example.de", ["invoiceaddress"] = "Weg 1" };
|
||||||
|
s.Req = JArray.Parse(reqJson);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecomputeLineValues ────────────────────────────────────────────────────
|
||||||
|
private static JObject Line(InvoiceDraftSession s, int block, int line) =>
|
||||||
|
(JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeLineValues_ComputesNetAndVatFromRawQtyPriceAndRate()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':2,'v':50,'vat':'19%'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||||
|
|
||||||
|
var l = Line(s, 0, 0);
|
||||||
|
Assert.Equal(100m, l["vt"]!.Value<decimal>());
|
||||||
|
Assert.Equal(19m, l["vv"]!.Value<decimal>());
|
||||||
|
Assert.Equal(0m, l["vs"]!.Value<decimal>());
|
||||||
|
Assert.Equal(0m, l["vsv"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeLineValues_ServiceType_AlsoFillsServiceNetAndVat()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'a','typ':'Service','qn':3,'v':10,'vat':'19%'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||||
|
|
||||||
|
var l = Line(s, 0, 0);
|
||||||
|
Assert.Equal(30m, l["vt"]!.Value<decimal>());
|
||||||
|
Assert.Equal(5.7m, l["vv"]!.Value<decimal>());
|
||||||
|
Assert.Equal(30m, l["vs"]!.Value<decimal>());
|
||||||
|
Assert.Equal(5.7m, l["vsv"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 50)] // no quantity posted -> leave value untouched
|
||||||
|
[InlineData(2, 0)] // no price posted -> leave value untouched
|
||||||
|
public void RecomputeLineValues_MissingQtyOrPrice_LeavesExistingValueUntouched(decimal qty, decimal price)
|
||||||
|
{
|
||||||
|
var s = SessionWith($@"[
|
||||||
|
{{ 'Id':'10','itm':[ {{'id':'a','typ':'material','qn':{qty},'v':{price},'vat':'19%','vt':777,'vv':111}} ] }}
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||||
|
|
||||||
|
var l = Line(s, 0, 0);
|
||||||
|
Assert.Equal(777m, l["vt"]!.Value<decimal>()); // untouched — mirrors quantChange's own guard
|
||||||
|
Assert.Equal(111m, l["vv"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeLineValues_ZeroVatRate_ComputesNetWithNoVat()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':4,'v':25,'vat':'0%'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||||
|
|
||||||
|
var l = Line(s, 0, 0);
|
||||||
|
Assert.Equal(100m, l["vt"]!.Value<decimal>());
|
||||||
|
Assert.Equal(0m, l["vv"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeLineValues_SetHeaderConvertedSum_IsNotClobberedByRecompute()
|
||||||
|
{
|
||||||
|
// A converted set header carries a synthesised sum (no raw qty/price of its own) —
|
||||||
|
// RecomputeLineValues must never overwrite it, mirroring quantChange's guard.
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'h','typ':'set','vt':1000,'vv':190,'vat':'19%'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||||
|
|
||||||
|
var l = Line(s, 0, 0);
|
||||||
|
Assert.Equal(1000m, l["vt"]!.Value<decimal>());
|
||||||
|
Assert.Equal(190m, l["vv"]!.Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[
|
||||||
|
{'vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'},
|
||||||
|
{'vt':50,'vv':9.5,'vs':50,'vsv':9.5,'vat':'19%'} ] },
|
||||||
|
{ 'Id':'11','itm':[
|
||||||
|
{'vt':200,'vv':14,'vs':0,'vsv':0,'vat':'7%'} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
|
||||||
|
Assert.Equal(350m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(42.5m, s.Sums.TotalVat);
|
||||||
|
Assert.Equal(392.5m, s.Sums.TotalGross);
|
||||||
|
Assert.Equal(50m, s.Sums.ServiceNet);
|
||||||
|
Assert.Equal(9.5m, s.Sums.ServiceVat);
|
||||||
|
Assert.Equal(28.5m, s.Sums.VatByRate["19"]);
|
||||||
|
Assert.Equal(14m, s.Sums.VatByRate["7"]);
|
||||||
|
Assert.Equal(150m, s.Sums.NetByBlock["10"]);
|
||||||
|
Assert.Equal(200m, s.Sums.NetByBlock["11"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", p13b: true);
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
|
||||||
|
Assert.Equal(100m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(100m, s.Sums.TotalGross);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalVat);
|
||||||
|
Assert.Empty(s.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeTotals_EmptyDraft_AllZero()
|
||||||
|
{
|
||||||
|
var s = SessionWith("[]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(0m, s.Sums.TotalGross);
|
||||||
|
Assert.Empty(s.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("19,0%", "19")]
|
||||||
|
[InlineData("7%", "7")]
|
||||||
|
[InlineData("19", "19")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData("0", "")]
|
||||||
|
[InlineData("7,5", "7.5")]
|
||||||
|
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
|
||||||
|
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ValidDraft_NoErrors()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "warning")]
|
||||||
|
[InlineData("not-an-email", "error")]
|
||||||
|
public void Validate_EmailProblems_AreFlagged(string email, string severity)
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
|
||||||
|
s.New["invoiceemail"] = email;
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_NoItems_IsError()
|
||||||
|
{
|
||||||
|
var s = SessionWith("[]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_UnknownVatRate_IsWarning()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vv':0.5,'vat':'5%'}] }]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_MissingAddress_IsWarning()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
|
||||||
|
s.New["invoiceaddress"] = "";
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_NegativeTotal_IsWarning()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':-50,'vv':0,'vat':''}] }]");
|
||||||
|
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||||
|
InvoiceDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecomputePositions ────────────────────────────────────────────────────
|
||||||
|
private static string Pos(InvoiceDraftSession s, int block, int line) =>
|
||||||
|
((JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line])["p"]!.ToString();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputePositions_NumbersPricedLinesContinuouslyAcrossBlocks()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1}, {'id':'b','typ':'service','vt':2} ] },
|
||||||
|
{ 'Id':'11','itm':[ {'id':'c','typ':'material','vt':3} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputePositions(s);
|
||||||
|
|
||||||
|
Assert.Equal("1", Pos(s, 0, 0));
|
||||||
|
Assert.Equal("2", Pos(s, 0, 1));
|
||||||
|
Assert.Equal("3", Pos(s, 1, 0)); // continuous, not restarting per block
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputePositions_SkipsHeadingAndFreeTextLines()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[
|
||||||
|
{'id':'t','typ':'Title','vt':0},
|
||||||
|
{'id':'a','typ':'material','vt':1},
|
||||||
|
{'id':'x','typ':'Text','vt':0},
|
||||||
|
{'id':'b','typ':'material','vt':2} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputePositions(s);
|
||||||
|
|
||||||
|
Assert.Equal("", Pos(s, 0, 0)); // title carries no number
|
||||||
|
Assert.Equal("1", Pos(s, 0, 1));
|
||||||
|
Assert.Equal("", Pos(s, 0, 2)); // free text carries no number
|
||||||
|
Assert.Equal("2", Pos(s, 0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputePositions_NumbersSetHeaderLikeAnyItem()
|
||||||
|
{
|
||||||
|
// A set header is numbered just like the editor numbers it — only text/title lines are skipped.
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[
|
||||||
|
{'id':'h','typ':'set','vt':1000},
|
||||||
|
{'id':'a','typ':'material','vt':600},
|
||||||
|
{'id':'b','typ':'material','vt':400} ] }
|
||||||
|
]");
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputePositions(s);
|
||||||
|
|
||||||
|
Assert.Equal("1", Pos(s, 0, 0)); // set header keeps position 1 (matches the editor)
|
||||||
|
Assert.Equal("2", Pos(s, 0, 1));
|
||||||
|
Assert.Equal("3", Pos(s, 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputePositions_AfterBlockOrderChange_RenumbersToNewSequence()
|
||||||
|
{
|
||||||
|
var s = SessionWith(@"[
|
||||||
|
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1} ] },
|
||||||
|
{ 'Id':'11','itm':[ {'id':'b','typ':'material','vt':2} ] }
|
||||||
|
]");
|
||||||
|
// Simulate a section reorder: swap the two blocks.
|
||||||
|
var b0 = s.Req[0]; var b1 = s.Req[1];
|
||||||
|
s.Req = new JArray(b1.DeepClone(), b0.DeepClone());
|
||||||
|
|
||||||
|
InvoiceDraftCalculator.RecomputePositions(s);
|
||||||
|
|
||||||
|
Assert.Equal("1", Pos(s, 0, 0)); // formerly block 11's item is now position 1
|
||||||
|
Assert.Equal("2", Pos(s, 1, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.Notifications;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="DraftNotifier"/> targets the draft's SignalR group (keyed by
|
||||||
|
/// session token, ADR 0007) with the right method/payload, and — like the other
|
||||||
|
/// notification path — swallows hub failures so a missed coordination ping never
|
||||||
|
/// fails the underlying operation.
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftNotifierTests
|
||||||
|
{
|
||||||
|
private sealed class CapturingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
private readonly bool _throw;
|
||||||
|
public string? Method { get; private set; }
|
||||||
|
public object?[]? Args { get; private set; }
|
||||||
|
public CapturingClientProxy(bool doThrow = false) => _throw = doThrow;
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_throw) throw new InvalidOperationException("hub down");
|
||||||
|
Method = method;
|
||||||
|
Args = args;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHubClients : IHubClients
|
||||||
|
{
|
||||||
|
private readonly IClientProxy _proxy;
|
||||||
|
public string? RequestedGroup { get; private set; }
|
||||||
|
public StubHubClients(IClientProxy proxy) => _proxy = proxy;
|
||||||
|
public IClientProxy Group(string groupName) { RequestedGroup = groupName; return _proxy; }
|
||||||
|
public IClientProxy All => throw new NotImplementedException();
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> e) => throw new NotImplementedException();
|
||||||
|
public IClientProxy Client(string c) => throw new NotImplementedException();
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> c) => throw new NotImplementedException();
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> g) => throw new NotImplementedException();
|
||||||
|
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => throw new NotImplementedException();
|
||||||
|
public IClientProxy User(string u) => throw new NotImplementedException();
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> u) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHubContext : IHubContext<DraftPreviewHub>
|
||||||
|
{
|
||||||
|
public StubHubContext(IHubClients clients) => Clients = clients;
|
||||||
|
public IHubClients Clients { get; }
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DraftNotifier notifier, StubHubClients clients, CapturingClientProxy proxy) Create(bool doThrow = false)
|
||||||
|
{
|
||||||
|
var proxy = new CapturingClientProxy(doThrow);
|
||||||
|
var clients = new StubHubClients(proxy);
|
||||||
|
var notifier = new DraftNotifier(new StubHubContext(clients), NullLogger<DraftNotifier>.Instance);
|
||||||
|
return (notifier, clients, proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SignalDraftReadyAsync_SendsToTokenGroupWithVersion()
|
||||||
|
{
|
||||||
|
var (notifier, clients, proxy) = Create();
|
||||||
|
|
||||||
|
await notifier.SignalDraftReadyAsync("tok-1", 7);
|
||||||
|
|
||||||
|
Assert.Equal("tok-1", clients.RequestedGroup);
|
||||||
|
Assert.Equal("draftReady", proxy.Method);
|
||||||
|
var payload = JObject.FromObject(proxy.Args![0]!);
|
||||||
|
Assert.Equal("tok-1", payload["token"]!.Value<string>());
|
||||||
|
Assert.Equal(7, payload["version"]!.Value<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SignalExpiringAsync_SendsSecondsLeft()
|
||||||
|
{
|
||||||
|
var (notifier, _, proxy) = Create();
|
||||||
|
|
||||||
|
await notifier.SignalExpiringAsync("tok-2", 120);
|
||||||
|
|
||||||
|
Assert.Equal("draftExpiring", proxy.Method);
|
||||||
|
var payload = JObject.FromObject(proxy.Args![0]!);
|
||||||
|
Assert.Equal(120, payload["secondsLeft"]!.Value<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SignalClosedAsync_SendsReason()
|
||||||
|
{
|
||||||
|
var (notifier, _, proxy) = Create();
|
||||||
|
|
||||||
|
await notifier.SignalClosedAsync("tok-3", "expired");
|
||||||
|
|
||||||
|
Assert.Equal("draftClosed", proxy.Method);
|
||||||
|
var payload = JObject.FromObject(proxy.Args![0]!);
|
||||||
|
Assert.Equal("expired", payload["reason"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptyToken_DoesNotSend()
|
||||||
|
{
|
||||||
|
var (notifier, clients, proxy) = Create();
|
||||||
|
|
||||||
|
await notifier.SignalDraftReadyAsync("", 1);
|
||||||
|
|
||||||
|
Assert.Null(clients.RequestedGroup);
|
||||||
|
Assert.Null(proxy.Method);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HubFailure_IsSwallowed()
|
||||||
|
{
|
||||||
|
var (notifier, _, _) = Create(doThrow: true);
|
||||||
|
|
||||||
|
// Must not throw — a failed coordination ping cannot fail the caller's operation.
|
||||||
|
await notifier.SignalDraftReadyAsync("tok", 1);
|
||||||
|
await notifier.SignalExpiringAsync("tok", 60);
|
||||||
|
await notifier.SignalClosedAsync("tok", "expired");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,873 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||||
|
/// without a database, proving the backend-authoritative model behaves correctly at
|
||||||
|
/// the service seam (ADR 0006). Blocks use the editor's <c>itm</c>/<c>items</c> shape.
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceDraftServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Captures the invoice handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||||
|
private sealed class FakeInvoiceService : IInvoiceService
|
||||||
|
{
|
||||||
|
public FdsInvoiceData? Registered;
|
||||||
|
public bool? LastChange;
|
||||||
|
public Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Registered = invoice;
|
||||||
|
LastChange = change;
|
||||||
|
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
||||||
|
return Task.FromResult(invoice);
|
||||||
|
}
|
||||||
|
public FdsInvoiceData? PreviewInvoice;
|
||||||
|
public bool? PreviewDraft;
|
||||||
|
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||||
|
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
|
||||||
|
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||||
|
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||||
|
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
|
||||||
|
{
|
||||||
|
var cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
|
||||||
|
var inv = new FakeInvoiceService();
|
||||||
|
var svc = new InvoiceDraftEditService(cache, inv, NullLogger<InvoiceDraftEditService>.Instance);
|
||||||
|
return (svc, inv, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject Payload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'}],
|
||||||
|
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenFromPayload_SeedsSessionAndComputesTotals()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||||
|
Assert.Equal(0, s.Version);
|
||||||
|
Assert.Equal(100m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(119m, s.Sums.TotalGross);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("email", h.Target);
|
||||||
|
Assert.Equal("a@b.de", h.OldValue);
|
||||||
|
Assert.Equal("neu@x.de", h.NewValue);
|
||||||
|
Assert.Equal(1, h.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_StructuredAddress_ComposesFreeTextAndPersistsJson()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var addr = JObject.Parse(@"{'name':'Muster GmbH','street':'Weg 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}");
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = addr });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
// Free-text block is composed for the PDF / SendToAddress path.
|
||||||
|
Assert.Equal("Muster GmbH\nWeg 1\n40223 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
|
||||||
|
// Structured JSON rides inside the CustomValues blob (interim persistence).
|
||||||
|
var parsed = InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]!.Value<string>());
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal("DE123456789", parsed!.VatId);
|
||||||
|
Assert.True(parsed.IsEn16931Conformant);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Address_PlainString_KeepsLegacyFreeTextBehavior()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Weg 9\n50667 Köln") });
|
||||||
|
|
||||||
|
Assert.Equal("Weg 9\n50667 Köln", s2!.New["invoiceaddress"]!.Value<string>());
|
||||||
|
Assert.Null(InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]?.Value<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockReplace_RecomputesTotals()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var newBlock = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':50,'vv':9.5,'vat':'19%'}],
|
||||||
|
'items':[{'id':'900','type':'material','total_net':50,'vat':'19%'}]}");
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
|
||||||
|
|
||||||
|
Assert.Equal(50m, s2!.Sums.TotalNet);
|
||||||
|
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockRemove_EmptiesDraftAndFlagsNoItems()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.remove", Ref = "1" });
|
||||||
|
|
||||||
|
Assert.Equal(0m, s2!.Sums.TotalNet);
|
||||||
|
Assert.Contains(s2.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b" }); // no value → toggle
|
||||||
|
|
||||||
|
Assert.Equal(100m, s2!.Sums.TotalGross); // reverse-charge → gross == net
|
||||||
|
Assert.Empty(s2.Sums.VatByRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
Assert.Null(svc.ApplyPatch("ghost", new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_RegistersWithMappedTotals_AndSetsInvId()
|
||||||
|
{
|
||||||
|
var (svc, inv, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("INV42", result!.Id);
|
||||||
|
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||||
|
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
||||||
|
|
||||||
|
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
|
||||||
|
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
|
||||||
|
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
var vatRate = prms.First(p => p.ParameterName == "@InvoiceVAT_1");
|
||||||
|
Assert.Equal("19", vatRate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHistory_UnknownToken_IsEmpty()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
Assert.Empty(svc.GetHistory("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("address", "invoiceaddress")]
|
||||||
|
[InlineData("title", "invoicetitle")]
|
||||||
|
[InlineData("provisionperiod", "provisionperiod")]
|
||||||
|
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
|
||||||
|
|
||||||
|
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ProvisionLocation_MirrorsLocAndProvisionlocation()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("Baustelle 7") });
|
||||||
|
|
||||||
|
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
|
||||||
|
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Contact_BuildsCustomValuesJson()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
|
||||||
|
{
|
||||||
|
Target = "contact",
|
||||||
|
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
|
||||||
|
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
|
||||||
|
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_SetmodeDelta_UpdatesAdmin()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("setonly") });
|
||||||
|
|
||||||
|
Assert.Equal("setonly", s2!.Admin["setmode"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_P13bExplicitFalse_TurnsOffAndRestoresVat()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var payload = Payload();
|
||||||
|
payload["admin"]!["p13b"] = true; // start reverse-charge
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
Assert.Empty(s.Sums.VatByRate);
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
|
||||||
|
|
||||||
|
Assert.Equal(119m, s2!.Sums.TotalGross); // VAT restored
|
||||||
|
Assert.Equal(19m, s2.Sums.VatByRate["19"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockReplace_InsertsWhenBlockIsNew()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var newBlock = JObject.Parse(@"{'Id':'2','text':'Zusatz','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],
|
||||||
|
'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}");
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "2", Value = newBlock });
|
||||||
|
|
||||||
|
Assert.Equal(2, s2!.Req.Count);
|
||||||
|
Assert.Equal(130m, s2.Sums.TotalNet); // 100 (block 1) + 30 (new block 2)
|
||||||
|
Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fds__prepInvoice's [SetItmID] window function anchors on the still-unconverted (price 0)
|
||||||
|
// Set header: the header row's own SetItmId self-references its own id ('1'), never null —
|
||||||
|
// ApplyItemSetPrice must still recognize id-equality first and never treat the header as its
|
||||||
|
// own member (see ApplyItemSetPrice's id == Ref check).
|
||||||
|
private static JObject SetPayload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','text':'Auftrag','itm':[
|
||||||
|
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||||
|
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||||
|
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'}],
|
||||||
|
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
|
||||||
|
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'}]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
// A fourth, unrelated item ('4') follows the set's members in the same block but was never
|
||||||
|
// attributed a SetItmId by fds__prepInvoice (it isn't part of the set) — it must stay untouched
|
||||||
|
// by the conversion, proving membership is driven purely by SetItmId, not row order/position.
|
||||||
|
private static JObject SetPayloadWithTrailingUnrelatedItem() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','text':'Auftrag','itm':[
|
||||||
|
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||||
|
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||||
|
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'},
|
||||||
|
{'id':'4','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
|
||||||
|
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
|
||||||
|
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'},
|
||||||
|
{'id':'4','type':'material','total_net':25,'vat':'19%'}]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemSetPrice_SumsMembersOntoHeaderAndNullsMembers()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
var block = (JObject)s2.Req[0];
|
||||||
|
var lines = (JArray)block["itm"]!;
|
||||||
|
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||||
|
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||||
|
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||||
|
|
||||||
|
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40
|
||||||
|
Assert.Equal(19m, header["vv"]!.Value<decimal>()); // 11.4 + 7.6
|
||||||
|
Assert.Equal(10m, header["vs"]!.Value<decimal>());
|
||||||
|
Assert.Equal(1.9m, header["vsv"]!.Value<decimal>());
|
||||||
|
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: members nulled (empty cell), not 0
|
||||||
|
Assert.Equal(JTokenType.Null, m2["vv"]!.Type);
|
||||||
|
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
|
||||||
|
Assert.Equal(JTokenType.Null, m3["vv"]!.Type);
|
||||||
|
|
||||||
|
// Total invoice sum is unchanged by the conversion (set price == sum of members).
|
||||||
|
Assert.Equal(100m, s2.Sums.TotalNet);
|
||||||
|
Assert.Equal(19m, s2.Sums.TotalVat);
|
||||||
|
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("item.setprice", h.Target);
|
||||||
|
Assert.Equal("1", h.Ref);
|
||||||
|
Assert.Equal("0", h.OldValue);
|
||||||
|
Assert.Equal("100", h.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemSetPrice_UnknownRef_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "999" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(0, s2!.Version);
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemSetPrice_RefNotASetHeader_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "2" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(0, s2!.Version);
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemSetPrice_HeaderSelfReferencingSetItmId_NeverCountsHeaderAsOwnMember()
|
||||||
|
{
|
||||||
|
// Regression test: fds__prepInvoice no longer nulls out the header's own SetItmId (it
|
||||||
|
// self-references its own id). ApplyItemSetPrice must still sum exactly the two real
|
||||||
|
// members (100 net), not 3x by also including the header as if it were a member of itself.
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
var block = (JObject)s2!.Req[0];
|
||||||
|
var lines = (JArray)block["itm"]!;
|
||||||
|
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||||
|
|
||||||
|
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40, not tripled by self-inclusion
|
||||||
|
Assert.Equal(100m, s2.Sums.TotalNet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ItemSetPrice_UnrelatedItemAfterMembers_IsNeverSweptIntoSet()
|
||||||
|
{
|
||||||
|
// Regression test for the reported bug: an item after the set's real members in the same
|
||||||
|
// block, but with no SetItmId of its own, must stay fully priced and untouched — only
|
||||||
|
// items the server actually tagged with SetItmId == the header id are members.
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(SetPayloadWithTrailingUnrelatedItem(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
var block = (JObject)s2!.Req[0];
|
||||||
|
var lines = (JArray)block["itm"]!;
|
||||||
|
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||||
|
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||||
|
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||||
|
var other = lines.OfType<JObject>().Single(l => (string)l["id"]! == "4");
|
||||||
|
|
||||||
|
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // only the real members (60 + 40)
|
||||||
|
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: nulled, not 0
|
||||||
|
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
|
||||||
|
Assert.Equal(25m, other["vt"]!.Value<decimal>()); // untouched — never part of the set
|
||||||
|
Assert.Equal(4.75m, other["vv"]!.Value<decimal>());
|
||||||
|
|
||||||
|
// Total invoice sum unaffected: 100 (set) + 25 (unrelated item) = 125.
|
||||||
|
Assert.Equal(125m, s2.Sums.TotalNet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
|
||||||
|
private static JObject BlocksPayload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[
|
||||||
|
{'Id':'1','text':'Auftrag A','itm':[
|
||||||
|
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
|
||||||
|
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
|
||||||
|
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
|
||||||
|
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
|
||||||
|
{'Id':'2','text':'Auftrag B','itm':[
|
||||||
|
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
|
||||||
|
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||||
|
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
|
||||||
|
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
|
||||||
|
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
|
||||||
|
var setA = (JObject)linesA[0];
|
||||||
|
Assert.Equal("set", (string)setA["typ"]!);
|
||||||
|
Assert.Equal("bset_1", (string)setA["id"]!);
|
||||||
|
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
|
||||||
|
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
|
||||||
|
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
|
||||||
|
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
|
||||||
|
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
|
||||||
|
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
|
||||||
|
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
|
||||||
|
|
||||||
|
// items contract mirrors it (PDF reads total_net from here).
|
||||||
|
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
|
||||||
|
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
|
||||||
|
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>());
|
||||||
|
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
|
||||||
|
|
||||||
|
// second block converted too; total conserved.
|
||||||
|
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
|
||||||
|
Assert.Equal(130m, s2.Sums.TotalNet);
|
||||||
|
Assert.Equal(24.7m, s2.Sums.TotalVat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
|
||||||
|
Assert.Single(linesA); // members removed, only the set row remains
|
||||||
|
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
|
||||||
|
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
|
||||||
|
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
|
||||||
|
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
|
||||||
|
{
|
||||||
|
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
|
||||||
|
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||||
|
var setDisplay = (JObject)state["setDisplay"]!;
|
||||||
|
Assert.False(setDisplay.ContainsKey("bset_1"));
|
||||||
|
Assert.Empty(setDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||||
|
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
|
||||||
|
}"), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(0, s2!.Version);
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Titel 2") });
|
||||||
|
var s3 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Adr 3") });
|
||||||
|
|
||||||
|
Assert.Equal(3, s3!.Version);
|
||||||
|
Assert.Equal(3, s3.History.Count);
|
||||||
|
Assert.Equal(new[] { "email", "title", "address" }, s3.History.Select(h => h.Target).ToArray());
|
||||||
|
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildState_ExposesPayloadSumsValidationAndVersion()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") });
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||||
|
|
||||||
|
Assert.Equal(1, state["version"]!.Value<int>());
|
||||||
|
Assert.Equal(100m, state["sums"]!["total_net"]!.Value<decimal>());
|
||||||
|
Assert.Equal(119m, state["sums"]!["total_gross"]!.Value<decimal>());
|
||||||
|
Assert.Equal(19m, state["sums"]!["vat"]!["19"]!.Value<decimal>());
|
||||||
|
Assert.Single((JArray)state["req"]!);
|
||||||
|
Assert.Equal(1, state["historyCount"]!.Value<int>());
|
||||||
|
Assert.NotNull(state["validation"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_ExistingInvId_UpdatesInsteadOfCreates()
|
||||||
|
{
|
||||||
|
var (svc, inv, _) = NewService();
|
||||||
|
var payload = Payload();
|
||||||
|
payload["invid"] = "INV7";
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
|
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.True(inv.LastChange); // prior InvId → update path
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_MapsInvoiceOptionsFrom13bAndSetmode()
|
||||||
|
{
|
||||||
|
var (svc, inv, _) = NewService();
|
||||||
|
var payload = Payload();
|
||||||
|
payload["admin"]!["p13b"] = true;
|
||||||
|
payload["admin"]!["setmode"] = "setonly";
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
|
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
|
||||||
|
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
|
||||||
|
Assert.Contains("§13b", options);
|
||||||
|
Assert.Contains("setmode:setonly", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
|
||||||
|
{
|
||||||
|
var (svc, inv, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var doc = svc.RenderPreview(s.Token);
|
||||||
|
|
||||||
|
Assert.NotNull(doc);
|
||||||
|
Assert.True(inv.PreviewDraft); // always rendered as a draft
|
||||||
|
var reg = inv.PreviewInvoice!.InvoiceRegistration!;
|
||||||
|
Assert.Equal("Rechnung", reg.getString("InvoiceTitle"));
|
||||||
|
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
|
||||||
|
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
|
||||||
|
Assert.Equal("19", reg.getString("InvoiceVAT_1")); // rate synthesised from server sums
|
||||||
|
Assert.True(inv.PreviewInvoice.IsDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
Assert.Null(svc.RenderPreview("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_RemovesSession_ThenReportsFalse()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
Assert.True(svc.Close(s.Token));
|
||||||
|
Assert.Null(svc.Get(s.Token));
|
||||||
|
Assert.False(svc.Close(s.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML sanitisation (values must never reach the DB/PDF wrapped in tags) ─
|
||||||
|
[Theory]
|
||||||
|
[InlineData("provisionperiod", "provisionperiod")]
|
||||||
|
[InlineData("title", "invoicetitle")]
|
||||||
|
[InlineData("email", "invoiceemail")]
|
||||||
|
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("<p>18.06.2026</p>") });
|
||||||
|
|
||||||
|
Assert.Equal("18.06.2026", s2!.New[newKey]!.Value<string>()); // no <p> tags stored
|
||||||
|
Assert.Equal("18.06.2026", Assert.Single(s2.History).NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
|
||||||
|
{
|
||||||
|
Target = "address",
|
||||||
|
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>5080 Laufenburg</p>")
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ScalarField_DecodesEntities()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Tom & Jerry") });
|
||||||
|
|
||||||
|
Assert.Equal("Tom & Jerry", s2!.New["invoicetitle"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_ProvisionLocation_SanitisesAndMirrorsLoc()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("<p>Baustelle 7</p>") });
|
||||||
|
|
||||||
|
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
|
||||||
|
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change history records the changed field, not the whole block JSON ─────
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockReplace_HistoryNewValueIsSectionText_NotJson()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var newBlock = JObject.Parse(@"{'Id':'1','text':'<p>Neue Überschrift</p>',
|
||||||
|
'itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],
|
||||||
|
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}");
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
|
||||||
|
|
||||||
|
var h = Assert.Single(s2!.History);
|
||||||
|
Assert.Equal("Neue Überschrift", h.NewValue); // the heading, sanitised — never the block JSON
|
||||||
|
Assert.DoesNotContain("{", h.NewValue);
|
||||||
|
Assert.Equal("Auftrag", h.OldValue);
|
||||||
|
// and the cached block text is stored clean too
|
||||||
|
Assert.Equal("Neue Überschrift", ((JObject)s2.Req[0])["text"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section reorder ───────────────────────────────────────────────────────
|
||||||
|
private static JObject TwoBlockPayload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
|
||||||
|
'req':[
|
||||||
|
{'Id':'1','text':'A','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]},
|
||||||
|
{'Id':'2','text':'B','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}
|
||||||
|
]}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockOrder_ReordersReqAndRenumbersPositions()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
|
||||||
|
Assert.Equal(new[] { "1", "2" }, s.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||||
|
Assert.Equal("1", ((JObject)((JArray)((JObject)s2.Req[0])["itm"]!)[0])["p"]!.ToString()); // block 2's item now position 1
|
||||||
|
Assert.Equal(130m, s2.Sums.TotalNet); // totals unaffected by reorder
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("1,2", h.OldValue);
|
||||||
|
Assert.Equal("2,1", h.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockOrder_UnchangedSequence_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['1','2']") });
|
||||||
|
|
||||||
|
Assert.Equal(0, s2!.Version); // no-op: no version bump, no history
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockOrder_UnknownIds_KeepMentionedFirstThenRest()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
|
||||||
|
|
||||||
|
// Only name block 2; block 1 is unmentioned and must be kept (appended after).
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','ghost']") });
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multiple VAT rates aggregate independently (ADR 0008: server owns every tax sum) ──
|
||||||
|
private static JObject MultiRatePayload() => JObject.Parse(@"{
|
||||||
|
'admin':{'p13b':false,'type':'r'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
|
||||||
|
'req':[
|
||||||
|
{'Id':'1','text':'A','itm':[
|
||||||
|
{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
|
||||||
|
{'id':'901','typ':'material','vt':200,'vv':14,'vat':'7%'},
|
||||||
|
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
|
||||||
|
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'},
|
||||||
|
{'id':'901','type':'material','total_net':200,'vat':'7%'},
|
||||||
|
{'id':'902','type':'material','total_net':50,'vat':'0%'}]}
|
||||||
|
]}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenFromPayload_MultipleVatRates_GroupsSumsPerRate()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
|
||||||
|
|
||||||
|
Assert.Equal(350m, s.Sums.TotalNet);
|
||||||
|
Assert.Equal(33m, s.Sums.TotalVat);
|
||||||
|
Assert.Equal(383m, s.Sums.TotalGross);
|
||||||
|
Assert.Equal(19m, s.Sums.VatByRate["19"]);
|
||||||
|
Assert.Equal(14m, s.Sums.VatByRate["7"]);
|
||||||
|
Assert.False(s.Sums.VatByRate.ContainsKey("0")); // zero-rate contributes no VAT key (mirrors calculator)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_BlockReplace_MultiRate_RecomputesEachRateIndependently()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
|
||||||
|
|
||||||
|
// Halve the 7%-rate line's net/VAT via a block replace; the 19% bucket must stay untouched.
|
||||||
|
var newBlock = JObject.Parse(@"{'Id':'1','text':'A','itm':[
|
||||||
|
{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
|
||||||
|
{'id':'901','typ':'material','vt':100,'vv':7,'vat':'7%'},
|
||||||
|
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
|
||||||
|
'items':[]}");
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
|
||||||
|
|
||||||
|
Assert.Equal(19m, s2!.Sums.VatByRate["19"]); // unchanged
|
||||||
|
Assert.Equal(7m, s2.Sums.VatByRate["7"]); // recomputed from new line
|
||||||
|
Assert.Equal(250m, s2.Sums.TotalNet);
|
||||||
|
Assert.Equal(26m, s2.Sums.TotalVat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BuildSetDisplay reflects both set-pricing modes (mirrors the PDF's InvoiceSetPricing) ──
|
||||||
|
// SetPayload()'s header item is still unconverted (own total_net == 0, as delivered by
|
||||||
|
// fds__prepInvoice) — until the set-item switch (ApplyItemSetPrice) actually gives it its own
|
||||||
|
// price, InvoiceSetPricing.Build must not apply the chosen setmode yet: header stays blank and
|
||||||
|
// each member keeps showing its own individual price.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("setprice")]
|
||||||
|
[InlineData("setonly")]
|
||||||
|
public void BuildState_SetDisplay_UnconvertedSet_HeaderBlankMembersIndividuallyPriced(string setmode)
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var payload = SetPayload();
|
||||||
|
payload["admin"]!["setmode"] = setmode;
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(s));
|
||||||
|
var setDisplay = (JObject)state["setDisplay"]!;
|
||||||
|
|
||||||
|
Assert.False(setDisplay["1"]!["p"]!.Value<bool>()); // header not priced yet
|
||||||
|
Assert.True(setDisplay["2"]!["p"]!.Value<bool>()); // member 2 keeps its own price
|
||||||
|
Assert.True(setDisplay["3"]!["p"]!.Value<bool>()); // member 3 keeps its own price
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once converted (ApplyItemSetPrice has given the header its own price), the chosen setmode
|
||||||
|
// takes effect: SetPrice blanks the members (still present in the map); SetOnly drops them
|
||||||
|
// from the map entirely.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("setprice", true, false)] // header priced, member 2 blank
|
||||||
|
[InlineData("setonly", true, false)] // header priced; member 2 dropped entirely (absent from map)
|
||||||
|
public void BuildState_SetDisplay_ConvertedSet_ReflectsSetmodeForHeaderAndMember(string setmode, bool headerShown, bool memberShown)
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var payload = SetPayload();
|
||||||
|
payload["admin"]!["setmode"] = setmode;
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" }); // convert the set first
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(s));
|
||||||
|
var setDisplay = (JObject)state["setDisplay"]!;
|
||||||
|
|
||||||
|
Assert.Equal(headerShown, setDisplay["1"]!["p"]!.Value<bool>());
|
||||||
|
if (setmode == "setonly")
|
||||||
|
Assert.False(setDisplay.ContainsKey("2")); // member removed from the display entirely
|
||||||
|
else
|
||||||
|
Assert.Equal(memberShown, setDisplay["2"]!["p"]!.Value<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full lifecycle recalculation: several edits of different kinds land in one consistent recompute ──
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_FullEditSequence_TextReorderSetPriceAndTaxToggle_EndsConsistent()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
|
||||||
|
|
||||||
|
// 1) text change
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Endabrechnung") });
|
||||||
|
// 2) reorder sections
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
|
||||||
|
// 3) toggle reverse-charge on, then off again (settings roundtrip)
|
||||||
|
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(true) });
|
||||||
|
var final = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
|
||||||
|
|
||||||
|
Assert.NotNull(final);
|
||||||
|
Assert.Equal("Endabrechnung", final!.New["invoicetitle"]!.Value<string>());
|
||||||
|
Assert.Equal(new[] { "2", "1" }, final.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||||
|
Assert.Equal("1", ((JObject)((JArray)((JObject)final.Req[0])["itm"]!)[0])["p"]!.ToString()); // renumbered after reorder
|
||||||
|
Assert.Equal(130m, final.Sums.TotalNet); // totals stable across the whole sequence
|
||||||
|
Assert.Equal(24.7m, final.Sums.TotalVat); // VAT restored after the toggle roundtrip
|
||||||
|
Assert.Equal(4, final.History.Count);
|
||||||
|
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
@@ -31,16 +31,25 @@ public class InvoiceOptionsTests
|
|||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultSetPrice_OmitsToken()
|
public void ExplicitDefaultSetPrice_EmitsToken()
|
||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
// An explicit choice of the default mode must still be persisted — otherwise it is
|
||||||
|
// indistinguishable from an invoice that was never switched to set-pricing at all
|
||||||
|
// (the editor relies on this to hide the "Set-Preisanzeige" menu entry once chosen).
|
||||||
|
=> Assert.Equal("setmode:setprice", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("itemprices", "setmode:itemprices")]
|
|
||||||
[InlineData("setonly", "setmode:setonly")]
|
[InlineData("setonly", "setmode:setonly")]
|
||||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
|
||||||
public void SetMode_EmitsToken(string mode, string expected)
|
public void SetMode_EmitsToken(string mode, string expected)
|
||||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovedItemPricesMode_OmitsToken()
|
||||||
|
// "itemprices" was a valid mode before the button/mode was removed. Any invoice options
|
||||||
|
// still carrying the stale value (or a client re-posting it) must not be persisted as a
|
||||||
|
// recognized token — it is treated the same as an unknown/garbage mode.
|
||||||
|
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "itemprices" }));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnknownSetMode_OmitsToken()
|
public void UnknownSetMode_OmitsToken()
|
||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||||
@@ -103,9 +112,11 @@ public class InvoiceOptionsTests
|
|||||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||||
{
|
{
|
||||||
// The token this side emits must parse back to the same mode on the PDF side.
|
// The token this side emits must parse back to the same mode on the PDF side.
|
||||||
Assert.Equal(SetDisplayMode.ItemPrices,
|
|
||||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
|
||||||
Assert.Equal(SetDisplayMode.SetOnly,
|
Assert.Equal(SetDisplayMode.SetOnly,
|
||||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
||||||
|
// A removed mode never round-trips as itself — it is never persisted, so reading it back
|
||||||
|
// always yields the default SetPrice.
|
||||||
|
Assert.Equal(SetDisplayMode.SetPrice,
|
||||||
|
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Fuchs.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-logic tests for the structured invoice recipient address: free-text composition for the
|
||||||
|
/// PDF, EN 16931 / DATEV conformity detection, effortless B2C (no VAT id), and JSON round-tripping
|
||||||
|
/// through the <c>CustomValues</c> blob. See ADR 0012.
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceRecipientAddressTests
|
||||||
|
{
|
||||||
|
private static InvoiceRecipientAddress FullB2B() => new()
|
||||||
|
{
|
||||||
|
Name = "Muster GmbH",
|
||||||
|
Contact = "Frau Schmidt",
|
||||||
|
Street = "Hauptstraße 1",
|
||||||
|
PostalCode = "40223",
|
||||||
|
City = "Düsseldorf",
|
||||||
|
CountryCode = "DE",
|
||||||
|
VatId = "DE123456789",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_DomesticFullAddress_OmitsCountryLineAndVatId()
|
||||||
|
{
|
||||||
|
var text = FullB2B().Compose();
|
||||||
|
Assert.Equal("Muster GmbH\nz.Hd. Frau Schmidt\nHauptstraße 1\n40223 Düsseldorf", text);
|
||||||
|
Assert.DoesNotContain("DE123456789", text); // VAT id never in the postal block
|
||||||
|
Assert.DoesNotContain("\nDE", text); // domestic country code suppressed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_ForeignCountry_AppendsCountryCodeLine()
|
||||||
|
{
|
||||||
|
var addr = FullB2B();
|
||||||
|
addr.CountryCode = "AT";
|
||||||
|
addr.City = "Wien";
|
||||||
|
addr.PostalCode = "1010";
|
||||||
|
Assert.EndsWith("1010 Wien\nAT", addr.Compose());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrivatePerson_NoVatId_IsB2CAndStillEn16931Conformant()
|
||||||
|
{
|
||||||
|
var addr = new InvoiceRecipientAddress
|
||||||
|
{
|
||||||
|
Name = "Max Mustermann",
|
||||||
|
Street = "Weg 2",
|
||||||
|
PostalCode = "50667",
|
||||||
|
City = "Köln",
|
||||||
|
CountryCode = "DE",
|
||||||
|
// no VatId
|
||||||
|
};
|
||||||
|
Assert.True(addr.IsPrivatePerson);
|
||||||
|
Assert.True(addr.IsEn16931Conformant);
|
||||||
|
Assert.Empty(addr.MissingForEn16931());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "DE", "Köln", new[] { "Name" })]
|
||||||
|
[InlineData("Firma", "", "Köln", new[] { "Land" })]
|
||||||
|
[InlineData("Firma", "DE", "", new[] { "Ort/PLZ" })]
|
||||||
|
public void MissingForEn16931_FlagsMandatoryGaps(string name, string country, string city, string[] expected)
|
||||||
|
{
|
||||||
|
var addr = new InvoiceRecipientAddress { Name = name, CountryCode = country, City = city };
|
||||||
|
Assert.Equal(expected, addr.MissingForEn16931());
|
||||||
|
Assert.False(addr.IsEn16931Conformant);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_AcceptsAlternateKeys()
|
||||||
|
{
|
||||||
|
var addr = InvoiceRecipientAddress.FromJson(JObject.Parse(
|
||||||
|
@"{'name':'X','plz':'12345','ort':'Ort','country':'de','ustid':'DE9'}"));
|
||||||
|
Assert.Equal("12345", addr.PostalCode);
|
||||||
|
Assert.Equal("Ort", addr.City);
|
||||||
|
Assert.Equal("DE", addr.CountryCode); // uppercased
|
||||||
|
Assert.Equal("DE9", addr.VatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromCustomValues_RoundTripsThroughTheBlob()
|
||||||
|
{
|
||||||
|
var cv = new JObject
|
||||||
|
{
|
||||||
|
["contactName"] = "someone",
|
||||||
|
[InvoiceRecipientAddress.CustomValuesKey] = FullB2B().ToJson(),
|
||||||
|
};
|
||||||
|
var parsed = InvoiceRecipientAddress.FromCustomValues(cv.ToString());
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal("Muster GmbH", parsed!.Name);
|
||||||
|
Assert.Equal("DE123456789", parsed.VatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromCustomValues_ReturnsNull_WhenNoStructuredAddressPresent()
|
||||||
|
{
|
||||||
|
Assert.Null(InvoiceRecipientAddress.FromCustomValues(@"{'contactName':'x'}"));
|
||||||
|
Assert.Null(InvoiceRecipientAddress.FromCustomValues(""));
|
||||||
|
Assert.Null(InvoiceRecipientAddress.FromCustomValues("not json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
|
|||||||
|
|
||||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
|
||||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
|
||||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||||
|
[InlineData("set_only", SetDisplayMode.SetOnly)]
|
||||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||||
[InlineData("", SetDisplayMode.SetPrice)]
|
[InlineData("", SetDisplayMode.SetPrice)]
|
||||||
|
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
|
||||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||||
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ModeFromInvoiceOptions_ReadsToken()
|
public void ModeFromInvoiceOptions_ReadsToken()
|
||||||
{
|
{
|
||||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
|
||||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
// A stale/removed "itemprices" token (e.g. from an invoice created before the mode was
|
||||||
|
// dropped) must not throw — it degrades gracefully to the default SetPrice mode.
|
||||||
|
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
|
|||||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
|
||||||
[Fact]
|
|
||||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
|
||||||
{
|
|
||||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
|
||||||
|
|
||||||
Assert.Equal(4, lines.Count);
|
|
||||||
Assert.True(lines[0].IsSetHeader);
|
|
||||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
|
||||||
Assert.True(lines[1].ShowPrice);
|
|
||||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
|
||||||
Assert.True(lines[2].ShowPrice);
|
|
||||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
|
||||||
Assert.True(lines[3].ShowPrice); // standalone
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_SetOnly_RemovesMembers()
|
public void Build_SetOnly_RemovesMembers()
|
||||||
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
|
|||||||
Assert.Equal("Anfahrt", lines[1].Title);
|
Assert.Equal("Anfahrt", lines[1].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
// ── Unconverted set (header total still 0): shown blank, members individually priced ──
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
|
||||||
{
|
{
|
||||||
var items = new List<Dictionary<string, object?>>
|
var items = new List<Dictionary<string, object?>>
|
||||||
{
|
{
|
||||||
SetHeader("7", "Set ohne Preis"), // total 0
|
SetHeader("7", "Set ohne Preis"), // total 0 — not yet converted
|
||||||
Member("7", "A", "120.00", "120.00"),
|
Member("7", "A", "120.00", "120.00"),
|
||||||
Member("7", "B", "80.00", "80.00")
|
Member("7", "B", "80.00", "80.00")
|
||||||
};
|
};
|
||||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
|
||||||
|
Assert.Equal(3, lines.Count);
|
||||||
|
Assert.True(lines[0].IsSetHeader);
|
||||||
|
Assert.False(lines[0].ShowPrice); // header not priced yet
|
||||||
|
Assert.Equal(0m, lines[0].TotalNet);
|
||||||
|
|
||||||
|
Assert.True(lines[1].ShowPrice); // members keep their own price
|
||||||
|
Assert.Equal(120.00m, lines[1].TotalNet);
|
||||||
|
Assert.True(lines[2].ShowPrice);
|
||||||
|
Assert.Equal(80.00m, lines[2].TotalNet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetOnly on an unconverted set also just passes items through unchanged ──
|
||||||
|
[Fact]
|
||||||
|
public void Build_HeaderTotalZero_UnconvertedSet_SetOnlyModeStillPassesThrough()
|
||||||
|
{
|
||||||
|
var items = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
SetHeader("7", "Set ohne Preis"),
|
||||||
|
Member("7", "A", "120.00", "120.00"),
|
||||||
|
Member("7", "B", "80.00", "80.00")
|
||||||
|
};
|
||||||
|
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetOnly);
|
||||||
|
|
||||||
|
Assert.Equal(3, lines.Count); // members not dropped before conversion
|
||||||
|
Assert.False(lines[0].ShowPrice);
|
||||||
|
Assert.True(lines[1].ShowPrice);
|
||||||
|
Assert.True(lines[2].ShowPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||||
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
|
||||||
{
|
{
|
||||||
var items = new List<Dictionary<string, object?>>
|
var items = new List<Dictionary<string, object?>>
|
||||||
{
|
{
|
||||||
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
|
|||||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||||
Member("10", "Waschbecken", "600.00", "600.00")
|
Member("10", "Waschbecken", "600.00", "600.00")
|
||||||
};
|
};
|
||||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
|
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||||
var note = lines.First(l => l.Title == "Hinweis");
|
var note = lines.First(l => l.Title == "Hinweis");
|
||||||
Assert.False(note.ShowPrice); // text member stays blank
|
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
|
||||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
Assert.False(lines.First(l => l.Title == "Waschbecken").ShowPrice); // members blank in SetPrice mode too
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ public class MFRClientConfigTests
|
|||||||
Assert.Equal("", config.LogoutAddress);
|
Assert.Equal("", config.LogoutAddress);
|
||||||
Assert.Equal("", config.TokenCookieName);
|
Assert.Equal("", config.TokenCookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: an empty/missing host used to be silently turned into "https:///odata/"
|
||||||
|
// (empty authority), which only failed much later inside RestSharp with a cryptic
|
||||||
|
// "Invalid URI: The hostname could not be parsed." Fail fast at construction instead.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Constructor_WithMissingHost_ThrowsArgumentException(string? url)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => new MFRClientConfig(url!));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MFRClientCredentialsTests
|
public class MFRClientCredentialsTests
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Fuchs.Services;
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -46,6 +49,12 @@ public class ProcessWebComServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> throw new HttpRequestException("simulated transport failure");
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
{
|
{
|
||||||
private readonly HttpMessageHandler _handler;
|
private readonly HttpMessageHandler _handler;
|
||||||
@@ -53,7 +62,42 @@ public class ProcessWebComServiceTests
|
|||||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
|
/// <summary>
|
||||||
|
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
|
||||||
|
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
|
||||||
|
/// failures carry the service response / exception detail.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingComService : ProcessWebComService
|
||||||
|
{
|
||||||
|
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||||
|
|
||||||
|
public CapturingComService(
|
||||||
|
IOptions<ProcessWebComSettings> settings,
|
||||||
|
IOptions<FuchsEmailSettings> emailSettings,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||||
|
DateTime sent, bool success, IEnumerable<string> errors)
|
||||||
|
{
|
||||||
|
Entries.Add((success, errors.ToList()));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||||
|
{
|
||||||
|
var settings = Options.Create(new ProcessWebComSettings
|
||||||
|
{
|
||||||
|
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
|
||||||
|
});
|
||||||
|
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
|
||||||
|
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||||
{
|
{
|
||||||
var settings = Options.Create(new ProcessWebComSettings
|
var settings = Options.Create(new ProcessWebComSettings
|
||||||
{
|
{
|
||||||
@@ -62,10 +106,15 @@ public class ProcessWebComServiceTests
|
|||||||
AccountId = "acct",
|
AccountId = "acct",
|
||||||
Token = "tok"
|
Token = "tok"
|
||||||
});
|
});
|
||||||
|
var emailSettings = Options.Create(new FuchsEmailSettings
|
||||||
|
{
|
||||||
|
OverrideRecipient = overrideRecipient
|
||||||
|
});
|
||||||
return new ProcessWebComService(
|
return new ProcessWebComService(
|
||||||
NullLogger<ProcessWebComService>.Instance,
|
NullLogger<ProcessWebComService>.Instance,
|
||||||
intranet: null!,
|
intranet: null!,
|
||||||
settings,
|
settings,
|
||||||
|
emailSettings,
|
||||||
new StubHttpClientFactory(handler));
|
new StubHttpClientFactory(handler));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +171,225 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.Equal(0, handler.CallCount);
|
Assert.Equal(0, handler.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audit logging: every attempt is logged; failures carry the service response ─
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
|
||||||
|
{
|
||||||
|
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||||
|
|
||||||
|
var entry = Assert.Single(svc.Entries);
|
||||||
|
Assert.True(entry.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||||
|
{
|
||||||
|
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||||
|
|
||||||
|
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
var entry = Assert.Single(svc.Entries);
|
||||||
|
Assert.False(entry.success);
|
||||||
|
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||||
|
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||||
|
{
|
||||||
|
var svc = CreateCapturing(new ThrowingHandler());
|
||||||
|
|
||||||
|
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
var entry = Assert.Single(svc.Entries);
|
||||||
|
Assert.False(entry.success);
|
||||||
|
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||||
|
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not-an-email")]
|
||||||
|
[InlineData("")]
|
||||||
|
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||||
|
{
|
||||||
|
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
var entry = Assert.Single(svc.Entries);
|
||||||
|
Assert.False(entry.success);
|
||||||
|
Assert.Contains(entry.log, l => l.Contains("Invalid recipient email address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||||
|
{
|
||||||
|
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||||
|
|
||||||
|
var entry = Assert.Single(svc.Entries);
|
||||||
|
Assert.False(entry.success);
|
||||||
|
Assert.Contains(entry.log, l => l.ToLowerInvariant().Contains("disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit-log parameters honour the NOT NULL columns of fds__emaillog ──────
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuditLogParameters_FailedAttempt_NeverPassesNullForNotNullColumns()
|
||||||
|
{
|
||||||
|
// fds__emaillog.config and .DateSent are NOT NULL; a failed/unset send must not send NULL
|
||||||
|
// (regression: it previously did, so every audit write failed and no email was ever logged).
|
||||||
|
var pl = ProcessWebComService.BuildAuditLogParameters(
|
||||||
|
"admin_test", guid: "", config: "", sent: default, success: false, errors: new[] { "err" });
|
||||||
|
|
||||||
|
object? config = pl.Single(p => p.ParameterName == "@config").Value;
|
||||||
|
object? dateSent = pl.Single(p => p.ParameterName == "@DateSent").Value;
|
||||||
|
|
||||||
|
Assert.Equal("", config);
|
||||||
|
Assert.NotEqual(DBNull.Value, config);
|
||||||
|
Assert.IsType<DateTime>(dateSent);
|
||||||
|
Assert.NotEqual((object)DBNull.Value, dateSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuditLogParameters_SuccessfulSend_KeepsProvidedSentTimestamp()
|
||||||
|
{
|
||||||
|
var when = new DateTime(2026, 7, 16, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var pl = ProcessWebComService.BuildAuditLogParameters(
|
||||||
|
"inv_1", guid: "g1", config: "", sent: when, success: true, errors: Array.Empty<string>());
|
||||||
|
|
||||||
|
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||||
|
|
||||||
|
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(1, handler.CallCount);
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||||
|
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
string subject = json["communication"]!["subject"]!.ToString();
|
||||||
|
Assert.Contains("realcustomer@example.de", subject);
|
||||||
|
Assert.Contains("Rechnung 123", subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||||
|
|
||||||
|
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(1, handler.CallCount);
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler);
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||||
|
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Override enforcement across every real appsettings*.json environment ───
|
||||||
|
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
||||||
|
// layers them (base file + optional environment-specific overlay). These files
|
||||||
|
// are copied into this test assembly's output directory via the Fuchs project
|
||||||
|
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content
|
||||||
|
// with CopyToOutputDirectory). This proves the safety net holds for whichever
|
||||||
|
// environment/appsettings file ends up "active" at runtime - not just a
|
||||||
|
// hand-typed literal - so a future edit that silently breaks the override key
|
||||||
|
// or its value in any appsettings*.json file would fail this test.
|
||||||
|
//
|
||||||
|
// Note: this API has a single "communication.to" field - there is no distinct
|
||||||
|
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||||
|
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
|
||||||
|
// satisfied by asserting that field no longer carries the original recipient
|
||||||
|
// once an override is configured.
|
||||||
|
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||||
|
{
|
||||||
|
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
|
||||||
|
foreach (var file in Directory.EnumerateFiles(AppContext.BaseDirectory, "appsettings.*.json"))
|
||||||
|
{
|
||||||
|
string[] parts = Path.GetFileName(file).Split('.');
|
||||||
|
if (parts.Length == 3 && parts[0] == "appsettings" && parts[2] == "json")
|
||||||
|
yield return new object[] { parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveOverrideRecipientForEnvironment(string environmentName)
|
||||||
|
{
|
||||||
|
var builder = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(AppContext.BaseDirectory)
|
||||||
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
|
||||||
|
if (!string.IsNullOrEmpty(environmentName))
|
||||||
|
builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false);
|
||||||
|
return builder.Build()["Fuchs:Email:OverrideRecipient"] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(AppSettingsEnvironments))]
|
||||||
|
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
|
||||||
|
{
|
||||||
|
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
|
||||||
|
|
||||||
|
const string originalRecipient = "realcustomer@tenant-owner.example";
|
||||||
|
bool result = await svc.SendEmailAsync(
|
||||||
|
"env_" + (string.IsNullOrEmpty(environmentName) ? "base" : environmentName),
|
||||||
|
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(1, handler.CallCount);
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||||
|
{
|
||||||
|
// Override configured for this environment: the real recipient (to/cc/bcc)
|
||||||
|
// must be fully cleared and replaced by the override - never leaked.
|
||||||
|
Assert.Equal(overrideRecipient, sentRecipient);
|
||||||
|
Assert.NotEqual(originalRecipient, sentRecipient);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No override configured for this environment: real recipient is used normally.
|
||||||
|
Assert.Equal(originalRecipient, sentRecipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Attachment payload contract ────────────────────────────────────────────
|
// ── Attachment payload contract ────────────────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||||
@@ -136,7 +404,7 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.NotNull(handler.LastRequestBody);
|
Assert.NotNull(handler.LastRequestBody);
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
var att = (JArray)json["attachments"]!;
|
var att = (JArray)json["communication"]!["attachments"]!;
|
||||||
Assert.Single(att);
|
Assert.Single(att);
|
||||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exhaustively exercises the pure reminder-draft aggregation/validation (ADR 0006,
|
||||||
|
/// the reminder mirror of <see cref="InvoiceDraftCalculatorTests"/>). Being static/pure,
|
||||||
|
/// the open-amount math and the plausibility checks are unit-testable without a DB.
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderDraftCalculatorTests
|
||||||
|
{
|
||||||
|
private static ReminderDraftSession Session(string newJson) =>
|
||||||
|
new() { New = JObject.Parse(newJson) };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{'amount':119,'amount_payed':0}", 119, 0, 119)]
|
||||||
|
[InlineData("{'amount':119,'amount_payed':20}", 119, 20, 99)]
|
||||||
|
[InlineData("{'amount':'119,50','amount_payed':'19,50'}", 119.50, 19.50, 100)] // German decimals
|
||||||
|
[InlineData("{'amount':'100.00','amount_payed':'40.00'}", 100, 40, 60)] // invariant decimals
|
||||||
|
[InlineData("{}", 0, 0, 0)] // missing → 0
|
||||||
|
[InlineData("{'amount':50,'amount_payed':80}", 50, 80, -30)] // overpaid → negative
|
||||||
|
public void RecomputeTotals_ComputesOpenAmount(string newJson, double total, double payed, double open)
|
||||||
|
{
|
||||||
|
var s = Session(newJson);
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
Assert.Equal((decimal)total, s.Sums.AmountTotal);
|
||||||
|
Assert.Equal((decimal)payed, s.Sums.AmountPayed);
|
||||||
|
Assert.Equal((decimal)open, s.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_EmptyEmail_Warns()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceaddress':'Weg 1','subject':'X'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("bad")]
|
||||||
|
[InlineData("no-at-sign.de")]
|
||||||
|
[InlineData("trailing@dot.")]
|
||||||
|
public void Validate_InvalidEmail_Errors(string email)
|
||||||
|
{
|
||||||
|
var s = Session($"{{'amount':119,'invoiceemail':'{email}','invoiceaddress':'Weg 1','subject':'X'}}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ValidEmail_NoEmailMessage()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'X'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.DoesNotContain(s.ValidationMessages, m => m.Field == "email");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_EmptyAddressAndSubject_Warn()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceemail':'a@b.de'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "subject" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{'amount':0,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||||
|
[InlineData("{'amount':50,'amount_payed':80,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||||
|
public void Validate_NonPositiveOpenAmount_Warns(string newJson)
|
||||||
|
{
|
||||||
|
var s = Session(newJson);
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "amount" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_HealthyDraft_HasNoMessages()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Empty(s.ValidationMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the reminder draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||||
|
/// without a database — the reminder mirror of <see cref="InvoiceDraftServiceTests"/>,
|
||||||
|
/// proving the backend-authoritative model behaves correctly at the service seam (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderDraftServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Captures the reminder handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||||
|
private sealed class FakeReminderService : IReminderService
|
||||||
|
{
|
||||||
|
public FdsReminderData? Registered;
|
||||||
|
public bool? LastChange;
|
||||||
|
public FdsReminderData? PreviewReminder;
|
||||||
|
public bool? PreviewDraft;
|
||||||
|
|
||||||
|
public Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Registered = reminder;
|
||||||
|
LastChange = change;
|
||||||
|
reminder.ReminderRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "REM42" });
|
||||||
|
return Task.FromResult(reminder);
|
||||||
|
}
|
||||||
|
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) { PreviewReminder = reminder; PreviewDraft = draft; return new Document(); }
|
||||||
|
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ReminderDraftEditService svc, FakeReminderService rem) NewService()
|
||||||
|
{
|
||||||
|
var cache = new ReminderDraftCache(new ConfigurationBuilder().Build());
|
||||||
|
var rem = new FakeReminderService();
|
||||||
|
var svc = new ReminderDraftEditService(cache, rem, NullLogger<ReminderDraftEditService>.Instance);
|
||||||
|
return (svc, rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject Payload() => JObject.Parse(@"{
|
||||||
|
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenFromPayload_SeedsSessionAndComputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||||
|
Assert.Equal(0, s.Version);
|
||||||
|
Assert.Equal(119m, s.Sums.AmountTotal);
|
||||||
|
Assert.Equal(119m, s.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("email", h.Target);
|
||||||
|
Assert.Equal("a@b.de", h.OldValue);
|
||||||
|
Assert.Equal("neu@x.de", h.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Amount_RecomputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||||
|
|
||||||
|
Assert.Equal(200m, s2!.Sums.AmountTotal);
|
||||||
|
Assert.Equal(200m, s2.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_AmountPayed_RecomputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||||
|
|
||||||
|
Assert.Equal(100m, s2!.Sums.AmountOpen); // 119 - 19
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_AmountFromGermanString_NormalisesToInvariant()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject("249,90") });
|
||||||
|
|
||||||
|
Assert.Equal("249.90", s2!.New["amount"]!.Value<string>()); // stored invariant
|
||||||
|
Assert.Equal(249.90m, s2.Sums.AmountTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Null(svc.ApplyPatch("ghost", new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownTarget_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
|
||||||
|
|
||||||
|
Assert.Equal(0, s2!.Version);
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("subject", "subject")]
|
||||||
|
[InlineData("address", "invoiceaddress")]
|
||||||
|
[InlineData("text", "text")]
|
||||||
|
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
|
||||||
|
|
||||||
|
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("subject", "subject")]
|
||||||
|
[InlineData("email", "invoiceemail")]
|
||||||
|
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("<p>clean me</p>") });
|
||||||
|
|
||||||
|
Assert.Equal("clean me", s2!.New[newKey]!.Value<string>());
|
||||||
|
Assert.DoesNotContain("<", s2.New[newKey]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||||
|
{
|
||||||
|
Target = "address",
|
||||||
|
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>40000 Düsseldorf</p>")
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("Firma AG\nWeg 1\n40000 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Contact_BuildsCustomValuesJson()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||||
|
{
|
||||||
|
Target = "contact",
|
||||||
|
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
|
||||||
|
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
|
||||||
|
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "subject", Value = JToken.FromObject("Mahnung 2") });
|
||||||
|
var s3 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||||
|
|
||||||
|
Assert.Equal(3, s3!.Version);
|
||||||
|
Assert.Equal(new[] { "email", "subject", "amount" }, s3.History.Select(h => h.Target).ToArray());
|
||||||
|
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildState_ExposesPayloadSumsValidationAndVersion()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||||
|
|
||||||
|
Assert.Equal(1, state["version"]!.Value<int>());
|
||||||
|
Assert.Equal(119m, state["sums"]!["amount_total"]!.Value<decimal>());
|
||||||
|
Assert.Equal(100m, state["sums"]!["amount_open"]!.Value<decimal>());
|
||||||
|
Assert.Equal(1, state["historyCount"]!.Value<int>());
|
||||||
|
Assert.NotNull(state["validation"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_RegistersAndSetsRemId_CreatePath()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("REM42", result!.Id);
|
||||||
|
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||||
|
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||||
|
// the email/subject the editor set must reach registration
|
||||||
|
Assert.Equal("a@b.de", rem.Registered!.RawInvoiceEmail);
|
||||||
|
Assert.Equal("Zahlungserinnerung", rem.Registered!.NewValues!.getString("subject"));
|
||||||
|
Assert.Equal("INV5", rem.Registered!.RawInvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_ExistingRemId_UpdatePath()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var payload = Payload();
|
||||||
|
payload["remid"] = "REM7";
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
|
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.True(rem.LastChange); // prior RemId → update path
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var doc = svc.RenderPreview(s.Token);
|
||||||
|
|
||||||
|
Assert.NotNull(doc);
|
||||||
|
Assert.True(rem.PreviewDraft);
|
||||||
|
Assert.True(rem.PreviewReminder!.IsDraft);
|
||||||
|
var reg = rem.PreviewReminder!.ReminderRegistration!;
|
||||||
|
Assert.Equal("Zahlungserinnerung", reg.getString("subject"));
|
||||||
|
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
|
||||||
|
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
|
||||||
|
Assert.Equal("R2026-1", reg.getString("InvoiceId"));
|
||||||
|
// the synthesised single-invoice row the reminder table renders
|
||||||
|
Assert.Single(rem.PreviewReminder!.ReminderItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Null(svc.RenderPreview("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHistory_UnknownToken_IsEmpty()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Empty(svc.GetHistory("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_RemovesSession_ThenReportsFalse()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
Assert.True(svc.Close(s.Token));
|
||||||
|
Assert.Null(svc.Get(s.Token));
|
||||||
|
Assert.False(svc.Close(s.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Fuchs.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="RequestValueHelper.Resolve"/>, which backs IntranetController's
|
||||||
|
/// Form()/HasForm() helpers. Endpoints in _allowedGet (e.g. req/idoc, rem/idoc) are invoked via
|
||||||
|
/// a plain GET (window.open with '?id=...'), so this must resolve from the query string without
|
||||||
|
/// ever touching an IFormCollection built from a non-form request (that would previously throw
|
||||||
|
/// InvalidOperationException in production - see Do() unhandled-exception log for fn=req id=idoc).
|
||||||
|
/// </summary>
|
||||||
|
public class RequestValueHelperTests
|
||||||
|
{
|
||||||
|
private static IFormCollection Form(params (string Key, string Value)[] pairs)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, StringValues>();
|
||||||
|
foreach (var (key, value) in pairs) dict[key] = value;
|
||||||
|
return new FormCollection(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryCollection Query(params (string Key, string Value)[] pairs)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, StringValues>();
|
||||||
|
foreach (var (key, value) in pairs) dict[key] = value;
|
||||||
|
return new QueryCollection(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_FormContentTypeWithKey_ReturnsFormValue()
|
||||||
|
{
|
||||||
|
string? result = RequestValueHelper.Resolve(
|
||||||
|
hasFormContentType: true,
|
||||||
|
form: Form(("id", "abc123")),
|
||||||
|
query: Query(("id", "from-query")),
|
||||||
|
key: "id");
|
||||||
|
|
||||||
|
Assert.Equal("abc123", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_NoFormContentType_FallsBackToQuery()
|
||||||
|
{
|
||||||
|
// Simulates a GET request opened via window.open('?id=...'): no Content-Type header,
|
||||||
|
// so the (empty) form collection must not be consulted - only the query string.
|
||||||
|
string? result = RequestValueHelper.Resolve(
|
||||||
|
hasFormContentType: false,
|
||||||
|
form: FormCollection.Empty,
|
||||||
|
query: Query(("id", "7O32P")),
|
||||||
|
key: "id");
|
||||||
|
|
||||||
|
Assert.Equal("7O32P", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_FormContentTypeButKeyMissingFromForm_FallsBackToQuery()
|
||||||
|
{
|
||||||
|
string? result = RequestValueHelper.Resolve(
|
||||||
|
hasFormContentType: true,
|
||||||
|
form: Form(("other", "value")),
|
||||||
|
query: Query(("id", "7O32P")),
|
||||||
|
key: "id");
|
||||||
|
|
||||||
|
Assert.Equal("7O32P", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_KeyMissingFromBoth_ReturnsNull()
|
||||||
|
{
|
||||||
|
string? result = RequestValueHelper.Resolve(
|
||||||
|
hasFormContentType: true,
|
||||||
|
form: Form(("other", "value")),
|
||||||
|
query: Query(("other", "value")),
|
||||||
|
key: "id");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_NoFormContentTypeAndQueryEmpty_ReturnsNull()
|
||||||
|
{
|
||||||
|
string? result = RequestValueHelper.Resolve(
|
||||||
|
hasFormContentType: false,
|
||||||
|
form: FormCollection.Empty,
|
||||||
|
query: QueryCollection.Empty,
|
||||||
|
key: "id");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
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 RunOnce_Disabled_PublishesNotRunReport()
|
||||||
|
{
|
||||||
|
var reporter = new StartupCheckReporter();
|
||||||
|
using var service = new TestableStartupSelfTestService(
|
||||||
|
new ServiceCollection().BuildServiceProvider(),
|
||||||
|
CreateConfiguration(),
|
||||||
|
Options.Create(CreateSettings(enabled: false)),
|
||||||
|
NullLogger<StartupSelfTestService>.Instance,
|
||||||
|
reporter);
|
||||||
|
|
||||||
|
await service.RunForTestAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(reporter.Latest);
|
||||||
|
Assert.False(reporter.Latest!.Ran);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
|
||||||
|
{
|
||||||
|
var reporter = new StartupCheckReporter();
|
||||||
|
using var service = new TestableStartupSelfTestService(
|
||||||
|
new ServiceCollection().BuildServiceProvider(),
|
||||||
|
CreateConfiguration(),
|
||||||
|
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
|
||||||
|
NullLogger<StartupSelfTestService>.Instance,
|
||||||
|
reporter)
|
||||||
|
{
|
||||||
|
KeyVaultResult = true,
|
||||||
|
DatabaseResult = false,
|
||||||
|
MailerResult = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.RunForTestAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var report = reporter.Latest;
|
||||||
|
Assert.NotNull(report);
|
||||||
|
Assert.True(report!.Ran);
|
||||||
|
Assert.NotNull(report.CompletedUtc);
|
||||||
|
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
|
||||||
|
Assert.True(kv.Enabled && kv.Ok);
|
||||||
|
var db = Assert.Single(report.Items, i => i.Name == "Database");
|
||||||
|
Assert.True(db.Enabled);
|
||||||
|
Assert.False(db.Ok); // intentionally-failing path
|
||||||
|
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
|
||||||
|
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,
|
||||||
|
StartupCheckReporter? reporter = null)
|
||||||
|
: base(serviceProvider, configuration, settings, logger, reporter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Admin module's read-only diagnostics service. Covers the passive config
|
||||||
|
/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's
|
||||||
|
/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live
|
||||||
|
/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding +
|
||||||
|
/// override reporting), and the emitted probe telemetry.
|
||||||
|
/// </summary>
|
||||||
|
public class SystemStatusServiceTests
|
||||||
|
{
|
||||||
|
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||||
|
|
||||||
|
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ConnectionStrings:fuchs_fds_ConnectionString"] =
|
||||||
|
"Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';",
|
||||||
|
["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;",
|
||||||
|
["Fuchs:IsTestDeployment"] = "true",
|
||||||
|
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
|
||||||
|
["Fds:MFR_UserName"] = "mfruser",
|
||||||
|
["Fds:SyncEnabled"] = "true",
|
||||||
|
["SecretManagement:VaultUri"] = "https://vault.example/",
|
||||||
|
["SecretManagement:AppName"] = "fuchs",
|
||||||
|
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token",
|
||||||
|
["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password",
|
||||||
|
};
|
||||||
|
if (overrides != null)
|
||||||
|
foreach (var kv in overrides) dict[kv.Key] = kv.Value;
|
||||||
|
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SystemStatusService Build(
|
||||||
|
IConfiguration? config = null,
|
||||||
|
Mock<IBlobStorageService>? blob = null,
|
||||||
|
Mock<IMfrClientFactory>? mfr = null,
|
||||||
|
Mock<IComService>? com = null,
|
||||||
|
ProcessWebComSettings? mailer = null,
|
||||||
|
FuchsEmailSettings? email = null,
|
||||||
|
AzureBlobStorageSettings? blobSettings = null,
|
||||||
|
IServiceProvider? provider = null,
|
||||||
|
StartupCheckReporter? startupChecks = null)
|
||||||
|
{
|
||||||
|
var env = new Mock<IHostEnvironment>();
|
||||||
|
env.SetupGet(e => e.EnvironmentName).Returns("Development");
|
||||||
|
|
||||||
|
return new SystemStatusService(
|
||||||
|
config ?? Config(),
|
||||||
|
env.Object,
|
||||||
|
provider ?? new ServiceCollection().BuildServiceProvider(),
|
||||||
|
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||||
|
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||||
|
(com ?? new Mock<IComService>()).Object,
|
||||||
|
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||||
|
Options.Create(email ?? new FuchsEmailSettings()),
|
||||||
|
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||||
|
startupChecks ?? new StartupCheckReporter(),
|
||||||
|
NullLogger<SystemStatusService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetInfo (passive snapshot) ─────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public void GetInfo_ParsesConnectionStringAndReflectsConfiguration()
|
||||||
|
{
|
||||||
|
var svc = Build();
|
||||||
|
|
||||||
|
SystemInfoSnapshot info = svc.GetInfo();
|
||||||
|
|
||||||
|
Assert.Equal("Development", info.Environment);
|
||||||
|
Assert.True(info.IsTestDeployment);
|
||||||
|
Assert.True(info.Database.Configured);
|
||||||
|
Assert.Equal("SQLHOST,1433", info.Database.Server);
|
||||||
|
Assert.Equal("site_fuchs_test", info.Database.Catalog);
|
||||||
|
Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password
|
||||||
|
Assert.True(info.Mfr.CredentialsConfigured);
|
||||||
|
Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host);
|
||||||
|
Assert.True(info.Mfr.SyncEnabled);
|
||||||
|
Assert.Equal("fuchs", info.KeyVault.AppName);
|
||||||
|
Assert.Equal(2, info.KeyVault.ManagedSecretCount);
|
||||||
|
Assert.False(info.KeyVault.ClientRegistered); // empty provider
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetInfo_NeverLeaksDatabasePassword()
|
||||||
|
{
|
||||||
|
var svc = Build();
|
||||||
|
|
||||||
|
SystemInfoSnapshot info = svc.GetInfo();
|
||||||
|
|
||||||
|
Assert.DoesNotContain("secret", info.Database.UserId ?? "");
|
||||||
|
Assert.DoesNotContain("secret", info.Database.Server ?? "");
|
||||||
|
Assert.DoesNotContain("secret", info.Database.Catalog ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet)
|
||||||
|
{
|
||||||
|
var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" };
|
||||||
|
var svc = Build(email: email);
|
||||||
|
|
||||||
|
SystemInfoSnapshot info = svc.GetInfo();
|
||||||
|
|
||||||
|
Assert.Equal(overrideSet, info.Email.OverrideActive);
|
||||||
|
Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient);
|
||||||
|
Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer
|
||||||
|
Assert.True(info.Email.TokenConfigured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetInfo_StartupChecks_NullWhenServiceHasNotRun()
|
||||||
|
{
|
||||||
|
var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet
|
||||||
|
|
||||||
|
Assert.Null(svc.GetInfo().StartupChecks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetInfo_StartupChecks_ReflectsReporterContents()
|
||||||
|
{
|
||||||
|
var reporter = new StartupCheckReporter();
|
||||||
|
reporter.Set(new StartupCheckReport
|
||||||
|
{
|
||||||
|
Ran = true,
|
||||||
|
CompletedUtc = DateTimeOffset.UtcNow,
|
||||||
|
MachineName = "BUILD-HOST",
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new StartupCheckItem { Name = "Database", Enabled = true, Ok = true },
|
||||||
|
new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
var svc = Build(startupChecks: reporter);
|
||||||
|
|
||||||
|
var report = svc.GetInfo().StartupChecks;
|
||||||
|
|
||||||
|
Assert.NotNull(report);
|
||||||
|
Assert.True(report!.Ran);
|
||||||
|
Assert.Equal("BUILD-HOST", report.MachineName);
|
||||||
|
Assert.Equal(2, report.Items.Count);
|
||||||
|
Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing()
|
||||||
|
{
|
||||||
|
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||||
|
var svc = Build(config);
|
||||||
|
|
||||||
|
Assert.False(svc.GetInfo().Database.Configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blob probe ─────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_Disabled_ReturnsDisabled()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("blob", r.Component);
|
||||||
|
Assert.Equal("disabled", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" });
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("unconfigured", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" });
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("ok", r.Status);
|
||||||
|
Assert.True(r.Ok);
|
||||||
|
Assert.Contains("acct", r.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity
|
||||||
|
{
|
||||||
|
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||||
|
Detail = "StorageV2 / Standard_LRS",
|
||||||
|
Containers = new List<BlobContainerCount>
|
||||||
|
{
|
||||||
|
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 },
|
||||||
|
new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("ok", r.Status);
|
||||||
|
Assert.NotNull(r.Metrics);
|
||||||
|
Assert.Equal(2, r.Metrics!.Count);
|
||||||
|
var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices");
|
||||||
|
Assert.Equal("42 Dateien", inv.Value);
|
||||||
|
var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders");
|
||||||
|
Assert.Equal("nicht vorhanden", rem.Value);
|
||||||
|
Assert.Contains("42 Dateien gesamt", r.Message); // total across containers
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity
|
||||||
|
{
|
||||||
|
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||||
|
Containers = new List<BlobContainerCount>
|
||||||
|
{
|
||||||
|
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("ok", r.Status);
|
||||||
|
var inv = Assert.Single(r.Metrics!);
|
||||||
|
Assert.Equal("Anzahl nicht ermittelbar", inv.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" });
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.Equal("error", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key Vault probe ────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
|
||||||
|
{
|
||||||
|
var svc = Build(); // empty service provider → no SecretClient
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("keyvault");
|
||||||
|
|
||||||
|
Assert.Equal("keyvault", r.Component);
|
||||||
|
Assert.Equal("unconfigured", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured()
|
||||||
|
{
|
||||||
|
var config = Config(new()
|
||||||
|
{
|
||||||
|
["SecretManagement:ManagedSecretKeys:0"] = null,
|
||||||
|
["SecretManagement:ManagedSecretKeys:1"] = null,
|
||||||
|
});
|
||||||
|
var svc = Build(config);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("keyvault");
|
||||||
|
|
||||||
|
Assert.Equal("unconfigured", r.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MFR probe ──────────────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeMfr_FactoryThrows_ReturnsError()
|
||||||
|
{
|
||||||
|
var mfr = new Mock<IMfrClientFactory>();
|
||||||
|
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials"));
|
||||||
|
var svc = Build(mfr: mfr);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("mfr");
|
||||||
|
|
||||||
|
Assert.Equal("mfr", r.Component);
|
||||||
|
Assert.Equal("error", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
Assert.Equal("no credentials", r.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Database probe (unconfigured is deterministic without a live server) ────
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured()
|
||||||
|
{
|
||||||
|
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||||
|
var svc = Build(config);
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("database");
|
||||||
|
|
||||||
|
Assert.Equal("database", r.Component);
|
||||||
|
Assert.Equal("unconfigured", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unknown component ──────────────────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_UnknownComponent_ReturnsError()
|
||||||
|
{
|
||||||
|
var svc = Build();
|
||||||
|
|
||||||
|
var r = await svc.ProbeAsync("does-not-exist");
|
||||||
|
|
||||||
|
Assert.Equal("error", r.Status);
|
||||||
|
Assert.False(r.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAllAsync_ReturnsOneResultPerComponent()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||||
|
var mfr = new Mock<IMfrClientFactory>();
|
||||||
|
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x"));
|
||||||
|
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||||
|
var svc = Build(config, blob: blob, mfr: mfr);
|
||||||
|
|
||||||
|
var results = await svc.ProbeAllAsync();
|
||||||
|
|
||||||
|
Assert.Equal(svc.ProbeComponents.Count, results.Count);
|
||||||
|
foreach (var comp in svc.ProbeComponents)
|
||||||
|
Assert.Contains(results, r => r.Component == comp);
|
||||||
|
Assert.All(results, r => Assert.True(r.DurationMs >= 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_EmitsProbeTelemetry()
|
||||||
|
{
|
||||||
|
var blob = new Mock<IBlobStorageService>();
|
||||||
|
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||||
|
var svc = Build(blob: blob);
|
||||||
|
|
||||||
|
long delta = 0;
|
||||||
|
using var listener = new MeterListener
|
||||||
|
{
|
||||||
|
InstrumentPublished = (inst, l) =>
|
||||||
|
{
|
||||||
|
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes")
|
||||||
|
l.EnableMeasurementEvents(inst);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
await svc.ProbeAsync("blob");
|
||||||
|
|
||||||
|
Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test email ─────────────────────────────────────────────────────────────
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "subj", "body")]
|
||||||
|
[InlineData("to@example.test", "", "body")]
|
||||||
|
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body)
|
||||||
|
{
|
||||||
|
var com = new Mock<IComService>();
|
||||||
|
var svc = Build(com: com);
|
||||||
|
|
||||||
|
var r = await svc.SendTestEmailAsync(to, subject, body);
|
||||||
|
|
||||||
|
Assert.False(r.Sent);
|
||||||
|
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient()
|
||||||
|
{
|
||||||
|
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
|
||||||
|
var com = new Mock<IComService>();
|
||||||
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||||
|
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
|
||||||
|
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
var svc = Build(com: com);
|
||||||
|
|
||||||
|
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1<script>\nZeile2");
|
||||||
|
|
||||||
|
Assert.True(r.Sent);
|
||||||
|
Assert.Equal("dest@example.test", capturedTo);
|
||||||
|
Assert.Equal("Betreff", capturedSubject);
|
||||||
|
Assert.NotNull(capturedHtml);
|
||||||
|
Assert.Contains("<script>", capturedHtml); // HTML-encoded, not injected
|
||||||
|
Assert.DoesNotContain("<script>", capturedHtml);
|
||||||
|
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
|
||||||
|
Assert.Null(r.OverrideRecipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||||
|
{
|
||||||
|
var com = new Mock<IComService>();
|
||||||
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
|
||||||
|
|
||||||
|
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||||
|
|
||||||
|
Assert.True(r.Sent);
|
||||||
|
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||||
|
Assert.Equal("safety@example.test", r.OverrideRecipient);
|
||||||
|
Assert.Contains("safety@example.test", r.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
|
||||||
|
{
|
||||||
|
var com = new Mock<IComService>();
|
||||||
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
var svc = Build(com: com);
|
||||||
|
|
||||||
|
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||||
|
|
||||||
|
Assert.False(r.Sent);
|
||||||
|
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using OCORE.SQL;
|
||||||
|
using static OCORE.SQL.sql;
|
||||||
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
// Partial class: Admin / system-status module.
|
||||||
|
//
|
||||||
|
// Access is restricted to users whose "fds_sys" module authorization is greater than 4.
|
||||||
|
// The menu button is only shown, and the module script only loaded, for such users (frontend),
|
||||||
|
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
|
||||||
|
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
|
||||||
|
public partial class IntranetController
|
||||||
|
{
|
||||||
|
// fds_sys authorization must exceed this to use the Admin module.
|
||||||
|
private const int AdminMinAuthExclusive = 4;
|
||||||
|
|
||||||
|
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Do_Process_Admin action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||||
|
|
||||||
|
// The auth probe is the one endpoint that answers for BOTH authorized and unauthorized
|
||||||
|
// users (the frontend uses manage>0 to decide whether to render the module at all).
|
||||||
|
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||||
|
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||||
|
|
||||||
|
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||||
|
|
||||||
|
if (!authorized)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Admin access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||||
|
UserAccountID, authLevel, id);
|
||||||
|
return Unauthorized401();
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = _systemStatus;
|
||||||
|
switch (id.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "status":
|
||||||
|
{
|
||||||
|
var info = status.GetInfo();
|
||||||
|
var probes = await status.ProbeAllAsync(HttpContext.RequestAborted);
|
||||||
|
return AdminJson(new { info, probes });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "info":
|
||||||
|
return AdminJson(new { info = status.GetInfo() });
|
||||||
|
|
||||||
|
case "probe":
|
||||||
|
{
|
||||||
|
// code carries the component id, e.g. /do/admin/probe/database
|
||||||
|
string component = string.IsNullOrWhiteSpace(code) ? Form("component") : code;
|
||||||
|
if (string.IsNullOrWhiteSpace(component))
|
||||||
|
return BadRequest400();
|
||||||
|
var probe = await status.ProbeAsync(component, HttpContext.RequestAborted);
|
||||||
|
return AdminJson(new { probe });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "testmail":
|
||||||
|
{
|
||||||
|
if (!HasForm("to", "subject"))
|
||||||
|
return BadRequest400();
|
||||||
|
var result = await status.SendTestEmailAsync(
|
||||||
|
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
|
||||||
|
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
|
||||||
|
UserAccountID, result.RequestedRecipient, result.Sent);
|
||||||
|
return AdminJson(new { result });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
|
||||||
|
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
|
||||||
|
private static readonly JsonSerializerSettings CamelCaseJson = new()
|
||||||
|
{
|
||||||
|
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirror OCORE's getJSONResult exactly (application/json; charset=utf-8) so the response
|
||||||
|
// parses identically to every other endpoint the frontend's postXT talks to.
|
||||||
|
private ContentResult AdminJson(object payload) =>
|
||||||
|
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json; charset=utf-8");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
|
||||||
|
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
|
||||||
|
/// Returns -3 when it cannot be determined (fail-closed).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
var val = await getSQLValue_async<int>(
|
||||||
|
"SELECT [dbo].[fis_getModuleAuth](@module, @authuser);",
|
||||||
|
_intranet.Intranet__SQLConnectionString, -3,
|
||||||
|
StdParamlist(SQL_VarChar("@module", "fds_sys")),
|
||||||
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
return val.Result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ public partial class IntranetController
|
|||||||
return await JSONAsync(new { manage = 1 });
|
return await JSONAsync(new { manage = 1 });
|
||||||
|
|
||||||
case "up":
|
case "up":
|
||||||
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
|
_logger.LogInformation("Banking statement upload: {FileCount} file(s) user={User}",
|
||||||
Request.Form.Files.Count, UserAccountID);
|
Request.Form.Files.Count, UserAccountID);
|
||||||
|
var uploadResults = new List<object>();
|
||||||
foreach (var fle in Request.Form.Files)
|
foreach (var fle in Request.Form.Files)
|
||||||
{
|
{
|
||||||
using var stream = fle.OpenReadStream();
|
using var stream = fle.OpenReadStream();
|
||||||
@@ -34,6 +35,9 @@ public partial class IntranetController
|
|||||||
|
|
||||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||||
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
||||||
|
var (importFrom, importTo) = BankingDateRange(tbl);
|
||||||
|
bool importFailed = false;
|
||||||
|
string importFailure = "";
|
||||||
|
|
||||||
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
||||||
{
|
{
|
||||||
@@ -48,16 +52,80 @@ public partial class IntranetController
|
|||||||
dtwa.CommandAfterError = new SqlCommand(
|
dtwa.CommandAfterError = new SqlCommand(
|
||||||
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
|
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
|
||||||
dtwa.OnError += (_, exc, _) =>
|
dtwa.OnError += (_, exc, _) =>
|
||||||
|
{
|
||||||
|
importFailed = true;
|
||||||
|
importFailure = exc.Message;
|
||||||
|
_logger.LogError(exc,
|
||||||
|
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
|
||||||
|
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||||
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
||||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||||
|
};
|
||||||
dtwa.OnCommandAfterError += (_, exc) =>
|
dtwa.OnCommandAfterError += (_, exc) =>
|
||||||
|
{
|
||||||
|
importFailed = true;
|
||||||
|
importFailure = exc.Message;
|
||||||
|
_logger.LogError(exc,
|
||||||
|
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
|
||||||
|
"rescueTable={RescueTable} user={User}",
|
||||||
|
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
|
||||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||||
|
};
|
||||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||||
tbl.Rows.Count, UserAccountID);
|
tbl.Rows.Count, UserAccountID);
|
||||||
dtwa.DoSubmit();
|
dtwa.DoSubmit();
|
||||||
|
if (dtwa.SubmitException != null)
|
||||||
|
{
|
||||||
|
importFailed = true;
|
||||||
|
importFailure = dtwa.SubmitException.Message;
|
||||||
|
_logger.LogError(dtwa.SubmitException,
|
||||||
|
"Banking upload submit exception — file={File} destTable={DestTable} user={User}",
|
||||||
|
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importFailed)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Banking import failed — file={File} rows={Rows} reason={Reason} user={User}",
|
||||||
|
fle.FileName, tbl.Rows.Count, importFailure, UserAccountID);
|
||||||
|
await _events.BankingImportIssueAsync(
|
||||||
|
$"Kontobewegungen aus {fle.FileName} konnten nicht importiert werden: {importFailure}",
|
||||||
|
fle.FileName, UserAccountID);
|
||||||
|
}
|
||||||
|
else if (tbl.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
// Parsing produced zero rows — check the preceding "Bank statement parsed"
|
||||||
|
// warning from BankingService for the reason (missing account element,
|
||||||
|
// unsupported schema variant, empty file, ...).
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Banking import: 0 rows parsed from {File} — nothing to import. user={User}",
|
||||||
|
fle.FileName, UserAccountID);
|
||||||
|
await _events.BankingImportIssueAsync(
|
||||||
|
$"Aus {fle.FileName} konnten keine Kontobewegungen importiert werden.",
|
||||||
|
fle.FileName, UserAccountID);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Banking import succeeded — file={File} rows={Rows} from={From} to={To} user={User}",
|
||||||
|
fle.FileName, tbl.Rows.Count, importFrom, importTo, UserAccountID);
|
||||||
|
await _events.BankingTransactionsImportedAsync(
|
||||||
|
importFrom, importTo, tbl.Rows.Count, fle.FileName, UserAccountID);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResults.Add(new
|
||||||
|
{
|
||||||
|
fileName = fle.FileName,
|
||||||
|
rows = tbl.Rows.Count,
|
||||||
|
success = !importFailed && tbl.Rows.Count > 0,
|
||||||
|
error = importFailed ? importFailure : ""
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Ok();
|
// Return a JSON body: the frontend posts with dataType 'json', so an
|
||||||
|
// empty 200 would be reported as a parse error and surface the generic
|
||||||
|
// "auth failed" alert even though the import actually succeeded.
|
||||||
|
return await JSONAsync(new { ok = true, files = uploadResults });
|
||||||
|
|
||||||
case "qtl":
|
case "qtl":
|
||||||
{
|
{
|
||||||
@@ -122,11 +190,21 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
if (!HasForm("taid")) return BadRequest400();
|
if (!HasForm("taid")) return BadRequest400();
|
||||||
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
||||||
var res = await getSQLValue_async(
|
var res = await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
var row = res.FirstRow;
|
||||||
|
bool success = row["success"] is true;
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
DateTime? valueDate = row["ValueDate"] == DBNull.Value ? null : (DateTime)row["ValueDate"];
|
||||||
|
decimal? amount = row["Amount"] == DBNull.Value ? null : (decimal)row["Amount"];
|
||||||
|
await _events.BankingTransactionMarkedDoneAsync(Form("taid"), valueDate, amount, UserAccountID);
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
? await JSONAsync(new { ok = true })
|
||||||
|
: StatusCode(500, new { error = "not successful" });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ati":
|
case "ati":
|
||||||
@@ -139,7 +217,55 @@ public partial class IntranetController
|
|||||||
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
|
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
return res.Result is true
|
||||||
|
? await JSONAsync(new { ok = true })
|
||||||
|
: StatusCode(500, new { error = "not successful" });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "man":
|
||||||
|
{
|
||||||
|
var pl = StdParamlist(
|
||||||
|
SQL_VarChar("@taID", Form("taID"), dbNull_IfEmpty: true),
|
||||||
|
SQL_Date("@ValueDate", Form("ValueDate")),
|
||||||
|
SQL_Decimal("@Amount", Form("Amount"), precision: 9, scale: 2),
|
||||||
|
SQL_NVarChar("@NameOfPayer", Form("NameOfPayer"), dbNull_IfEmpty: true),
|
||||||
|
SQL_VarChar("@SepaRemittanceInformation", Form("SepaRemittanceInformation"), dbNull_IfEmpty: true));
|
||||||
|
await getSQLDatatable_async(
|
||||||
|
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
|
||||||
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Manual banking transaction upserted taID={TaID} user={User}", Form("taID"), UserAccountID);
|
||||||
|
return await JSONAsync(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mget":
|
||||||
|
{
|
||||||
|
if (!HasForm("taid")) return BadRequest400();
|
||||||
|
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
||||||
|
var res = await getSQLDatatable_async(
|
||||||
|
"EXECUTE [dbo].[fds__getBankingtransaction_manual] @taID, @authuser;",
|
||||||
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
return await JSONAsync(res.FirstRow.toObjectDictionary());
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mdel":
|
||||||
|
{
|
||||||
|
if (!HasForm("taid")) return BadRequest400();
|
||||||
|
var pl = StdParamlist(
|
||||||
|
SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true),
|
||||||
|
SQL_Date("@ValueDate", (string?)null),
|
||||||
|
SQL_Decimal("@Amount", (string?)null, precision: 9, scale: 2),
|
||||||
|
SQL_NVarChar("@NameOfPayer", null, dbNull_IfEmpty: true),
|
||||||
|
SQL_VarChar("@SepaRemittanceInformation", null, dbNull_IfEmpty: true));
|
||||||
|
await getSQLDatatable_async(
|
||||||
|
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
|
||||||
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Manual banking transaction deleted taID={TaID} user={User}", Form("taid"), UserAccountID);
|
||||||
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vfi":
|
case "vfi":
|
||||||
@@ -155,14 +281,47 @@ public partial class IntranetController
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Form helpers ─────────────────────────────────────────────────────────
|
// ── Form helpers ─────────────────────────────────────────────────────────
|
||||||
|
// Reads from the posted form when available, falling back to the query string. This
|
||||||
|
// supports endpoints in _allowedGet (e.g. req/idoc, rem/idoc) that are invoked via a
|
||||||
|
// plain GET (window.open with '?id=...'), where Request.Form has no Content-Type and
|
||||||
|
// would otherwise throw InvalidOperationException.
|
||||||
protected bool HasForm(params string[] keys) =>
|
protected bool HasForm(params string[] keys) =>
|
||||||
keys.All(k => Request.Form.ContainsKey(k) && !string.IsNullOrWhiteSpace(Request.Form[k]));
|
keys.All(k => !string.IsNullOrWhiteSpace(FormValue(k)));
|
||||||
|
|
||||||
protected string Form(string key, string fallback = "") =>
|
protected string Form(string key, string fallback = "") =>
|
||||||
Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback;
|
FormValue(key) ?? fallback;
|
||||||
|
|
||||||
|
private string? FormValue(string key) =>
|
||||||
|
RequestValueHelper.Resolve(
|
||||||
|
Request.HasFormContentType,
|
||||||
|
Request.HasFormContentType ? Request.Form : Microsoft.AspNetCore.Http.FormCollection.Empty,
|
||||||
|
Request.Query,
|
||||||
|
key);
|
||||||
|
|
||||||
|
private static (DateTime? From, DateTime? To) BankingDateRange(System.Data.DataTable tbl)
|
||||||
|
{
|
||||||
|
DateTime? from = null;
|
||||||
|
DateTime? to = null;
|
||||||
|
foreach (System.Data.DataRow row in tbl.Rows)
|
||||||
|
{
|
||||||
|
DateTime? date = BankingRowDate(row, tbl.Columns.Contains("EntryDate") ? "EntryDate" : "")
|
||||||
|
?? BankingRowDate(row, tbl.Columns.Contains("ValueDate") ? "ValueDate" : "");
|
||||||
|
if (date == null) continue;
|
||||||
|
from = from == null || date.Value < from.Value ? date.Value : from;
|
||||||
|
to = to == null || date.Value > to.Value ? date.Value : to;
|
||||||
|
}
|
||||||
|
return (from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? BankingRowDate(System.Data.DataRow row, string column)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(column) || row[column] == DBNull.Value) return null;
|
||||||
|
if (row[column] is DateTime dt) return dt.Date;
|
||||||
|
return DateTime.TryParse(row[column]?.ToString(), out var parsed) ? parsed.Date : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
// Partial class: live, backend-authoritative invoice draft editing (ADR 0006).
|
||||||
|
// The browser posts single edits here; the server mutates the in-memory session
|
||||||
|
// (the source of truth), recomputes/validates, and pings the editing browser over
|
||||||
|
// SignalR (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries
|
||||||
|
// only signals (ADR 0007).
|
||||||
|
public partial class IntranetController
|
||||||
|
{
|
||||||
|
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||||
|
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||||
|
|
||||||
|
// POST inv/dopen — { payload } → { token, version }
|
||||||
|
// The editor assembles the initial draft (from a service request or a reloaded DB draft
|
||||||
|
// via the existing render paths) and seeds the authoritative session here. Reload/discard
|
||||||
|
// is the client re-fetching + re-seeding, so there is no server-side DB reshaping.
|
||||||
|
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("payload"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
JObject payload;
|
||||||
|
try { payload = JObject.Parse(Form("payload")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
var session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
||||||
|
_logger.LogInformation("Draft dopen: session {Token} (invId={InvId}) user={User}", session.Token, session.InvId, UserAccountID);
|
||||||
|
// The browser holds the token from this response and fetches dstate directly; there is
|
||||||
|
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
|
||||||
|
// only subsequent server-side changes.
|
||||||
|
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dstate — { token } → full view state
|
||||||
|
private async Task<IActionResult> HandleDraftState(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var session = _invoiceDrafts.Get(Form("token"));
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
return await JSONAsync(_invoiceDrafts.BuildState(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||||
|
private async Task<IActionResult> HandleDraftPatch(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token", "delta")) return BadRequest400();
|
||||||
|
InvoiceDraftDelta? delta;
|
||||||
|
try { delta = JsonConvert.DeserializeObject<InvoiceDraftDelta>(Form("delta")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||||
|
|
||||||
|
var session = _invoiceDrafts.ApplyPatch(Form("token"), delta);
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||||
|
return await JSONAsync(new { ok = true, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||||
|
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var doc = _invoiceDrafts.RenderPreview(Form("token"));
|
||||||
|
if (doc == null) return DraftGone();
|
||||||
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||||
|
private async Task<IActionResult> HandleDraftSave(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
string token = Form("token");
|
||||||
|
var before = _invoiceDrafts.Get(token);
|
||||||
|
if (before == null) return DraftGone();
|
||||||
|
bool existed = !string.IsNullOrEmpty(before.InvId);
|
||||||
|
|
||||||
|
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (fdInv == null) return DraftGone();
|
||||||
|
if (string.IsNullOrEmpty(fdInv.Id))
|
||||||
|
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
|
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
||||||
|
var after = _invoiceDrafts.Get(token);
|
||||||
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
|
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dhistory — { token } → { history[] }
|
||||||
|
private async Task<IActionResult> HandleDraftHistory(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
if (_invoiceDrafts.Get(Form("token")) == null) return DraftGone();
|
||||||
|
var history = _invoiceDrafts.GetHistory(Form("token"))
|
||||||
|
.Select(h => new
|
||||||
|
{
|
||||||
|
timestamp = h.TimestampUtc,
|
||||||
|
target = h.Target,
|
||||||
|
@ref = h.Ref,
|
||||||
|
oldValue = h.OldValue,
|
||||||
|
newValue = h.NewValue,
|
||||||
|
version = h.Version
|
||||||
|
});
|
||||||
|
return await JSONAsync(new { history });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST inv/dclose — { token } → { ok }
|
||||||
|
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
bool ok = _invoiceDrafts.Close(Form("token"));
|
||||||
|
_logger.LogDebug("Draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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":
|
||||||
@@ -86,8 +86,15 @@ public partial class IntranetController
|
|||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
if (!string.IsNullOrEmpty(dt2.Exception))
|
if (!string.IsNullOrEmpty(dt2.Exception))
|
||||||
|
{
|
||||||
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
|
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
|
||||||
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
|
await _events.InvoiceIssueAsync(
|
||||||
|
$"Rechnung {invoiceId} konnte nicht als versandt markiert werden.",
|
||||||
|
UserAccountID, invoiceId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID);
|
||||||
|
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "pget":
|
case "pget":
|
||||||
@@ -149,11 +156,20 @@ 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 });
|
||||||
|
|
||||||
|
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||||
|
case "dopen": return await HandleDraftOpen(fn, id, code);
|
||||||
|
case "dstate": return await HandleDraftState(fn, id, code);
|
||||||
|
case "dpatch": return await HandleDraftPatch(fn, id, code);
|
||||||
|
case "dpreview": return await HandleDraftPreview(fn, id, code);
|
||||||
|
case "dsave": return await HandleDraftSave(fn, id, code);
|
||||||
|
case "dhistory": return await HandleDraftHistory(fn, id, code);
|
||||||
|
case "dclose": return await HandleDraftClose(fn, id, code);
|
||||||
|
|
||||||
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)
|
||||||
@@ -78,16 +78,30 @@ public partial class IntranetController
|
|||||||
var ldic = BuildInvoiceRequestList(sqldset);
|
var ldic = BuildInvoiceRequestList(sqldset);
|
||||||
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
||||||
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
|
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
|
||||||
bool has13b = invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b");
|
string invoiceOptions = invDic.nz("InvoiceOptions", "");
|
||||||
|
bool has13b = invoiceOptions.Split(',').Contains("§13b");
|
||||||
if (has13b)
|
if (has13b)
|
||||||
adminDic["p13b"] = true;
|
adminDic["p13b"] = true;
|
||||||
|
// Carry the persisted set-pricing mode forward (see InvoiceSetPricing.cs / FdsInvoiceData.BuildInvoiceOptions):
|
||||||
|
// needed so the editor knows the invoice has already been explicitly switched to a set-display
|
||||||
|
// mode (including the default "setprice") and hides the "Set-Preisanzeige" menu entry accordingly.
|
||||||
|
string? setmodeToken = invoiceOptions.Split(',')
|
||||||
|
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (setmodeToken != null)
|
||||||
|
adminDic["setmode"] = setmodeToken["setmode:".Length..].Trim().ToLowerInvariant();
|
||||||
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
|
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
|
||||||
invoiceId, ldic.Count, has13b, UserAccountID);
|
invoiceId, ldic.Count, has13b, UserAccountID);
|
||||||
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", Form("id"), UserAccountID);
|
string invoiceId = Form("id");
|
||||||
|
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||||
|
// This handler has its own catch (returns 500) and so never reaches the Do safety net;
|
||||||
|
// notify the user here so a failed invoice load is not silently swallowed.
|
||||||
|
await _events.InvoiceIssueAsync(
|
||||||
|
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
|
||||||
|
UserAccountID, invoiceId);
|
||||||
return StatusCode(500);
|
return StatusCode(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,8 +402,22 @@ public partial class IntranetController
|
|||||||
sqldset.Tables("itm").Columns.Contains("order") ? "order" : ""))
|
sqldset.Tables("itm").Columns.Contains("order") ? "order" : ""))
|
||||||
{
|
{
|
||||||
var d = sitm.toObjectDictionary();
|
var d = sitm.toObjectDictionary();
|
||||||
double net = Convert.ToDouble(d.no("value_total", 0));
|
double net = Convert.ToDouble(d.no("value_total", 0));
|
||||||
double vat = Convert.ToDouble(d.no("vat", 0));
|
double vat = Convert.ToDouble(d.no("vat", 0));
|
||||||
|
double value = Convert.ToDouble(d.no("value", 0));
|
||||||
|
string quantityStr = d.nz("Quantity");
|
||||||
|
// quantityhours/UnitString reconstruct the hour-based quantity editor's raw
|
||||||
|
// fields (e.g. "5 Std" -> quantityhours=5, UnitString="Std") from the persisted
|
||||||
|
// "Quantity" string, mirroring the legacy fds__invoice_data ndic mapping — without
|
||||||
|
// this, re-editing a reloaded hour-based item showed an empty quantity field.
|
||||||
|
object quantityHours = "";
|
||||||
|
if (value != 0 && !string.IsNullOrEmpty(quantityStr))
|
||||||
|
{
|
||||||
|
long qh = (long)(net / value);
|
||||||
|
if (quantityStr.StartsWith(qh.ToString(CultureInfo.InvariantCulture) + " ", StringComparison.Ordinal))
|
||||||
|
quantityHours = qh;
|
||||||
|
}
|
||||||
|
string unitString = !string.IsNullOrEmpty(quantityStr) ? quantityStr.RightFromFirst(" ") : "";
|
||||||
itm.Add(new Dictionary<string, object?>
|
itm.Add(new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = d["Id"],
|
["Id"] = d["Id"],
|
||||||
@@ -399,8 +427,11 @@ public partial class IntranetController
|
|||||||
["svcnet_val"] = d.no("value_service", 0),
|
["svcnet_val"] = d.no("value_service", 0),
|
||||||
["net"] = d.no("value", 0),
|
["net"] = d.no("value", 0),
|
||||||
["quantity"] = d["Quantity"],
|
["quantity"] = d["Quantity"],
|
||||||
|
["quantityhours"] = quantityHours,
|
||||||
|
["UnitString"] = unitString,
|
||||||
["Type"] = d["Type"],
|
["Type"] = d["Type"],
|
||||||
["Note"] = null,
|
["Note"] = null,
|
||||||
|
["NameOrNumber"] = "",
|
||||||
["htmltext"] = d["Text"],
|
["htmltext"] = d["Text"],
|
||||||
["position"] = d["Position"],
|
["position"] = d["Position"],
|
||||||
["SortOrder"] = d["SortOrder"]
|
["SortOrder"] = d["SortOrder"]
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
case "get":
|
case "get":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("Reminder get: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogDebug("Reminder get: preparing reminder for invoice {InvId} type={Type} level={Level} user={User}",
|
||||||
|
Form("id"), Form("type"), Form("level"), UserAccountID);
|
||||||
var pl = StdParamlist(
|
var pl = StdParamlist(
|
||||||
SQL_VarChar("@InvId", Form("id")),
|
SQL_VarChar("@InvId", Form("id")),
|
||||||
SQL_VarChar("@type", Form("type")),
|
SQL_VarChar("@type", Form("type")),
|
||||||
@@ -32,41 +34,61 @@ public partial class IntranetController
|
|||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
tablenames: new[] { "rem" },
|
tablenames: new[] { "rem" },
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
if (!string.IsNullOrEmpty(dset.Exception))
|
||||||
|
_logger.LogError("Reminder get: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
|
||||||
return await JSONAsync(new { rm = dset.Table("rem").FirstRow.toObjectDictionary() });
|
return await JSONAsync(new { rm = dset.Table("rem").FirstRow.toObjectDictionary() });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "prep":
|
case "prep":
|
||||||
{
|
{
|
||||||
if (!HasForm("remc")) return BadRequest400();
|
if (!HasForm("remc")) { _logger.LogWarning("Reminder prep: missing form field 'remc', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Reminder prep: creating draft reminder, user={User}", UserAccountID);
|
||||||
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
|
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
|
||||||
var fdRem = await _reminders.RegisterReminderAsync(
|
var fdRem = await _reminders.RegisterReminderAsync(
|
||||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||||
if (!string.IsNullOrEmpty(fdRem.Id))
|
if (!string.IsNullOrEmpty(fdRem.Id))
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Reminder prep: draft reminder {RemId} created, user={User}", fdRem.Id, UserAccountID);
|
||||||
|
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" });
|
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "conf": return await HandleReminderConf(fn, id, code);
|
case "conf": return await HandleReminderConf(fn, id, code);
|
||||||
|
|
||||||
case "srs":
|
case "srs":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("Reminder srs: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Reminder srs: marking reminder {RemId} as sent, user={User}", Form("id"), UserAccountID);
|
||||||
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@auto", false));
|
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@auto", false));
|
||||||
var dt2 = await getSQLDataSet_async(
|
var dt2 = await getSQLDataSet_async(
|
||||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
|
if (string.IsNullOrEmpty(dt2.Exception))
|
||||||
|
await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Reminder srs: SQL error marking reminder {RemId} sent: {SqlError}, user={User}", Form("id"), dt2.Exception, UserAccountID);
|
||||||
|
await _events.ReminderIssueAsync(
|
||||||
|
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
|
||||||
|
UserAccountID, Form("id"));
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "rdoc":
|
case "rdoc":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("Reminder rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogDebug("Reminder rdoc: fetching stored reminder document {RemId} typ={Typ} user={User}", Form("id"), Form("typ"), UserAccountID);
|
||||||
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
|
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
|
||||||
if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
if (file == null || fc == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||||
|
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||||
|
}
|
||||||
return Form("typ") != "img"
|
return Form("typ") != "img"
|
||||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
||||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
||||||
@@ -75,15 +97,27 @@ public partial class IntranetController
|
|||||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||||
case "resend": return await HandleReminderResend(fn, id, code);
|
case "resend": return await HandleReminderResend(fn, id, code);
|
||||||
|
|
||||||
|
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||||
|
case "dopen": return await HandleReminderDraftOpen(fn, id, code);
|
||||||
|
case "dstate": return await HandleReminderDraftState(fn, id, code);
|
||||||
|
case "dpatch": return await HandleReminderDraftPatch(fn, id, code);
|
||||||
|
case "dpreview": return await HandleReminderDraftPreview(fn, id, code);
|
||||||
|
case "dsave": return await HandleReminderDraftSave(fn, id, code);
|
||||||
|
case "dhistory": return await HandleReminderDraftHistory(fn, id, code);
|
||||||
|
case "dclose": return await HandleReminderDraftClose(fn, id, code);
|
||||||
|
|
||||||
case "lrem":
|
case "lrem":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogDebug("Reminder lrem: listing reminders for invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||||
var dset = await getSQLDataSet_async(
|
var dset = await getSQLDataSet_async(
|
||||||
"EXECUTE [dbo].[fds__lookupReminders] @InvId, @authuser;",
|
"EXECUTE [dbo].[fds__lookupReminders] @InvId, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
|
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
|
||||||
tablenames: new[] { "ov", "rem" },
|
tablenames: new[] { "ov", "rem" },
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
|
if (!string.IsNullOrEmpty(dset.Exception))
|
||||||
|
_logger.LogError("Reminder lrem: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
|
||||||
return await JSONAsync(new
|
return await JSONAsync(new
|
||||||
{
|
{
|
||||||
ov = dset.Table("ov").FirstRow.toStringDictionary(),
|
ov = dset.Table("ov").FirstRow.toStringDictionary(),
|
||||||
@@ -91,18 +125,23 @@ public partial class IntranetController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
default: return Ok();
|
default:
|
||||||
|
_logger.LogWarning("Do_Process_Reminder: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleReminderConf(string fn, string id, string code)
|
private async Task<IActionResult> HandleReminderConf(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("HandleReminderConf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("HandleReminderConf: finalizing reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||||
var dt = await getSQLDatatable_async(
|
var dt = await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__setReminderFinal] @Id, @authuser;",
|
"EXECUTE [dbo].[fds__setReminderFinal] @Id, @authuser;",
|
||||||
_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));
|
||||||
|
if (!string.IsNullOrEmpty(dt.Exception))
|
||||||
|
_logger.LogError("HandleReminderConf: SQL error finalizing reminder {RemId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||||
var frdic = dt.FirstRow.toObjectDictionary();
|
var frdic = dt.FirstRow.toObjectDictionary();
|
||||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||||
{
|
{
|
||||||
@@ -127,23 +166,43 @@ public partial class IntranetController
|
|||||||
email.Trim(), "", remdoc);
|
email.Trim(), "", remdoc);
|
||||||
if (sent)
|
if (sent)
|
||||||
{
|
{
|
||||||
|
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID);
|
||||||
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
||||||
await getSQLDatatable_async(
|
await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pls,
|
_intranet.Intranet__SQLConnectionString, pls,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Reminder email send failed — reminderId={ReminderId} email={Email} user={User}",
|
||||||
|
remId, email.Trim(), UserAccountID);
|
||||||
|
await _events.ReminderIssueAsync(
|
||||||
|
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||||
|
UserAccountID, remId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
else if (filebyte.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Reminder PDF render returned 0 bytes — reminderId={ReminderId} user={User}",
|
||||||
|
remId, UserAccountID);
|
||||||
|
await _events.ReminderIssueAsync(
|
||||||
|
$"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.",
|
||||||
|
UserAccountID, remId);
|
||||||
|
}
|
||||||
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
|
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||||
|
_logger.LogDebug("HandleReminderIdoc: reminderId={RemId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
|
||||||
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
|
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
|
||||||
if (string.IsNullOrEmpty(fdRem.Id)) return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" });
|
if (string.IsNullOrEmpty(fdRem.Id)) { _logger.LogWarning("HandleReminderIdoc: reminder not found id={RemId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" }); }
|
||||||
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||||
if (Form("typ") != "img")
|
if (Form("typ") != "img")
|
||||||
{
|
{
|
||||||
@@ -158,7 +217,8 @@ public partial class IntranetController
|
|||||||
|
|
||||||
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||||
|
_logger.LogInformation("HandleReminderResend: resending reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||||
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), new SqlParameter("@includefile", true));
|
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), new SqlParameter("@includefile", true));
|
||||||
var dset = await getSQLDataSet_async(
|
var dset = await getSQLDataSet_async(
|
||||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||||
@@ -178,14 +238,39 @@ public partial class IntranetController
|
|||||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||||
await _comService.SendEmailAsync($"rem_{remId}",
|
bool sent = await _comService.SendEmailAsync($"rem_{remId}",
|
||||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||||
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
||||||
email.Trim(), "", remdoc);
|
email.Trim(), "", remdoc);
|
||||||
|
if (sent)
|
||||||
|
{
|
||||||
|
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
|
||||||
|
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Reminder resend email send failed — reminderId={ReminderId} email={Email} user={User}",
|
||||||
|
remId, email.Trim(), UserAccountID);
|
||||||
|
await _events.ReminderIssueAsync(
|
||||||
|
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht erneut an {email.Trim()} versandt werden.",
|
||||||
|
UserAccountID, remId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ReminderIssueResult(string message, string reminderId = "")
|
||||||
|
{
|
||||||
|
// Mirrors the SignalR toast in a durable app log: without this, a reminder
|
||||||
|
// save/create/send failure was only visible as a GUI notification nobody was
|
||||||
|
// necessarily watching at the time.
|
||||||
|
_logger.LogError("Reminder issue — reminderId={ReminderId} user={User} message={Message}",
|
||||||
|
reminderId, UserAccountID, message);
|
||||||
|
await _events.ReminderIssueAsync(message, UserAccountID, reminderId);
|
||||||
|
return StatusCode(500, new { error = message });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildReminderBody(double amountOpen) =>
|
private static string BuildReminderBody(double amountOpen) =>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
// Partial class: live, backend-authoritative reminder draft editing (ADR 0006) — the
|
||||||
|
// reminder mirror of IntranetController.InvoiceDraft.cs. The browser posts single edits
|
||||||
|
// here; the server mutates the in-memory session (the source of truth), recomputes the
|
||||||
|
// open amount / validates, and pings the editing browser over the shared DraftPreviewHub
|
||||||
|
// (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries only signals.
|
||||||
|
public partial class IntranetController
|
||||||
|
{
|
||||||
|
// POST rem/dopen — { payload } → { token, version }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftOpen(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("payload"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Reminder draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
JObject payload;
|
||||||
|
try { payload = JObject.Parse(Form("payload")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Reminder draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
var session = _reminderDrafts.OpenFromPayload(payload, UserAccountID);
|
||||||
|
_logger.LogInformation("Reminder draft dopen: session {Token} (remId={RemId}) user={User}", session.Token, session.RemId, UserAccountID);
|
||||||
|
// The browser holds the token from this response and fetches dstate directly; there is
|
||||||
|
// no server 'draftReady' on open (it would race the client's group-join).
|
||||||
|
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dstate — { token } → full view state
|
||||||
|
private async Task<IActionResult> HandleReminderDraftState(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var session = _reminderDrafts.Get(Form("token"));
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
return await JSONAsync(_reminderDrafts.BuildState(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||||
|
private async Task<IActionResult> HandleReminderDraftPatch(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token", "delta")) return BadRequest400();
|
||||||
|
ReminderDraftDelta? delta;
|
||||||
|
try { delta = JsonConvert.DeserializeObject<ReminderDraftDelta>(Form("delta")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Reminder draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||||
|
|
||||||
|
var session = _reminderDrafts.ApplyPatch(Form("token"), delta);
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||||
|
return await JSONAsync(new { ok = true, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||||
|
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var doc = _reminderDrafts.RenderPreview(Form("token"));
|
||||||
|
if (doc == null) return DraftGone();
|
||||||
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||||
|
private async Task<IActionResult> HandleReminderDraftSave(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
string token = Form("token");
|
||||||
|
var before = _reminderDrafts.Get(token);
|
||||||
|
if (before == null) return DraftGone();
|
||||||
|
bool existed = !string.IsNullOrEmpty(before.RemId);
|
||||||
|
|
||||||
|
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (fdRem == null) return DraftGone();
|
||||||
|
if (string.IsNullOrEmpty(fdRem.Id))
|
||||||
|
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
|
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
||||||
|
var after = _reminderDrafts.Get(token);
|
||||||
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
|
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dhistory — { token } → { history[] }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftHistory(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
if (_reminderDrafts.Get(Form("token")) == null) return DraftGone();
|
||||||
|
var history = _reminderDrafts.GetHistory(Form("token"))
|
||||||
|
.Select(h => new
|
||||||
|
{
|
||||||
|
timestamp = h.TimestampUtc,
|
||||||
|
target = h.Target,
|
||||||
|
@ref = h.Ref,
|
||||||
|
oldValue = h.OldValue,
|
||||||
|
newValue = h.NewValue,
|
||||||
|
version = h.Version
|
||||||
|
});
|
||||||
|
return await JSONAsync(new { history });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dclose — { token } → { ok }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftClose(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
bool ok = _reminderDrafts.Close(Form("token"));
|
||||||
|
_logger.LogDebug("Reminder draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,15 @@ public partial class IntranetController
|
|||||||
ri["params"] = dset.Tables("params")
|
ri["params"] = dset.Tables("params")
|
||||||
.toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'");
|
.toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'");
|
||||||
}
|
}
|
||||||
catch { ri["params"] = Array.Empty<Dictionary<string, object>>(); }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Without this, a genuinely broken params filter/query is indistinguishable
|
||||||
|
// from the expected "this report has no params" case in the response.
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Report catalog: failed to load params for object_id={ObjectId} user={User}",
|
||||||
|
ri["object_id"], UserAccountID);
|
||||||
|
ri["params"] = Array.Empty<Dictionary<string, object>>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await JSONAsync(new
|
return await JSONAsync(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,13 +27,16 @@ public partial class IntranetController
|
|||||||
|
|
||||||
case "rthd":
|
case "rthd":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("Requests rthd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Requests rthd: toggling hidden state for request {ReqId}, user={User}", Form("id"), UserAccountID);
|
||||||
var sqldt = await getSQLDatatable_async(
|
var sqldt = await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__toggleRequestHidden] @Id, @authuser;",
|
"EXECUTE [dbo].[fds__toggleRequestHidden] @Id, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
StdParamlist(SQL_BigInt("@Id", Form("id"))),
|
StdParamlist(SQL_BigInt("@Id", Form("id"))),
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
if (sqldt.Count == 0) return StatusCode(404, new { error = "not found" });
|
if (!string.IsNullOrEmpty(sqldt.Exception))
|
||||||
|
_logger.LogError("Requests rthd: SQL error for request {ReqId}: {SqlError}, user={User}", Form("id"), sqldt.Exception, UserAccountID);
|
||||||
|
if (sqldt.Count == 0) { _logger.LogWarning("Requests rthd: request {ReqId} not found, user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "not found" }); }
|
||||||
var dic = sqldt.FirstRow.toObjectDictionary();
|
var dic = sqldt.FirstRow.toObjectDictionary();
|
||||||
return await JSONAsync(new { id = dic["EntityId"], visible = dic.no("hidden", false) is not true });
|
return await JSONAsync(new { id = dic["EntityId"], visible = dic.no("hidden", false) is not true });
|
||||||
}
|
}
|
||||||
@@ -45,63 +48,87 @@ public partial class IntranetController
|
|||||||
|
|
||||||
case "save":
|
case "save":
|
||||||
{
|
{
|
||||||
if (!HasForm("invc")) return BadRequest400();
|
if (!HasForm("invc")) { _logger.LogWarning("Requests save: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
bool saveChange = !string.IsNullOrEmpty(Form("id"));
|
||||||
|
_logger.LogInformation("Requests save: saving invoice draft change={Change} invId={InvId} user={User}", saveChange, Form("id"), UserAccountID);
|
||||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||||
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
|
change: saveChange, invId: Form("id"), UserAccountID, DbSec);
|
||||||
|
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Requests save: invoice draft {InvId} saved, user={User}", fdInv.Id, UserAccountID);
|
||||||
|
await _events.InvoiceDraftRegisteredAsync(fdInv, saveChange, UserAccountID);
|
||||||
|
}
|
||||||
return !string.IsNullOrEmpty(fdInv.Id)
|
return !string.IsNullOrEmpty(fdInv.Id)
|
||||||
? await JSONAsync(new { id = fdInv.Id })
|
? await JSONAsync(new { id = fdInv.Id })
|
||||||
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" });
|
: await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "sprep":
|
case "sprep":
|
||||||
{
|
{
|
||||||
if (!HasForm("invc")) return BadRequest400();
|
if (!HasForm("invc")) { _logger.LogWarning("Requests sprep: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Requests sprep: preparing new invoice draft, user={User}", UserAccountID);
|
||||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||||
change: false, invId: "", UserAccountID, DbSec);
|
change: false, invId: "", UserAccountID, DbSec);
|
||||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
|
||||||
|
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "sedit":
|
case "sedit":
|
||||||
{
|
{
|
||||||
if (!HasForm("id", "invc")) return BadRequest400();
|
if (!HasForm("id", "invc")) { _logger.LogWarning("Requests sedit: missing form field 'id'/'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Requests sedit: updating invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
|
||||||
|
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "sdel":
|
case "sdel":
|
||||||
if (!HasForm("id")) return BadRequest400();
|
{
|
||||||
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
|
if (!HasForm("id")) { _logger.LogWarning("Requests sdel: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("Requests sdel: deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||||
|
var ok = await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
|
||||||
_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();
|
if (!ok)
|
||||||
|
{
|
||||||
|
_logger.LogError("Requests sdel: SQL failed deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||||
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gelöscht werden.", Form("id"));
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
_logger.LogWarning("Do_Process_Requests: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleRequestList(string fn, string id, string code)
|
private async Task<IActionResult> HandleRequestList(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("mode")) return BadRequest400();
|
if (!HasForm("mode")) { _logger.LogWarning("HandleRequestList: missing form field 'mode', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
string mode = Form("mode").ToLower();
|
string mode = Form("mode").ToLower();
|
||||||
|
_logger.LogDebug("HandleRequestList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
|
||||||
if (mode == "s" && Form("tgt").Contains(':'))
|
if (mode == "s" && Form("tgt").Contains(':'))
|
||||||
{
|
{
|
||||||
var pl = StdParamlist(
|
var pl = StdParamlist(
|
||||||
@@ -122,7 +149,10 @@ public partial class IntranetController
|
|||||||
}
|
}
|
||||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("HandleRequestList: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||||
return BadRequest400();
|
return BadRequest400();
|
||||||
|
}
|
||||||
{
|
{
|
||||||
var pl = StdParamlist(
|
var pl = StdParamlist(
|
||||||
SQL_Date("@tgtdate", tgtdate),
|
SQL_Date("@tgtdate", tgtdate),
|
||||||
@@ -141,20 +171,33 @@ public partial class IntranetController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Dictionary<string, object?>> AttachReports(SQLDataSet dset)
|
private List<Dictionary<string, object?>> AttachReports(SQLDataSet dset)
|
||||||
{
|
{
|
||||||
var req = new List<Dictionary<string, object?>>(dset.Tables("requests").toArrayofObjectDictionaries()!);
|
var req = new List<Dictionary<string, object?>>(dset.Tables("requests").toArrayofObjectDictionaries()!);
|
||||||
foreach (var r in req)
|
foreach (var r in req)
|
||||||
{
|
{
|
||||||
try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); }
|
try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); }
|
||||||
catch { /* no reports table */ }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// "reports" table absent is expected for some queries; but a real failure while
|
||||||
|
// joining (e.g. malformed filter) looked identical to that with no way to tell them apart.
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"AttachReports: failed to join reports for requestId={RequestId} user={User}",
|
||||||
|
r["Id"], UserAccountID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -170,20 +213,25 @@ 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)
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("HandleRequestGet: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogDebug("HandleRequestGet requestId={ReqId} mode={Mode} user={User}", Form("id"), Form("mode"), UserAccountID);
|
||||||
string modeVal = Form("mode").ne("ov");
|
string modeVal = Form("mode").ne("ov");
|
||||||
string[] tn = modeVal switch
|
string[] tn = modeVal switch
|
||||||
{
|
{
|
||||||
@@ -210,7 +258,8 @@ public partial class IntranetController
|
|||||||
|
|
||||||
private async Task<IActionResult> HandleRequestIget(string fn, string id, string code)
|
private async Task<IActionResult> HandleRequestIget(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id", "typ")) return BadRequest400();
|
if (!HasForm("id", "typ")) { _logger.LogWarning("HandleRequestIget: missing form field 'id'/'typ', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogDebug("HandleRequestIget requestId={ReqId} typ={Typ} mode={Mode} user={User}", Form("id"), Form("typ"), Form("mode"), UserAccountID);
|
||||||
var pl = StdParamlist(
|
var pl = StdParamlist(
|
||||||
SQL_BigInt("@servicerequestid", Form("id")),
|
SQL_BigInt("@servicerequestid", Form("id")),
|
||||||
SQL_VarChar("@mode", Form("mode").ne("ov")),
|
SQL_VarChar("@mode", Form("mode").ne("ov")),
|
||||||
@@ -252,12 +301,15 @@ public partial class IntranetController
|
|||||||
|
|
||||||
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) return BadRequest400();
|
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||||
var dt = await getSQLDatatable_async(
|
var dt = await getSQLDatatable_async(
|
||||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
||||||
_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));
|
||||||
|
if (!string.IsNullOrEmpty(dt.Exception))
|
||||||
|
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||||
var frdic = dt.FirstRow.toObjectDictionary();
|
var frdic = dt.FirstRow.toObjectDictionary();
|
||||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||||
{
|
{
|
||||||
@@ -285,31 +337,72 @@ public partial class IntranetController
|
|||||||
body, email.Trim(), "", inv);
|
body, email.Trim(), "", inv);
|
||||||
if (sent)
|
if (sent)
|
||||||
{
|
{
|
||||||
|
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
||||||
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
||||||
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pls,
|
_intranet.Intranet__SQLConnectionString, pls,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Invoice email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||||
|
invId, email.Trim(), UserAccountID);
|
||||||
|
await _events.InvoiceIssueAsync(
|
||||||
|
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||||
|
UserAccountID, invId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
else if (filebyte.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}",
|
||||||
|
invId, UserAccountID);
|
||||||
|
await _events.InvoiceIssueAsync(
|
||||||
|
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
|
||||||
|
UserAccountID, invId);
|
||||||
|
}
|
||||||
|
// hasFile tells the frontend whether the PDF was actually stored, so it only opens the
|
||||||
|
// idoc preview popup when there is something to show (never on a failed render/store).
|
||||||
|
return await JSONAsync(new { ok = true, hasFile = filebyte.Length > 0 });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the PDF inline (browser shows it) while advertising the real download filename —
|
||||||
|
/// both a quoted ASCII form and RFC 5987 <c>filename*</c> for spaces/non-ASCII. Works around
|
||||||
|
/// the OCORE FileContentResult helper, whose classic-MVC <c>ExecuteResult(ControllerContext)</c>
|
||||||
|
/// never runs under ASP.NET Core, so the filename was dropped and downloads used the "idoc"
|
||||||
|
/// endpoint segment.
|
||||||
|
/// </summary>
|
||||||
|
private void SetInlinePdfFilename(string filename)
|
||||||
|
{
|
||||||
|
string safe = (filename ?? "").Replace("\"", "").Replace("\r", " ").Replace("\n", " ").Trim();
|
||||||
|
if (safe.Length == 0) return;
|
||||||
|
Response.Headers["Content-Disposition"] =
|
||||||
|
$"inline; filename=\"{safe}\"; filename*=UTF-8''{Uri.EscapeDataString(safe)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||||
|
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
|
||||||
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
|
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
|
||||||
if (string.IsNullOrEmpty(fdInv.Id)) return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" });
|
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
|
||||||
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
|
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
|
||||||
if (Form("typ") != "img")
|
if (Form("typ") != "img")
|
||||||
{
|
{
|
||||||
byte[]? ct = Form("create", "0") != "1"
|
byte[]? ct = Form("create", "0") != "1"
|
||||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return ct != null
|
if (ct == null)
|
||||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||||
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
|
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||||
|
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
|
||||||
|
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||||
|
SetInlinePdfFilename(filename);
|
||||||
|
return File(ct, "application/pdf");
|
||||||
}
|
}
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
@@ -317,7 +410,8 @@ public partial class IntranetController
|
|||||||
|
|
||||||
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||||
|
_logger.LogInformation("HandleRequestResend: resending invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||||
var dtset = await getSQLDataSet_async(
|
var dtset = await getSQLDataSet_async(
|
||||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString,
|
_intranet.Intranet__SQLConnectionString,
|
||||||
@@ -335,14 +429,35 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
||||||
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||||
await _comService.SendEmailAsync(
|
bool sent = await _comService.SendEmailAsync(
|
||||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
||||||
|
if (sent)
|
||||||
|
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Invoice resend email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||||
|
invId, email.Trim(), UserAccountID);
|
||||||
|
await _events.InvoiceIssueAsync(
|
||||||
|
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht erneut an {email.Trim()} versandt werden.",
|
||||||
|
UserAccountID, invId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok();
|
return await JSONAsync(new { ok = true });
|
||||||
}
|
}
|
||||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> InvoiceIssueResult(string message, string invoiceId = "")
|
||||||
|
{
|
||||||
|
// Mirrors the SignalR toast in a durable app log: without this, an invoice save/create/send
|
||||||
|
// failure was only visible as a GUI notification nobody was necessarily watching at the time.
|
||||||
|
_logger.LogError("Invoice issue — invoiceId={InvoiceId} user={User} message={Message}",
|
||||||
|
invoiceId, UserAccountID, message);
|
||||||
|
await _events.InvoiceIssueAsync(message, UserAccountID, invoiceId);
|
||||||
|
return StatusCode(500, new { error = message });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildInvoiceBody(double balance, string paymentTerms) =>
|
private static string BuildInvoiceBody(double balance, string paymentTerms) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Web;
|
using System.Web;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Notifications;
|
||||||
using Fuchs.Services;
|
using Fuchs.Services;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -33,6 +34,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
private readonly IReportService _reports;
|
private readonly IReportService _reports;
|
||||||
private readonly IInvoiceService _invoices;
|
private readonly IInvoiceService _invoices;
|
||||||
private readonly IReminderService _reminders;
|
private readonly IReminderService _reminders;
|
||||||
|
private readonly IEventService _events;
|
||||||
|
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||||
|
private readonly IReminderDraftService _reminderDrafts;
|
||||||
|
private readonly IDraftNotifier _draftNotifier;
|
||||||
|
private readonly ISystemStatusService _systemStatus;
|
||||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||||
private readonly List<string> _allowedGet = new()
|
private readonly List<string> _allowedGet = new()
|
||||||
{
|
{
|
||||||
@@ -59,7 +65,12 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
IWidgetService widgets,
|
IWidgetService widgets,
|
||||||
IReportService reports,
|
IReportService reports,
|
||||||
IInvoiceService invoices,
|
IInvoiceService invoices,
|
||||||
IReminderService reminders)
|
IReminderService reminders,
|
||||||
|
IEventService events,
|
||||||
|
IInvoiceDraftService invoiceDrafts,
|
||||||
|
IReminderDraftService reminderDrafts,
|
||||||
|
IDraftNotifier draftNotifier,
|
||||||
|
ISystemStatusService systemStatus)
|
||||||
{
|
{
|
||||||
_intranet = intranet;
|
_intranet = intranet;
|
||||||
_mfr = mfr;
|
_mfr = mfr;
|
||||||
@@ -72,6 +83,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
_reports = reports;
|
_reports = reports;
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_reminders = reminders;
|
_reminders = reminders;
|
||||||
|
_events = events;
|
||||||
|
_invoiceDrafts = invoiceDrafts;
|
||||||
|
_reminderDrafts = reminderDrafts;
|
||||||
|
_draftNotifier = draftNotifier;
|
||||||
|
_systemStatus = systemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||||
@@ -102,7 +118,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID);
|
public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID);
|
||||||
|
|
||||||
public FIS_SQLOptions SqlOpt(string fn, string id, string code) =>
|
public FIS_SQLOptions SqlOpt(string fn, string id, string code) =>
|
||||||
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code });
|
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code }, _logger);
|
||||||
|
|
||||||
// ── Action helpers ────────────────────────────────────────────────────────
|
// ── Action helpers ────────────────────────────────────────────────────────
|
||||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||||
@@ -140,7 +156,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"),
|
||||||
@@ -150,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
"rem" => await Do_Process_Reminder(fn, id, code),
|
"rem" => await Do_Process_Reminder(fn, id, code),
|
||||||
"rep" => await Do_Process_Reports(fn, id, code),
|
"rep" => await Do_Process_Reports(fn, id, code),
|
||||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||||
|
"admin" => await Do_Process_Admin(fn, id, code),
|
||||||
"auth" => await HandleAuth(fn, id, code),
|
"auth" => await HandleAuth(fn, id, code),
|
||||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||||
"spw" => await HandleSendPassword(fn, id, code),
|
"spw" => await HandleSendPassword(fn, id, code),
|
||||||
@@ -164,12 +181,25 @@ 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)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||||
fn, id, code, UserAccountID);
|
fn, id, code, UserAccountID);
|
||||||
|
// Standing rule: an exception that interrupts a user-initiated action must reach the
|
||||||
|
// user as a notification, not only the log. This is the catch-all safety net for any
|
||||||
|
// Do_Process_* action that throws without first publishing its own (more specific)
|
||||||
|
// issue event. Pre-auth flows (login/logout, unauthenticated GETs) are skipped — there
|
||||||
|
// is no user session to notify and the HTTP status already conveys the failure.
|
||||||
|
if (UserIdent.IsAuthenticated)
|
||||||
|
{
|
||||||
|
await _events.UserIssueAsync(
|
||||||
|
"Aktion fehlgeschlagen",
|
||||||
|
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||||
|
UserAccountID,
|
||||||
|
new Dictionary<string, object?> { ["fn"] = fn });
|
||||||
|
}
|
||||||
return ServerError();
|
return ServerError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +281,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 ──────────────────────────────────────────────────────
|
||||||
@@ -281,7 +311,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)
|
||||||
@@ -319,7 +349,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)
|
||||||
@@ -341,7 +371,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"];
|
||||||
@@ -396,10 +426,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)
|
||||||
@@ -425,7 +455,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)
|
||||||
@@ -440,7 +470,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"]))
|
||||||
{
|
{
|
||||||
@@ -449,7 +479,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,21 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a request value from the posted form, falling back to the query string.
|
||||||
|
/// Extracted as pure/testable logic: endpoints in <c>_allowedGet</c> (e.g. req/idoc, rem/idoc)
|
||||||
|
/// are invoked via a plain GET (window.open with '?id=...'), where <see cref="HttpRequest.Form"/>
|
||||||
|
/// has no Content-Type and throws <see cref="InvalidOperationException"/> if read directly.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RequestValueHelper
|
||||||
|
{
|
||||||
|
internal static string? Resolve(bool hasFormContentType, IFormCollection form, IQueryCollection query, string key)
|
||||||
|
{
|
||||||
|
if (hasFormContentType && form.TryGetValue(key, out var formValue))
|
||||||
|
return formValue.ToString();
|
||||||
|
if (query.TryGetValue(key, out var queryValue))
|
||||||
|
return queryValue.ToString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-10
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
|||||||
| Project | Type | Purpose |
|
| Project | Type | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
| **Fuchs_DataService** | Class Library | MFR ERP sync logic (entity polling, invoice/DATEV files). Hosted in-process by **Fuchs** as a `PeriodicHostedService` (see ADR 0010) — no longer a standalone process. |
|
||||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||||
@@ -96,17 +96,18 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
|||||||
│ │
|
│ │
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Fuchs_DataService (Windows Service / Console) │
|
│ Fuchs_DataService (Class Library, hosted in-process) │
|
||||||
│ │
|
│ │
|
||||||
│ FdsMain.cs — Topshelf host, job definitions │
|
│ PeriodicHostedService — in Fuchs/Services; BackgroundService + │
|
||||||
│ PeriodicHostedService — BackgroundService with PeriodicTimer │
|
│ PeriodicTimer, registered in Program.cs │
|
||||||
|
│ when Fds:SyncEnabled (ADR 0010) │
|
||||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||||
│ FdsDebug.cs — Debug/file logging │
|
│ FdsDebug.cs — Debug/file logging │
|
||||||
│ │
|
│ │
|
||||||
│ Jobs: MfrSync (every N min) │
|
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||||
│ → UpdateIfNecessary_async (entity table sync) │
|
│ → UpdateIfNecessary_async (entity table sync) │
|
||||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||||
│ → GetInvoiceFiles_async (invoice PDF download) │
|
│ → GetInvoiceFiles_async (invoice PDF download) │
|
||||||
@@ -167,8 +168,14 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
|||||||
|
|
||||||
### 4.3 Service Layer (Dependency Injection)
|
### 4.3 Service Layer (Dependency Injection)
|
||||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`, `IERechnungService`.
|
||||||
|
|
||||||
|
`IERechnungService` (singleton) maps a finalized invoice to the EN 16931 model and embeds the
|
||||||
|
CII XML into the render-only visual PDF to produce a ZUGFeRD/Factur-X **PDF/A-3** hybrid via the
|
||||||
|
`eRechnungLib` submodule (gated by `Fuchs:ERechnung:Enabled`; falls back to the plain PDF/A on
|
||||||
|
disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md) and ADR 0012.
|
||||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
||||||
|
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
||||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||||
|
|
||||||
@@ -176,7 +183,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
|
|||||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||||
|
|
||||||
### 4.5 Background Service
|
### 4.5 Background Service
|
||||||
`Fuchs_DataService` runs as a Windows Service (Topshelf) with a `PeriodicHostedService` that polls the MFR ERP system on a timer, syncing entities and downloading invoice files.
|
The MFR ERP sync runs **in-process inside the web app** as a `PeriodicHostedService` (`Fuchs/Services/PeriodicHostedService.cs`), registered in `Program.cs` when `Fds:SyncEnabled` is true. It polls the MFR ERP on a timer (`Fds:ExecutionFrequency_Minutes`), syncing entities and downloading invoice files via the sync logic in the `Fuchs_DataService` library. See ADR [0010](Decisions/0010-mfr-sync-hosted-in-web-app.md).
|
||||||
|
|
||||||
### 4.6 Authentication
|
### 4.6 Authentication
|
||||||
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
||||||
@@ -409,7 +416,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
|||||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
5. ✅ **Resolved** — **Topshelf** removed; `Fuchs_DataService` is now a class library and the MFR sync is hosted in-process by the web app as a `PeriodicHostedService` (ADR 0010).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Concepts
|
||||||
|
|
||||||
|
This folder holds **living design write-ups** of how a subsystem currently
|
||||||
|
works: its moving parts, data flow, and how they fit together. Unlike
|
||||||
|
[`../Decisions`](../Decisions/README.md), concept docs are **not** immutable
|
||||||
|
— keep them in sync with the implementation as it evolves.
|
||||||
|
|
||||||
|
## What belongs here
|
||||||
|
|
||||||
|
"How does the notification pipeline work end to end" is a concept doc. "Why
|
||||||
|
did we choose SignalR over polling for it" is a decision. A single feature
|
||||||
|
area typically has one concept doc and may reference several decisions that
|
||||||
|
shaped it.
|
||||||
|
|
||||||
|
## File naming
|
||||||
|
|
||||||
|
`kebab-case-topic.md` (no numbering — concepts aren't sequential events).
|
||||||
|
|
||||||
|
## Required YAML frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
status: Active # Active | Deprecated
|
||||||
|
lastUpdated: 2026-07-03
|
||||||
|
applyTo: # glob(s) — files/areas this concept describes
|
||||||
|
- "Fuchs/Notifications/**"
|
||||||
|
relatedDecisions: # filenames in ../Decisions this concept implements
|
||||||
|
- "0001-domain-events-and-notification-triggers.md"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agents must scan the YAML frontmatter of every file in this folder first**
|
||||||
|
and only read the full body of concepts whose `applyTo` glob matches the
|
||||||
|
files they're about to touch, or whose subject is otherwise clearly relevant.
|
||||||
|
|
||||||
|
## Body template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Topic
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
One paragraph: what this subsystem does and why it exists.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
The mechanics — components, data flow, sequencing. Diagrams (ASCII/mermaid)
|
||||||
|
welcome where they clarify.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
Bullet list of the primary files/classes involved.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
Links to the ADRs in `../Decisions` that shaped this design.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Keep concepts current.** When you materially change how a documented
|
||||||
|
subsystem works, update its concept doc in the same change — don't let it
|
||||||
|
drift from the code.
|
||||||
|
- **Create a concept doc for new non-trivial subsystems.** If you build
|
||||||
|
something a future agent would need a paragraph of context to safely
|
||||||
|
modify, write that paragraph here instead of making them re-derive it from
|
||||||
|
the diff.
|
||||||
|
- Concepts describe **current** behavior. If something changes, edit the
|
||||||
|
doc in place — don't append a changelog inside it (git history is the
|
||||||
|
changelog).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
status: Active
|
||||||
|
lastUpdated: 2026-07-16
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||||
|
- "Fuchs/Services/SystemStatusService.cs"
|
||||||
|
- "Fuchs/Services/ISystemStatusService.cs"
|
||||||
|
- "Fuchs/Services/SystemStatusModels.cs"
|
||||||
|
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||||
|
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||||
|
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||||
|
relatedDecisions:
|
||||||
|
- "0011-admin-module-system-status.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin / System-Status module
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
The **Admin** module gives a privileged operator a live, in-app view of the running
|
||||||
|
deployment's configuration and health, plus a test-email tool. It answers "which host am I
|
||||||
|
on, can this instance reach SQL Server / Key Vault / Blob Storage / the MFR ERP, how is
|
||||||
|
email wired up (including the OverrideRecipient redirect), and does sending actually work".
|
||||||
|
Access is restricted to users whose `fds_sys` module authorization is greater than 4.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Authorization (two layers)
|
||||||
|
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
|
||||||
|
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
|
||||||
|
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
|
||||||
|
see the button and never fetch the module script.
|
||||||
|
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
|
||||||
|
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
|
||||||
|
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
|
||||||
|
frontend can cleanly decline to render. This is defense in depth: hiding the button is
|
||||||
|
not a security control on its own.
|
||||||
|
|
||||||
|
### Request flow
|
||||||
|
```
|
||||||
|
click "Administration" → $ocms.init('admin')
|
||||||
|
POST /do/admin/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||||
|
load /web/fis.admin.de.js + /web/fis.admin.css
|
||||||
|
$ocms.admin.init2() → init3() renders the page
|
||||||
|
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||||
|
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||||
|
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||||
|
- `GetInfo()` — passive snapshot, no network calls: host (machine/OS/framework/environment/
|
||||||
|
test-deployment/uptime), database (server/catalog/login parsed from the connection string —
|
||||||
|
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
|
||||||
|
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
|
||||||
|
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
|
||||||
|
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
|
||||||
|
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||||
|
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||||
|
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
|
||||||
|
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
|
||||||
|
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
|
||||||
|
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
|
||||||
|
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
|
||||||
|
blob probe uses it for the file counts.
|
||||||
|
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||||
|
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||||
|
reports the requested recipient and, when active, the override target.
|
||||||
|
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||||
|
`systemstatus.probe` activity span.
|
||||||
|
|
||||||
|
### Startup-checks widget (non-refreshable)
|
||||||
|
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
|
||||||
|
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
|
||||||
|
`StartupCheckReporter`, which `GetInfo()` returns as `StartupChecks`. The Admin page renders it as a
|
||||||
|
single **non-refreshable** card (there is no per-check retry — the live connectivity probes above
|
||||||
|
cover on-demand re-testing; the startup card is a historical record of the boot run). When the
|
||||||
|
self-test is disabled (`Enabled=false`, the appsettings default) or has not completed, the card shows
|
||||||
|
a "nicht ausgeführt" note. Each item shows OK / Fehler / übersprungen (a disabled check reports
|
||||||
|
`enabled=false`).
|
||||||
|
|
||||||
|
### Frontend (`fis.admin.js` + `fis.admin_txt_de.js` + `fis.admin.scss`)
|
||||||
|
Standard lazy-loaded module (same contract as `inv`/`rep`/`bam`). Renders a system card plus a
|
||||||
|
responsive grid of status cards; connectivity cards carry a colored status pill and a per-card
|
||||||
|
refresh button, the email card carries the test-email dialog. The topbar has an "Alle prüfen"
|
||||||
|
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
|
||||||
|
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + auth gate + camelCase JSON helper.
|
||||||
|
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
|
||||||
|
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
|
||||||
|
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs` — `CheckConnectivityAsync` + `BlobConnectivity`.
|
||||||
|
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
|
||||||
|
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
|
||||||
|
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
status: Active
|
||||||
|
lastUpdated: 2026-07-18
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/ERechnungMapper.cs"
|
||||||
|
- "Fuchs/Services/ERechnungService.cs"
|
||||||
|
- "Fuchs/Services/ERechnungSettings.cs"
|
||||||
|
- "Fuchs/Services/InvoiceRecipientAddress.cs"
|
||||||
|
- "Fuchs/Services/InvoiceService.cs"
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "eRechnungLib/**"
|
||||||
|
relatedDecisions:
|
||||||
|
- "0005-pdf-generation-and-erechnung.md"
|
||||||
|
- "0012-erechnung-single-pdfa-engine-pipeline.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# eRechnung output (ZUGFeRD/Factur-X)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Finalized invoices are emitted as a **ZUGFeRD 2.4 / Factur-X** hybrid: the FuchsPdf visual PDF
|
||||||
|
with the EN 16931 **CII XML** embedded, in a formally conformant **PDF/A-3**. This makes invoices
|
||||||
|
DATEV-ingestible and satisfies the B2B/B2G e-invoicing mandate. The library doing the structured
|
||||||
|
XML + PDF/A-3 work is the `eRechnungLib` submodule; Fuchs supplies the invoice data and the visual
|
||||||
|
PDF.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Editor (structured recipient dialog)
|
||||||
|
→ InvoiceDraftEditService (address delta = JSON object, stored in CustomValues.sendToAddress)
|
||||||
|
→ fds__setInvoice/… (persisted; dedicated SendToAddressJson column + composed SendToAddress)
|
||||||
|
→ InvoiceService.RenderInvoicePdfBytesAsync(final)
|
||||||
|
├─ FuchsPdf.DocToPdfBytesRaw(doc) → visual PDF (no Spire PDF/A)
|
||||||
|
└─ IERechnungService.TryBuildHybridPdf
|
||||||
|
├─ ERechnungMapper.BuildEInvoice FdsInvoiceData → eRechnungLib.Model.Invoice
|
||||||
|
└─ EInvoice.ToZugferd(EN16931, raw) → PDF/A-3 + Factur-X hybrid (bundled sRGB ICC)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Single PDF/A engine (ADR 0012).** eRechnungLib owns the one PDF/A-3 layer. For the eRechnung
|
||||||
|
path the Spire PDF/A step is skipped (`DocToPdfBytesRaw`); Spire stays only for on-screen preview
|
||||||
|
rasterisation. This avoids a conflicting second output intent / `pdfaid` marker.
|
||||||
|
- **Structured recipient address.** `InvoiceRecipientAddress` holds the EN 16931 buyer fields
|
||||||
|
(name, street, post code, city, country BT-55, optional VAT id BT-48). It is edited via a
|
||||||
|
dialog form (`$inv.eAddress` in `fis.inv_shared.js`), prefilled from `fds__prepInvoice`'s
|
||||||
|
`invoiceaddressData`, and carried as a JSON object through the draft cache. A private person
|
||||||
|
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
|
||||||
|
from it so the PDF layout is unchanged.
|
||||||
|
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
|
||||||
|
(from `Fuchs:ERechnung:Seller` config — name, address, Steuernummer BT-32, **USt-IdNr BT-31**,
|
||||||
|
IBAN/BIC, contact; defaults mirror the FuchsPdf letterhead), buyer = the structured recipient,
|
||||||
|
lines from the invoice items, §13b → reverse charge (category AE + exemption reason), payment
|
||||||
|
terms BT-20/BT-9, and the service date/period (BT-72 or BG-14) parsed from the structured
|
||||||
|
`ProvisionPeriod`. VAT breakdown and totals are recomputed by the library.
|
||||||
|
- **Profile selection (B2B/B2C vs B2G).** Default is ZUGFeRD **EN 16931** (DATEV). When the
|
||||||
|
recipient carries a **Leitweg-ID** (`InvoiceRecipientAddress.LeitwegId` → `BuyerReference` BT-10),
|
||||||
|
the invoice is B2G and emitted as **XRechnung** (`ZugferdProfile.XRechnung`, embedded
|
||||||
|
`xrechnung.xml`); the mapper then also fills the seller electronic address/contact (BT-34/BG-6)
|
||||||
|
and buyer electronic address (BT-49) that XRechnung requires.
|
||||||
|
- **Feature flag & fallback.** Emission is gated by `Fuchs:ERechnung:Enabled` (off until fully
|
||||||
|
validated). `IERechnungService` returns `null` on disable **or any failure**, so
|
||||||
|
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
|
||||||
|
- **Formal verification.** `Fuchs:ERechnung:Validation` calls the ProcessWeb eInvoice service
|
||||||
|
(`POST {ServiceUrl}/validatepdf`, raw `application/pdf`) which checks the EN 16931 XML **and**
|
||||||
|
PDF/A-3 (veraPDF) in one call. Both the EN 16931-ZUGFeRD and the XRechnung 3.0 output are
|
||||||
|
externally **ACCEPTED** (0 errors) and **PDF/A-3B COMPLIANT**. Getting there required fixing two
|
||||||
|
eRechnungLib defects (CII root children must be `rsm:` not `ram:`; embedded-file `/Subtype` MIME
|
||||||
|
encoding) and completing the mapper (seller VAT id BT-31, payment terms BT-20/BT-9). The
|
||||||
|
validator's target feature scope is documented in
|
||||||
|
[`../eRechnung-Validator-Requirements.md`](../eRechnung-Validator-Requirements.md).
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
|
||||||
|
- `Fuchs/Services/ERechnungMapper.cs` — `FdsInvoiceData` → `eRechnungLib.Model.Invoice`.
|
||||||
|
- `Fuchs/Services/ERechnungService.cs` / `ERechnungSettings.cs` — hybrid production + config.
|
||||||
|
- `Fuchs/Services/InvoiceService.cs` — wiring in `RenderInvoicePdfBytesAsync`.
|
||||||
|
- `Fuchs/code/FuchsPdf.cs` — `DocToPdfBytesRaw` (render-only visual PDF).
|
||||||
|
- `Fuchs/js/intranet/modules/fis.inv_shared.js` — `$inv.eAddress` structured dialog.
|
||||||
|
- `Fuchs_Database` — `fds__invoices.SendToAddressJson`, `fds__getCompanyAddressJson`,
|
||||||
|
`fds__prepInvoice.invoiceaddressData`, `fds__createInvoice`/`setInvoice`/`getInvoice`.
|
||||||
|
- `eRechnungLib/**` — CII/UBL serialization, EN 16931 validation, `FacturXPdfBuilder` (PDF/A-3).
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
- [`0005-pdf-generation-and-erechnung.md`](../Decisions/0005-pdf-generation-and-erechnung.md)
|
||||||
|
- [`0012-erechnung-single-pdfa-engine-pipeline.md`](../Decisions/0012-erechnung-single-pdfa-engine-pipeline.md)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
status: Active
|
||||||
|
lastUpdated: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/Services/ReminderDraft*"
|
||||||
|
- "Fuchs/Services/IReminderDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftSession.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/*DraftNotifier*"
|
||||||
|
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||||
|
- "Fuchs/Controllers/IntranetController.ReminderDraft.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
relatedDecisions:
|
||||||
|
- "0006-backend-authoritative-draft-editing.md"
|
||||||
|
- "0007-targeted-draft-signalr-groups.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Live draft editing (backend-authoritative invoice previews)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
While a back-office user edits an invoice draft, the authoritative state is held in
|
||||||
|
server memory, not in the browser. The browser posts single edits, the server mutates
|
||||||
|
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
||||||
|
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
||||||
|
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
||||||
|
explicit discard), reversing the earlier stateless editor. Invoices were the pilot;
|
||||||
|
reminders now mirror the same design (see "Reminders" below).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Open: Browser --POST inv/dopen {id | payload}--> server builds InvoiceDraftSession, caches it
|
||||||
|
Browser --SignalR JoinDraft(token)--> joins the draft's group; spinner while loading
|
||||||
|
Browser --POST inv/dstate {token}--> renders admin/new/req + server sums + validation
|
||||||
|
|
||||||
|
Edit: Browser --POST inv/dpatch {token, delta}--> mutate + recompute + validate + version++
|
||||||
|
Server --SignalR draftReady{token,version}--> Browser re-fetches inv/dstate, re-renders
|
||||||
|
|
||||||
|
Preview: Browser --POST inv/dpreview {token}--> PDF rendered straight from the cache (no upload)
|
||||||
|
Save: Browser --POST inv/dsave {token}--> flush cache->DB (RegisterInvoiceAsync) + EventService toast
|
||||||
|
History: Browser --POST inv/dhistory {token}--> change list -> "Änderungshistorie" dialog
|
||||||
|
Discard: Browser --POST inv/ddiscard {token}--> reload session from DB draft -> draftReady
|
||||||
|
Close: Browser --POST inv/dclose {token}--> session removed (+ LeaveDraft)
|
||||||
|
|
||||||
|
Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bitte zwischenspeichern"
|
||||||
|
Server (evict) --SignalR draftClosed{token,reason}--> close the editor with a reason
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Session** (`InvoiceDraftSession`) is a pure data holder: the editable payload as the
|
||||||
|
exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed
|
||||||
|
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||||
|
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||||
|
`RecomputeLineValues` (net/VAT/service-net/service-VAT per line from raw quantity ×
|
||||||
|
price × VAT rate, the `quantChange`/`setVat` port), `RecomputeTotals`
|
||||||
|
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
|
||||||
|
(numbers every line except heading/free-text lines continuously across the whole invoice —
|
||||||
|
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
|
||||||
|
including after a reorder),
|
||||||
|
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
|
||||||
|
exhaustively unit-tested. **The online editor performs no arithmetic of any kind** — not
|
||||||
|
header, footer, sums, totals, taxes, nor a single line's own net/VAT value: `$inv.quantChange`
|
||||||
|
and `$inv.setVat` only post the raw field the user changed (qty/price/vat rate), and
|
||||||
|
`$inv.invSumUpdate` only reassembles the row-contract array needed to post `req` — it computes
|
||||||
|
no totals, no VAT breakdown, and no service-refund note figures. All of those are rendered
|
||||||
|
exclusively from `dstate.sums` via `$inv.d.footer`.
|
||||||
|
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
|
||||||
|
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
|
||||||
|
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
|
||||||
|
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
|
||||||
|
drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then
|
||||||
|
renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change
|
||||||
|
history records the **changed field** (e.g. the new heading text), never the whole block JSON.
|
||||||
|
The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows
|
||||||
|
every position's price (set members are priced like standalone lines; only `setonly` collapses
|
||||||
|
them), so the PDF preview mirrors the online editor.
|
||||||
|
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
|
||||||
|
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
|
||||||
|
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||||
|
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
||||||
|
path), renders previews from a synthesised registration, and discards by reloading.
|
||||||
|
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||||
|
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||||
|
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||||
|
- **Signals** (`DraftPreviewHub` at `/draftpreview` + `IDraftNotifier`) are targeted at the
|
||||||
|
editing browser via a group named after the session token: `draftReady`, `draftExpiring`,
|
||||||
|
`draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`.
|
||||||
|
- **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins,
|
||||||
|
posts one delta per change, shows a loading state whenever awaiting a signal, and offers
|
||||||
|
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It computes nothing — no
|
||||||
|
header, footer, sums, totals, taxes, or per-line values.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||||
|
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
|
||||||
|
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
|
||||||
|
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
||||||
|
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
||||||
|
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
||||||
|
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
||||||
|
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
||||||
|
|
||||||
|
## Reminders (Zahlungserinnerung)
|
||||||
|
|
||||||
|
Reminders mirror the same backend-authoritative model with a reminder-shaped session. A
|
||||||
|
reminder chases a single invoiced amount, so the machinery is simpler than an invoice's:
|
||||||
|
there are no line-item blocks, VAT grouping or reordering — just recipient fields and the
|
||||||
|
amount pair.
|
||||||
|
|
||||||
|
- **Endpoints** are `rem/d*` (`dopen`/`dstate`/`dpatch`/`dpreview`/`dsave`/`dhistory`/`dclose`),
|
||||||
|
dispatched from `Do_Process_Reminder`. Finalise + email still runs through the existing
|
||||||
|
`rem/conf` (`HandleReminderConf`), exactly as invoices finalise through `req/sconf`.
|
||||||
|
- **Session** (`ReminderDraftSession`) holds the editor's `new` (subject / invoiceaddress /
|
||||||
|
invoiceemail / text / amount / amount_payed / CustomValues) and `rem` (invid / type /
|
||||||
|
invoiceid / invoicedate) blocks, plus server-computed `Sums` (`AmountTotal`, `AmountPayed`,
|
||||||
|
`AmountOpen`). It reuses the shared `ChangeHistoryEntry`; validation uses
|
||||||
|
`ReminderDraftValidationMessage`.
|
||||||
|
- **Calculation** (`ReminderDraftCalculator`, static/pure): `AmountOpen = AmountTotal − AmountPayed`,
|
||||||
|
plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested.
|
||||||
|
- **Deltas** (`ReminderDraftDelta`): scalar `email`/`address`/`subject`/`text` (HTML-sanitised via
|
||||||
|
the shared `InvoiceDraftEditService.HtmlToPlain`), the numeric `amount`/`amount_payed`
|
||||||
|
(normalised to an invariant decimal string), and `contact` (→ `CustomValues`).
|
||||||
|
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||||
|
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||||
|
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||||
|
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
||||||
|
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
||||||
|
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
||||||
|
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||||
|
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||||
|
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||||
|
groups serve invoice and reminder drafts alike.
|
||||||
|
- **Frontend** (`$inv.rd` in `fis.inv_shared.js`) opens/joins on `rem/dopen`, posts one delta per
|
||||||
|
inline edit and per item-row amount change, renders the open-amount footer + validation from the
|
||||||
|
server state, and previews/finalises through `rem/dpreview` → `rem/dsave` → `rem/conf`. It shares
|
||||||
|
the invoice editor DOM; `$inv.d` and `$inv.rd` each key off their own token, so the shared inline
|
||||||
|
editor safely no-ops for whichever mode is inactive.
|
||||||
|
|
||||||
|
### Reminder key files
|
||||||
|
- `Fuchs/code/ReminderDraftSession.cs` — session + `ReminderDraftSums` + `ReminderDraftValidationMessage`.
|
||||||
|
- `Fuchs/code/ReminderDraftCalculator.cs` — pure open-amount recompute + validate.
|
||||||
|
- `Fuchs/Services/ReminderDraftCache.cs` / `IReminderDraftCache.cs` — in-memory store + TTL.
|
||||||
|
- `Fuchs/Services/ReminderDraftEditService.cs` / `IReminderDraftService.cs` — orchestration + delta contract.
|
||||||
|
- `Fuchs/Services/ReminderDraftExpiryService.cs` — idle warn/evict monitor.
|
||||||
|
- `Fuchs/Controllers/IntranetController.ReminderDraft.cs` — `rem/d*` endpoints.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
|
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-03
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Notifications/**"
|
||||||
|
- "Fuchs/Services/**"
|
||||||
|
- "Fuchs/Controllers/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0001 — Domain events (success and failure) trigger user-understandable notifications
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Business operations (invoice creation, sending, marking sent, reminders,
|
||||||
|
banking import) happen server-side, often outside a synchronous request the
|
||||||
|
user is watching (background jobs, long-running sends). Users had no
|
||||||
|
reliable way to learn that an operation they cared about — or one that
|
||||||
|
failed — actually happened, short of refreshing lists or checking logs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Every meaningful business outcome, success **and** failure, is modeled as a
|
||||||
|
`DomainEvent` (`Fuchs/Notifications/DomainEvent.cs`) with:
|
||||||
|
- a `DomainEventType` enum value identifying what happened,
|
||||||
|
- the acting `UserAccountId`,
|
||||||
|
- a `Title`, and
|
||||||
|
- a `Context` dictionary of the data needed to render a human-readable
|
||||||
|
message (invoice number, email address, file name, row counts, etc.).
|
||||||
|
|
||||||
|
Services call the corresponding method on `IEventService`
|
||||||
|
(`Fuchs/Notifications/IEventService.cs`, implemented by `EventService`)
|
||||||
|
at the point the outcome is known — e.g.
|
||||||
|
`InvoiceSentToCustomerAsync(invoice, email, userAccountId)` or
|
||||||
|
`InvoiceIssueAsync(message, userAccountId, invoiceId)` on failure.
|
||||||
|
`EventService.PublishAsync` renders the event into a `GuiNotification` with a
|
||||||
|
German, end-user-readable `Message` (e.g. *"Rechnung R2026-0001 wurde an den
|
||||||
|
Kunden mit der E-Mail test@test.de versandt."*) and pushes it — see
|
||||||
|
[0002](0002-gui-notification-delivery-signalr.md) for delivery.
|
||||||
|
|
||||||
|
Every new business operation with a user-visible outcome (created, sent,
|
||||||
|
failed, imported, etc.) must add a `DomainEventType` value and a matching
|
||||||
|
`IEventService` method, and call it from the service at the point of success
|
||||||
|
**and** the point of failure.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- `IEventService` is injected into services that perform user-facing
|
||||||
|
operations (`InvoiceService`, `ReminderService`, `BankingService` callers)
|
||||||
|
— never bypass it by writing directly to `NotificationHub`.
|
||||||
|
- Failure paths must call the `*IssueAsync`/`*Failed` event too, not just
|
||||||
|
succeed-path events — silent failures are the problem this exists to
|
||||||
|
prevent.
|
||||||
|
- Messages are built server-side in `EventService.BuildNotification`, in
|
||||||
|
German, using only `Context` values — keep `Context` populated with
|
||||||
|
everything the message needs (don't rely on the client to look anything
|
||||||
|
up).
|
||||||
|
- Adding a new event type means updating the enum, the `IEventService`
|
||||||
|
interface + `EventService` implementation (trigger method + message
|
||||||
|
branch + `IsFailure` if it's a failure type), and the calling service —
|
||||||
|
in the same change.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Polling a status endpoint from the client**: rejected — adds latency,
|
||||||
|
extra load, and doesn't generalize to background/multi-tab flows as
|
||||||
|
cleanly as a push model.
|
||||||
|
- **Raw exception messages surfaced to the GUI**: rejected — not
|
||||||
|
user-understandable and leaks internal details; `Context` + a rendered
|
||||||
|
German message keeps the boundary between internal errors and
|
||||||
|
user-facing text explicit.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-03
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Notifications/**"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
- "Fuchs/wwwroot/web/**"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0002 — Backend notifications reach the GUI via a SignalR push to every logged-in session
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Domain events (see [0001](0001-domain-events-and-notification-triggers.md))
|
||||||
|
need to reach whichever browser session(s) a user has open, in near
|
||||||
|
real time, without the client polling.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- `NotificationHub` (`Fuchs/Notifications/NotificationHub.cs`) is an
|
||||||
|
`[Authorize]` SignalR `Hub` mapped at `/notifications` in `Program.cs`
|
||||||
|
(`app.MapHub<NotificationHub>("/notifications")`).
|
||||||
|
- `EventService.PublishAsync` sends every `GuiNotification` to
|
||||||
|
`_hub.Clients.All.SendAsync("notification", notification, ...)`. Delivery
|
||||||
|
is currently broadcast to all connected (authenticated) clients, not
|
||||||
|
targeted per-user — any logged-in session receives every notification.
|
||||||
|
- Publish failures are caught and logged (`_logger.LogWarning`) rather than
|
||||||
|
thrown — a notification-delivery failure must never fail the underlying
|
||||||
|
business operation that triggered it.
|
||||||
|
- On the client, `$fis.notifications` (`Fuchs/js/intranet/fis_main.js`)
|
||||||
|
opens the SignalR connection once a logged-in `useraccount_id` is known,
|
||||||
|
listens for the `"notification"` event, and calls `push(notification)`
|
||||||
|
to render a dismissible toast into `#notification_frame`. The toast is
|
||||||
|
styled by `notification.severity` (`"error"` vs `"info"`), giving failures
|
||||||
|
a distinct highlighted appearance from successes.
|
||||||
|
- `GuiNotification.Severity` is derived by `EventService.IsFailure` from the
|
||||||
|
`DomainEventType` — failure event types render as `"error"`, everything
|
||||||
|
else as `"info"`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Any new `DomainEventType` that represents a failure must be added to
|
||||||
|
`EventService.IsFailure` or it will render as a plain info toast instead
|
||||||
|
of being visually flagged.
|
||||||
|
- Because delivery is broadcast (not user-scoped), notifications are not a
|
||||||
|
substitute for private/sensitive data — `Context`/`Message` content must
|
||||||
|
stay appropriate for any logged-in user to see. If per-user targeting
|
||||||
|
becomes necessary, that is a new decision (SignalR groups keyed by user
|
||||||
|
ID), not a silent change to this one.
|
||||||
|
- The hub requires authentication; unauthenticated sessions never connect
|
||||||
|
and never receive notifications.
|
||||||
|
- Frontend rendering logic lives in `fis_main.js`/`fis.js` — keep the built
|
||||||
|
`wwwroot/web/fis.js`/`fis.min.js` in sync via the gulp build (see
|
||||||
|
`CLAUDE.md` Build & Test) whenever the notification client code changes.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Per-user SignalR groups**: more correct long-term but adds group
|
||||||
|
join/leave lifecycle management; deferred until a concrete need for
|
||||||
|
private notifications arises.
|
||||||
|
- **Server-Sent Events / long polling**: rejected — SignalR was already the
|
||||||
|
chosen real-time transport and needs no extra infrastructure.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-08
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Controllers/**"
|
||||||
|
- "Fuchs/Services/**"
|
||||||
|
- "Fuchs/Notifications/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0003 — Any exception that interrupts a user action notifies the user (not just the log)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
[0001](0001-domain-events-and-notification-triggers.md) requires the *modeled*
|
||||||
|
failure paths (invoice/reminder/banking create, send, import) to publish a
|
||||||
|
`*IssueAsync`/`*Failed` event. But an action can also fail through an
|
||||||
|
**unexpected/unmodeled** exception — a bug, a transient dependency error, an
|
||||||
|
edge case nobody wrote a specific failure event for. Those were only landing in
|
||||||
|
the log (`_logger.LogError` + an HTTP 500), so the user saw the action stop with
|
||||||
|
no explanation and no notification. The user asked that *whenever* an exception
|
||||||
|
interrupts a process they initiated, they be told via the notification system.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Every exception that **interrupts a user-initiated action** must surface to the
|
||||||
|
user through `IEventService`, in addition to being logged. Concretely:
|
||||||
|
|
||||||
|
- **Catch-all safety net at the dispatcher.** `IntranetController.Do`'s
|
||||||
|
top-level `catch` publishes a generic `UserIssueAsync("Aktion fehlgeschlagen",
|
||||||
|
…)` for any `Do_Process_*` action that throws without having already published
|
||||||
|
its own (more specific) issue event. It is guarded by
|
||||||
|
`UserIdent.IsAuthenticated` — pre-auth flows (login/logout, anonymous GETs)
|
||||||
|
have no session to notify and the HTTP status already conveys the failure.
|
||||||
|
- **Handlers with their own `catch` must notify locally.** A handler that
|
||||||
|
swallows its exception (returns a 500/error result instead of rethrowing)
|
||||||
|
never reaches the `Do` net, so it must call the matching issue event itself —
|
||||||
|
e.g. `HandleInvoiceGet` calls `InvoiceIssueAsync` before returning 500. Prefer
|
||||||
|
the domain-specific method (`InvoiceIssueAsync`/`ReminderIssueAsync`/
|
||||||
|
`BankingImportIssueAsync`); fall back to `UserIssueAsync` when none fits.
|
||||||
|
- **Message stays user-readable and broadcast-safe.** Per
|
||||||
|
[0002](0002-gui-notification-delivery-signalr.md) notifications are broadcast
|
||||||
|
to every logged-in session, so the German `Message`/`Context` must never carry
|
||||||
|
the raw exception text or anything sensitive — diagnostics go to the log; the
|
||||||
|
user gets a plain "could not be completed" message.
|
||||||
|
|
||||||
|
This deliberately **excludes** operations that do not interrupt a discrete user
|
||||||
|
action: background/best-effort work (blob archiving, startup self-tests,
|
||||||
|
per-entry parse skips) stays log-only, and auto-refreshing read views (dashboard
|
||||||
|
widgets, report reloads) return their error status without a toast, because
|
||||||
|
notifying on every poll cycle would spam the user rather than inform them.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- New `catch` blocks on a request-handling path must be classified: does the
|
||||||
|
exception interrupt a user action? If yes → publish an issue event (specific
|
||||||
|
if one exists, else `UserIssueAsync`). If it is background/best-effort or an
|
||||||
|
auto-poll read → log only, and say so in a comment.
|
||||||
|
- The `Do` net is a backstop, not a replacement for specific events: modeled
|
||||||
|
failures should still publish their contextful `*IssueAsync` at the point of
|
||||||
|
failure so the message names the invoice/reminder/file involved.
|
||||||
|
- Because the net only fires on *unhandled* exceptions (handled flows return
|
||||||
|
rather than rethrow), it does not double-notify the flows that already report
|
||||||
|
their own failures.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Rely solely on 0001's per-flow issue events**: rejected — it leaves every
|
||||||
|
unmodeled/unexpected exception silent, which is exactly the gap the user
|
||||||
|
reported.
|
||||||
|
- **Notify on every failing read/poll too (widgets, reports)**: rejected —
|
||||||
|
auto-refresh would turn a transient backend hiccup into a stream of toasts;
|
||||||
|
those paths surface failure via HTTP status instead.
|
||||||
|
- **Surface the raw exception message to the GUI**: rejected for the same
|
||||||
|
reason as 0001 — not user-understandable, leaks internals, and (per 0002) is
|
||||||
|
visible to every logged-in session.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-03
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Logging/**"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0003 — The solution is equipped with structured diagnostic logging
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Diagnosing issues in a deployed intranet instance requires a durable,
|
||||||
|
inspectable log of what the application did, independent of whether an
|
||||||
|
OpenTelemetry collector is attached (see
|
||||||
|
[0004](0004-opentelemetry-observability.md)) — logging must work
|
||||||
|
out-of-the-box on every environment with zero external dependencies.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- `Fuchs/Logging/FuchsLoggerProvider.cs` implements a custom
|
||||||
|
`ILoggerProvider`/`ILogger` registered via `builder.Logging.AddFuchsLogging()`
|
||||||
|
in `Program.cs`, with `SetMinimumLevel(LogLevel.Debug)`.
|
||||||
|
- Every log line always goes to `Debug.WriteLine` **and** to a rolling text
|
||||||
|
file under `<content root>/logs/` — `AppLog.txt` for
|
||||||
|
`Debug`/`Information`/`Warning`, `ErrorLog.txt` for `Error`/`Critical` —
|
||||||
|
so a failure investigation never depends on a debugger being attached.
|
||||||
|
- Log lines are structured with timestamp, level tag, category, message, and
|
||||||
|
(when present) the exception message + stack trace on continuation lines.
|
||||||
|
- Database logging (`fuchs__admin_logdebug`) is **prepared but disabled** by
|
||||||
|
default (`FuchsLoggerProvider.DatabaseLoggingEnabled = false`) — flip it
|
||||||
|
on only where DB-durable diagnostics are specifically needed, since it
|
||||||
|
adds a DB round-trip per log call.
|
||||||
|
- All logger calls elsewhere in the codebase use `ILogger<T>` injected via
|
||||||
|
DI with **structured** placeholders (`_logger.LogInformation("Sent {InvoiceNumber} to {Email}", ...)`),
|
||||||
|
never interpolated strings — this is enforced project-wide (see Coding
|
||||||
|
Standards / Observability in `CLAUDE.md`).
|
||||||
|
- File writes are best-effort: `AppendToFile` swallows its own exceptions —
|
||||||
|
a logging failure must never crash or interrupt the operation being
|
||||||
|
logged.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- New code must inject `ILogger<T>` and log entry/result/timing/errors for
|
||||||
|
meaningful operations (see [0004](0004-opentelemetry-observability.md) for
|
||||||
|
the matching tracing/metrics requirement) rather than adding ad-hoc
|
||||||
|
`Console.WriteLine`/`Debug.Print` calls.
|
||||||
|
- Because logs always write to `logs/AppLog.txt` and `ErrorLog.txt`
|
||||||
|
regardless of telemetry configuration, these files are the first place to
|
||||||
|
check when OTLP export isn't configured for an environment.
|
||||||
|
- Enabling `DatabaseLoggingEnabled` is a deliberate, explicit choice per
|
||||||
|
environment, not a default — it has a per-call DB cost.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Third-party logging framework (Serilog/NLog)**: rejected for now to
|
||||||
|
avoid an extra dependency for a need the in-box `ILogger` abstraction plus
|
||||||
|
a small custom provider already satisfies; revisit if requirements (e.g.
|
||||||
|
structured JSON sinks, log shipping) outgrow this.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-03
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Observability/**"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
- "Fuchs/Services/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0004 — OpenTelemetry is wired in extensively, without compromising performance
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Beyond text logs (see [0003](0003-structured-diagnostic-logging.md)), the
|
||||||
|
solution needs distributed tracing and metrics to understand performance and
|
||||||
|
behavior in production (PDF render durations, email send outcomes, MFR call
|
||||||
|
volume, banking import throughput) without depending on a debugger or manual
|
||||||
|
log-grepping — while never letting the absence of a collector break or slow
|
||||||
|
down the app.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- All instrumentation is centralized in `Fuchs/Observability/FuchsTelemetry.cs`:
|
||||||
|
one `ActivitySource` (`Fuchs.Intranet`) for tracing and one `Meter` for
|
||||||
|
metrics, exposing named `Counter<long>`/`Histogram<double>` instruments
|
||||||
|
(invoices/reminders/reports rendered, emails/SMS sent/failed, MT940 rows
|
||||||
|
parsed, banking entries skipped/truncated, MFR calls, blob upload
|
||||||
|
success/failure, PDF/report/email durations) plus a `StartActivity` helper.
|
||||||
|
- Wired in `Program.cs` behind `Fuchs:Telemetry:Enabled` (default `true`):
|
||||||
|
`AddOpenTelemetry()` with `AddAspNetCoreInstrumentation`,
|
||||||
|
`AddHttpClientInstrumentation`, `AddSqlClientInstrumentation` for tracing,
|
||||||
|
and `AddAspNetCoreInstrumentation`, `AddHttpClientInstrumentation`,
|
||||||
|
`AddRuntimeInstrumentation` for metrics.
|
||||||
|
- **Collection is always on; export is opt-in.** The OTLP exporter is only
|
||||||
|
added when `Fuchs:Telemetry:OtlpEndpoint` is configured — with no
|
||||||
|
collector present, spans/metrics are simply collected in-process and
|
||||||
|
discarded, so a missing collector can never cause startup failures,
|
||||||
|
exceptions, or blocking calls. Setting `Fuchs:Telemetry:Enabled=false`
|
||||||
|
disables instrumentation entirely.
|
||||||
|
- Per the project-wide Observability standard: every meaningful operation
|
||||||
|
starts an activity via `FuchsTelemetry.StartActivity(...)`, records the
|
||||||
|
matching counter/histogram, and logs entry/result/timing/errors via
|
||||||
|
injected `ILogger<T>` with structured placeholders — this is enforced for
|
||||||
|
new service/handler code, not just the initial wiring.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- New business operations worth observing must add a named instrument to
|
||||||
|
`FuchsTelemetry.cs` rather than creating ad-hoc `ActivitySource`/`Meter`
|
||||||
|
instances elsewhere — one source, one meter, keeps exporters and
|
||||||
|
dashboards simple.
|
||||||
|
- Because export is opt-in, local/dev environments get full in-process
|
||||||
|
instrumentation with zero setup; wiring an OTLP collector is purely an
|
||||||
|
ops-side configuration change (`Fuchs:Telemetry:OtlpEndpoint`), not a
|
||||||
|
code change.
|
||||||
|
- Instrumentation must stay cheap on the hot path — use the existing
|
||||||
|
counters/histograms rather than allocating new tags/dictionaries per call
|
||||||
|
where avoidable, and never make a business operation depend on the
|
||||||
|
exporter succeeding.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Always-on OTLP exporter requiring a collector**: rejected — would make
|
||||||
|
local dev and any environment without a collector fail hard or add
|
||||||
|
latency/timeouts trying to reach one.
|
||||||
|
- **Per-service ActivitySource/Meter instances**: rejected in favor of one
|
||||||
|
centralized `FuchsTelemetry` — avoids scattered instrument names and
|
||||||
|
duplicate registration boilerplate in `Program.cs`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/*DraftNotifier*"
|
||||||
|
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0006 — Invoice draft editing is backend-authoritative over an in-memory cache
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The invoice editor was deliberately **stateless**: the browser held the working
|
||||||
|
model, computed totals/VAT client-side (`invSumUpdate` in `fis.inv_shared.js`) and
|
||||||
|
re-posted the whole `invc` JSON on every preview/save. `EVAL_live_invoice_editing.md`
|
||||||
|
(2026) recommended keeping it that way and **against** a server-cached, SignalR-driven
|
||||||
|
model, because the real-time/co-editing benefits were weak for a single back-office
|
||||||
|
editor.
|
||||||
|
|
||||||
|
The product owner has since decided the trade-off differently and prioritised a
|
||||||
|
**single source of truth in the backend** with server-computed sums, server-side
|
||||||
|
plausibility/consistency checks, in-place PDF preview without re-upload, an automatic
|
||||||
|
change history, and an explicit discard. This decision records that reversal and the
|
||||||
|
architecture chosen to implement it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
While a user edits an invoice draft, the authoritative state lives **server-side** in
|
||||||
|
an in-memory `InvoiceDraftSession` (`Fuchs/code/InvoiceDraftSession.cs`), held by the
|
||||||
|
singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftService`
|
||||||
|
(`InvoiceDraftEditService`). The browser is a pure view/input layer.
|
||||||
|
|
||||||
|
- **Truth & calculation on the server.** `InvoiceDraftCalculator` is the pure,
|
||||||
|
unit-tested port of the former client-side math (`quantChange` + `setVat` +
|
||||||
|
`invSumUpdate`), including per-line net/VAT/service-value multiplication
|
||||||
|
(`RecomputeLineValues`), the §13b reverse-charge rule and VAT-per-rate grouping. The
|
||||||
|
browser performs **no arithmetic whatsoever** — not even a single line's
|
||||||
|
`net = qty × price` — it only renders the server's `req`/`sums`.
|
||||||
|
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
|
||||||
|
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
|
||||||
|
The server mutates the session, recomputes, validates, bumps a version, and pings the
|
||||||
|
editing browser (`draftReady`) to re-fetch `inv/dstate`. See
|
||||||
|
[0007](0007-targeted-draft-signalr-groups.md) for the targeted-signal transport.
|
||||||
|
- **Cache-only until Zwischenspeichern/Finalise.** Opening builds the session (from a
|
||||||
|
brand-new payload or by reloading a DB draft); edits touch only the cache. `dsave`
|
||||||
|
flushes the session to the DB by reusing the existing
|
||||||
|
`IInvoiceService.RegisterInvoiceAsync` — **no new persistence path** — and reports
|
||||||
|
success/failure through the existing `IEventService` (ADR 0001). Finalise continues
|
||||||
|
through `req/sconf`.
|
||||||
|
- **Preview from cache.** `inv/dpreview` renders the draft PDF straight from the session
|
||||||
|
(synthesised registration), with no client upload.
|
||||||
|
- **Automatic change history.** Every applied patch appends a `ChangeHistoryEntry`
|
||||||
|
(cache-only, never persisted); `inv/dhistory` exposes it for the "Änderungshistorie"
|
||||||
|
dialog.
|
||||||
|
- **Idle lifecycle with user warning.** `InvoiceDraftExpiryService` warns the editing
|
||||||
|
browser before a session's idle TTL lapses (`draftExpiring`) and, on eviction, tells
|
||||||
|
it to close the editor with a reason (`draftClosed`). TTL and warning lead are under
|
||||||
|
`Fuchs:DraftEditing`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The server is now **stateful for in-progress drafts**. This is acceptable for a
|
||||||
|
single-instance deployment; **scale-out requires sticky sessions or a distributed
|
||||||
|
cache/SignalR backplane** — none exist today, so this is a documented limitation, not
|
||||||
|
a silent assumption.
|
||||||
|
- New editor interactions must be modelled as a **delta** applied server-side (add a
|
||||||
|
case in `InvoiceDraftEditService.ApplyDelta` + calculator handling), never as a new
|
||||||
|
client-side calculation. Do not reintroduce client-side totals.
|
||||||
|
- `FdsInvoiceData` stays a pure data holder; `InvoiceDraftSession` is likewise a data
|
||||||
|
holder, with all logic in the service/calculator (mirrors the existing service split).
|
||||||
|
- Reminders (Mahnungen) are intended to follow the identical pattern as a second phase;
|
||||||
|
this decision covers invoices first (the pilot) and applies to the reminder mirror
|
||||||
|
when built.
|
||||||
|
- `EVAL_live_invoice_editing.md` and `INVOICE_LIFECYCLE.md` §4/§10 (the "stateless
|
||||||
|
editor" invariant) are superseded by this decision for the draft-editing flow and have
|
||||||
|
been annotated accordingly.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep the stateless editor** (the prior recommendation): rejected by the product
|
||||||
|
owner in favour of a backend single source of truth.
|
||||||
|
- **Full bidirectional SignalR hub for commands too**: rejected — edits as POSTs reuse
|
||||||
|
the existing controller/auth pattern and avoid a command reconnect/replay protocol; the
|
||||||
|
hub carries only coordination signals.
|
||||||
|
- **Write-through to the DB on every edit**: rejected — conflicts with the
|
||||||
|
"Zwischenspeichern = persist the cache" semantics and adds DB load; the cache is the
|
||||||
|
truth until an explicit save/finalise.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
|
- "Fuchs/Notifications/IDraftNotifier.cs"
|
||||||
|
- "Fuchs/Notifications/DraftNotifier.cs"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0007 — Draft-editing signals are targeted via a dedicated hub with per-draft groups
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Backend-authoritative draft editing (ADR 0006) needs to notify **exactly the one
|
||||||
|
browser** editing a given draft that its cached state changed, is about to expire, or
|
||||||
|
was closed. The existing `NotificationHub` (ADR 0002) deliberately **broadcasts** every
|
||||||
|
business toast to all logged-in sessions and explicitly deferred per-user/targeted
|
||||||
|
delivery as "a new decision". Draft coordination pings are high-frequency, per-editor,
|
||||||
|
and must not spray to every session.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Draft signals use a **dedicated** SignalR hub, `DraftPreviewHub`, mapped at
|
||||||
|
`/draftpreview` (separate from `NotificationHub` at `/notifications`). Targeting is by
|
||||||
|
**SignalR group named after the draft's session token**:
|
||||||
|
|
||||||
|
- The client calls the hub methods `JoinDraft(token)` / `LeaveDraft(token)` to
|
||||||
|
subscribe/unsubscribe its connection to a draft's group. The hub carries **no
|
||||||
|
commands** — only group membership (edits are POSTs; see ADR 0006).
|
||||||
|
- The server sends via `IDraftNotifier` (`DraftNotifier`) to `Clients.Group(token)`:
|
||||||
|
`draftReady{token,version}` (re-fetch), `draftExpiring{token,secondsLeft}` (idle
|
||||||
|
warning), `draftClosed{token,reason}` (session evicted/discarded → close the editor).
|
||||||
|
- Like `EventService`, delivery failures are logged and swallowed — a missed
|
||||||
|
coordination ping must never fail the underlying operation; the client also re-syncs on
|
||||||
|
reconnect and on its next POST.
|
||||||
|
|
||||||
|
Business success/failure messages for draft operations (e.g. "Zwischenstand
|
||||||
|
gespeichert") continue to flow through `IEventService`/`NotificationHub`, **not** this
|
||||||
|
hub — the two channels stay separate.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The session **token doubles as the group name**; it is an opaque GUID and must not
|
||||||
|
encode sensitive data. Any browser that knows a token can join its group, so tokens
|
||||||
|
must be treated as capabilities and only handed to the authenticated editor that opened
|
||||||
|
the draft.
|
||||||
|
- Adding a new draft signal means adding a method to `IDraftNotifier` + `DraftNotifier`
|
||||||
|
and a client handler in `$fis.draft` — not overloading the business notification path.
|
||||||
|
- ADR 0002 is unchanged: `NotificationHub` stays broadcast-only for toasts. This hub is
|
||||||
|
the answer to its "if per-user targeting becomes necessary, that is a new decision".
|
||||||
|
- Multi-instance scale-out needs a SignalR backplane for group delivery — same limitation
|
||||||
|
as ADR 0006.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Reuse `NotificationHub` with groups**: rejected — it would entangle broadcast toasts
|
||||||
|
with targeted, high-frequency editing pings and force ADR 0002's broadcast contract to
|
||||||
|
change. A separate hub keeps the concerns and their decisions independent.
|
||||||
|
- **Per-user groups (by account id)**: rejected — a user may open two drafts/tabs;
|
||||||
|
per-draft-token groups target the precise editor and naturally support that.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-15
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/Services/ReminderDraft*"
|
||||||
|
- "Fuchs/Services/IReminderDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftSession.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0008 — Invoices and reminders (all kinds) are fully backend-authoritative; PDF and online editor must render identical content
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADR [0006](0006-backend-authoritative-draft-editing.md) established the backend-authoritative
|
||||||
|
draft-editing model for invoices and noted reminders were "intended to follow the identical
|
||||||
|
pattern as a second phase". Both are now implemented (`InvoiceDraftEditService` /
|
||||||
|
`ReminderDraftEditService`). In practice, ambiguity kept resurfacing about *which* invoice/
|
||||||
|
reminder kinds this covers and *which* kinds of change qualify as "must be computed server-side":
|
||||||
|
e.g. whether a purely presentational client-side re-render (set-price display toggle, item
|
||||||
|
reordering, position renumbering) was allowed to keep any client-side math, and whether this
|
||||||
|
applies uniformly to every invoice type (regular `r`, partial/Abschlag `i`, final `f`, storno
|
||||||
|
`c`) and every reminder stage, not just the pilot "regular invoice" flow. This decision closes
|
||||||
|
that ambiguity explicitly.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
**Every invoice (all `InvoiceType` kinds: regular, partial/Abschlagsrechnung, final/
|
||||||
|
Schlussrechnung, Storno/credit) and every reminder (all reminder stages/Mahnstufen) is
|
||||||
|
backend-authoritative while being drafted or previewed.** This generalises and makes explicit
|
||||||
|
what ADR 0006 already implied for the pilot flow:
|
||||||
|
|
||||||
|
- **Any calculation** (net/VAT/gross totals, per-rate VAT grouping, service-refund figures,
|
||||||
|
§13b reverse-charge suppression, set-price sums, open-amount for reminders, position/line
|
||||||
|
numbering) is performed exclusively by the server (`InvoiceDraftCalculator`,
|
||||||
|
`ReminderDraftCalculator`, `InvoiceSetPricing`). The browser never sums, subtracts, or
|
||||||
|
otherwise derives a monetary or positional value — it only displays server-computed values.
|
||||||
|
This includes the single-line arithmetic that used to run in `quantChange`/`setVat`
|
||||||
|
(`net_val = qty × price`, `vat_val = net_val × rate`, service-net/-VAT splits): those
|
||||||
|
handlers now only post the raw, unmultiplied field the user typed (`qn`/`v`/`vat`) and the
|
||||||
|
server (`InvoiceDraftCalculator.RecomputeLineValues`) computes every derived line value.
|
||||||
|
Likewise the invoice footer (net/VAT-by-rate/gross), the per-block "isum" cell, and the
|
||||||
|
service-refund note figures are rendered exclusively from `dstate.sums` (`$inv.d.footer`);
|
||||||
|
`$inv.invSumUpdate` no longer accumulates any of these — it only reassembles the row
|
||||||
|
contract array needed to post `req` to the server and (on first load) seeds the session.
|
||||||
|
- **Any setting** (§13b flag, set-pricing display mode, payment terms, contact, custom values,
|
||||||
|
…) is applied server-side via a named `InvoiceDraftDelta`/`ReminderDraftDelta` target and
|
||||||
|
reflected back through `dstate`. The client never mutates its local model as the source of
|
||||||
|
truth for a setting; it optimistically reflects the *request* but always re-renders from the
|
||||||
|
next `dstate`/`draftReady` refresh.
|
||||||
|
- **Any text change** (recipient email/address, invoice title, provision location/period,
|
||||||
|
section headings, item name/description/notes) is sanitised and stored server-side
|
||||||
|
(`InvoiceDraftEditService.HtmlToPlain` et al.); the server's stored value is the one that
|
||||||
|
reaches the PDF and any reloaded draft.
|
||||||
|
- **Any reordering** (drag-reorder of service-request blocks/sections, drag-reorder of item
|
||||||
|
rows within a block) is committed as a `block.order` (or equivalent) delta; the server
|
||||||
|
performs the actual reorder and renumbers positions (`InvoiceDraftCalculator.RecomputePositions`).
|
||||||
|
The client's drag interaction is input only — the rendered order after a refresh is the
|
||||||
|
server's order, not whatever the browser left in the DOM mid-drag.
|
||||||
|
- **Irreversible one-way conversions** (e.g. "Auf Setpreis umstellen" — switching a set's
|
||||||
|
member items from individual prices to a single set price) are likewise backend-only
|
||||||
|
operations (`item.setprice` delta / `InvoiceDraftEditService.ApplyItemSetPrice`), never
|
||||||
|
computed or applied in the browser.
|
||||||
|
- **The PDF must render 100% the same information and content as the online editor at any
|
||||||
|
given moment.** Both consume the identical authoritative session data:
|
||||||
|
- The online editor renders `dstate`'s `req`/`sums`/`setDisplay`/`notes` — all server-computed.
|
||||||
|
- The PDF preview (`inv/dpreview`, `rem/dpreview`) renders straight from the same cached
|
||||||
|
session via a synthesised registration (`InvoiceDraftEditService.RenderPreview` /
|
||||||
|
`ReminderDraftEditService`'s reminder equivalent) — **not** from a separate client upload
|
||||||
|
or a re-derived model.
|
||||||
|
- `FuchsPdf.BuildInvoiceNotes` (notice paragraphs) is called identically for both the
|
||||||
|
editor's `notes` array and the PDF body, so intro/closing texts can never drift between
|
||||||
|
the two renderings.
|
||||||
|
- Any new editor-visible fact (a new total, a new flag, a new note) must be added to the
|
||||||
|
shared session/service layer once, not duplicated as separate editor-only and PDF-only
|
||||||
|
logic.
|
||||||
|
- This applies for the full lifecycle while a document is a draft (open → edit → preview →
|
||||||
|
Zwischenspeichern) up to finalise; a finalised, persisted invoice/reminder is immutable
|
||||||
|
and is rendered straight from its stored DB data (no draft session involved) — that path
|
||||||
|
already has no client-side math to begin with.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- New invoice/reminder editor features must be modelled as a server-side delta + calculator
|
||||||
|
change, exactly as ADR 0006 already requires; this decision removes any residual excuse to
|
||||||
|
special-case a "just this one is presentational, do it in JS" shortcut for reordering,
|
||||||
|
display-mode toggles, or one-way conversions.
|
||||||
|
- Any PDF-only or editor-only special-casing found in review is a bug against this decision —
|
||||||
|
the shared session/service must be extended so both renderers read the same value/flag.
|
||||||
|
- Reminder "Mahnstufen" and every invoice type share this obligation; there is no partial/
|
||||||
|
Abschlagsrechnung, Schlussrechnung, or Storno exemption while such a document is still a
|
||||||
|
draft going through the same `dopen`/`dpatch`/`dpreview`/`dsave` flow.
|
||||||
|
- Test coverage for the cache/session layer (`InvoiceDraftEditService`, `ReminderDraftEditService`,
|
||||||
|
`InvoiceDraftCalculator`, `InvoiceSetPricing`) must exercise every mutating operation
|
||||||
|
(text edits, reordering, all three set-pricing display modes, the set-price conversion,
|
||||||
|
multi-rate VAT sums, full recompute) against mock datasets, since this is now the single
|
||||||
|
place all of these behaviours are guaranteed correct — see `Fuchs.Tests/InvoiceDraftServiceTests.cs`,
|
||||||
|
`Fuchs.Tests/ReminderDraftServiceTests.cs`, `Fuchs.Tests/InvoiceDraftCalculatorTests.cs`,
|
||||||
|
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Scope this only to the invoice pilot flow** (leave reminders/other invoice kinds
|
||||||
|
ambiguous): rejected — the ambiguity itself was the problem being fixed; the underlying
|
||||||
|
session/service code already treats all kinds uniformly, so documenting anything narrower
|
||||||
|
would misrepresent the code.
|
||||||
|
- **Allow "purely cosmetic" client-side math for reordering/display toggles**: rejected —
|
||||||
|
history showed exactly this exception is where drift crept in (e.g. the set-price toggle
|
||||||
|
originally computed sums in the browser before being moved server-side); no exception is
|
||||||
|
granted.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-14
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||||
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "Fuchs/js/intranet/**"
|
||||||
|
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADRs [0006](0006-backend-authoritative-draft-editing.md) and
|
||||||
|
[0008](0008-invoices-and-reminders-fully-backend-authoritative.md) made draft editing
|
||||||
|
backend-authoritative. Under that model the set-price feature had **three** functions, of
|
||||||
|
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
|
||||||
|
whole-invoice **display modes** (`SetDisplayMode.SetPrice`/`SetOnly`): a non-mutating,
|
||||||
|
render-time transform (`InvoiceSetPricing.Build`) over explicit `type == "set"` header items
|
||||||
|
and their `SetItmId` members, persisted only as an `admin.setmode` flag. ADR 0008 calls them
|
||||||
|
"display-mode toggles" and treats them as presentational.
|
||||||
|
|
||||||
|
The product owner has redefined those two menu functions. They are **not** display toggles and
|
||||||
|
they are **not** keyed on set-item membership:
|
||||||
|
|
||||||
|
- Their grouping is the **service request** (`ServiceRequestId` = the editor's tbody block),
|
||||||
|
never `SetItmId`. Every block is treated as one set, whether or not it contains any
|
||||||
|
`type == "set"` item.
|
||||||
|
- Applying a mode is an **irreversible data change** written hard into the cached draft
|
||||||
|
dataset — not a reversible view flag. There is no toggle back; the user adjusts the result
|
||||||
|
by hand afterwards.
|
||||||
|
|
||||||
|
This decision records that redefinition. It **refines** ADR 0008's characterisation of these
|
||||||
|
two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR
|
||||||
|
0008's broader rule — every calculation server-side, and the PDF renders 100% the same content
|
||||||
|
as the editor — remains fully in force and is not superseded.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
There are three distinct set-price operations, kept clearly separated:
|
||||||
|
|
||||||
|
1. **Inline set-item switch — unchanged.** The row context button on a single `type == "set"`
|
||||||
|
item (`$inv.toSetPrice` → `item.setprice` delta → `InvoiceDraftEditService.ApplyItemSetPrice`).
|
||||||
|
It is `SetItmId`-based, sums the header's members onto the header, sets the members' prices to
|
||||||
|
`null` (empty cell, excluded from the sum — consistent with modes 2 & 3, not `0`), and is
|
||||||
|
one-way. This is the **only** set-price operation that reads `SetItmId`. The button is shown —
|
||||||
|
and the operation available — **only** for items that are `type == "set"` **and** carry a
|
||||||
|
`SetItmId` (and are still unconverted, i.e. own price `0`); an item missing either condition
|
||||||
|
never offers it.
|
||||||
|
|
||||||
|
2. **"Set mit Preis" (menu) — per-block, irreversible mutation.** Applied server-side to the
|
||||||
|
cached `InvoiceDraftSession`, grouped by `ServiceRequestId`. For **every** service-request
|
||||||
|
block:
|
||||||
|
- Insert one dedicated, emphasised **set row** at the top of the block, carrying the block's
|
||||||
|
aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real,
|
||||||
|
editable line item with its own id, so the user can manually change the set value afterwards
|
||||||
|
as an ordinary item edit.
|
||||||
|
- **Null out** the price of every existing item row in the block (set the price fields to
|
||||||
|
`null`, **not** `0`) so the row renders with an **empty** price/total cell. `null` and `0`
|
||||||
|
are semantically distinct here: `null` means "no price — render an empty cell and exclude
|
||||||
|
from the block sum", whereas `0` would legitimately print `0,00 €`. The rows themselves are
|
||||||
|
retained.
|
||||||
|
|
||||||
|
3. **"Nur Set mit Preis" (menu) — per-block, irreversible mutation.** As above, grouped by
|
||||||
|
`ServiceRequestId`. For every block:
|
||||||
|
- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
|
||||||
|
- **Remove** every existing item row in the block from the dataset entirely (the lines are
|
||||||
|
gone, not merely hidden).
|
||||||
|
|
||||||
|
Properties common to the two menu modes (2 and 3):
|
||||||
|
|
||||||
|
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` (the "cache
|
||||||
|
dataset") as a mutating `InvoiceDraftDelta`, computed on the server (never in the browser).
|
||||||
|
There is no render-time `admin.setmode` grouping flag driving how lines are shown, and no
|
||||||
|
reversible toggle.
|
||||||
|
- **Irreversible.** There is no patch to undo it. The only ways back are discarding the draft
|
||||||
|
(reloads the DB state) or hand-editing the resulting rows.
|
||||||
|
- **`SetItmId` is irrelevant.** Membership is the block, full stop.
|
||||||
|
- **Total unchanged.** The inserted set row's value equals the sum of the block's original items,
|
||||||
|
which are then excluded from the sum — either because their price is `null` (mode 2, `null`
|
||||||
|
counts as no contribution) or because they are gone (mode 3). So `InvoiceBalance`/
|
||||||
|
`InvoiceBalance_net` are unaffected.
|
||||||
|
- **Editor and PDF render identically** (ADR 0008): the dedicated set row is emphasised in both,
|
||||||
|
and both read the same mutated session.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- For the two menu modes, `InvoiceSetPricing` stops being a non-mutating render transform over
|
||||||
|
`type == "set"` groups; the grouping/insert/blank/remove is a real mutation in
|
||||||
|
`InvoiceDraftEditService`, keyed on the block. The inline `item.setprice` switch (operation 1)
|
||||||
|
remains the sole `SetItmId`-based, set-item-scoped operation.
|
||||||
|
- The previous `admin.setmode` display-flag model for these two modes — persisted `setmode:`
|
||||||
|
`InvoiceOptions` token, "Set-Preisanzeige menu entry disappears while unset", `Build(...)`
|
||||||
|
choosing `ShowPrice` per member at render time — is retired. Because the operation is a
|
||||||
|
one-shot irreversible mutation, there is no persisted display state to toggle. Any residual
|
||||||
|
`setmode:` token must degrade safely (ignored) and is no longer (re-)persisted.
|
||||||
|
- New/changed behaviour must be modelled as a server-side delta + calculator/service change and
|
||||||
|
covered by tests (`InvoiceDraftServiceTests`, `InvoiceSetPricingTests`): for each menu mode,
|
||||||
|
assert the inserted set row's value equals the block sum, the total is unchanged, mode 2
|
||||||
|
nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op.
|
||||||
|
- Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008
|
||||||
|
reminder mirror).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
Each of the following was raised and **explicitly decided against** as part of accepting this
|
||||||
|
decision — they are rejected choices, not open options to revisit without a superseding ADR:
|
||||||
|
|
||||||
|
- **Keep them as non-mutating display toggles** (the prior design): **explicitly rejected** by the
|
||||||
|
product owner — the set price must be a real, hand-editable value baked into the document, and
|
||||||
|
"Nur Set mit Preis" must actually drop the member lines, not just hide them.
|
||||||
|
- **Zero the blanked members' prices instead of nulling them**: **explicitly rejected** — `0` is
|
||||||
|
ambiguous (it prints `0,00 €`), so the frontend could not tell an empty cell from a genuine
|
||||||
|
zero price. Blanked members are set to `null` precisely to make "no price" unambiguous.
|
||||||
|
- **Carry the set price on the existing block heading row** instead of a dedicated row:
|
||||||
|
**explicitly rejected** — a separate, individually-editable set row keeps the section-heading
|
||||||
|
semantics intact and gives the user a concrete line to adjust afterwards.
|
||||||
|
- **Group by `SetItmId`/`type == "set"` headers like the inline switch:** **explicitly rejected**
|
||||||
|
— the menu modes present each *service request* as one set, independent of any mfr set-item;
|
||||||
|
conflating the two groupings is exactly the ambiguity this decision removes.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-15
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs_DataService/**"
|
||||||
|
- "Fuchs/Services/PeriodicHostedService.cs"
|
||||||
|
- "Fuchs/Program.cs"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0010 — MFR ERP sync runs in-process in the web app; Fuchs_DataService is a library
|
||||||
|
|
||||||
|
## Context
|
||||||
|
`Fuchs_DataService` was a standalone console/Windows Service hosted by **Topshelf**.
|
||||||
|
It carried its own `appsettings.json`, its own file-based configuration bootstrap
|
||||||
|
(`FdsConfig.Initialize()` reading the file), its own logging provider
|
||||||
|
(`FdsLoggerProvider`/`AddFdsLogging`), and a machine-name guard in `Main()` that
|
||||||
|
disabled the service on developer PCs. In practice the web app (`Fuchs`) already
|
||||||
|
referenced the project, already called `fds.FdsConfig.Initialize(builder.Configuration)`,
|
||||||
|
already registered `IFdsMfr`, and already created `FdsMfrClient` via
|
||||||
|
`IMfrClientFactory` — so the sync logic and the web app were sharing the same code
|
||||||
|
and the same connection strings while the service kept a second, parallel copy of
|
||||||
|
configuration/logging/hosting.
|
||||||
|
|
||||||
|
Maintaining a separate process, a second `appsettings.json` (duplicating connection
|
||||||
|
strings + MFR credentials), Topshelf, and a machine-name guard added drift risk and
|
||||||
|
operational overhead for no benefit the web host couldn't provide.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- **`Fuchs_DataService` is now a class library** (no `OutputType Exe`, no Topshelf,
|
||||||
|
no own `appsettings.json`, no `install.bat`/`un-install.bat`, no
|
||||||
|
`System.Configuration.ConfigurationManager`). It contains only the MFR sync
|
||||||
|
logic (`FdsMfr`/`IFdsMfr`, `FdsMfrClient`), the DATEV/zip helpers, `FdsShared`,
|
||||||
|
`FdsDebug`, and `FdsConfig`.
|
||||||
|
- **The host owns configuration.** `FdsConfig` keeps only
|
||||||
|
`Initialize(IConfiguration)` (the file-based overload is gone). The Fuchs web app
|
||||||
|
injects its `IConfiguration`; connection strings (`fuchs_fds_ConnectionString`)
|
||||||
|
and MFR credentials (`Fds:MFR_*`, Key Vault-managed) come from Fuchs.
|
||||||
|
- **The host owns logging.** `FdsLoggerProvider`/`AddFdsLogging` were removed; the
|
||||||
|
library uses only `ILogger`/`ILoggerFactory` injected from Fuchs's logging
|
||||||
|
(`AddFuchsLogging`). The library depends only on
|
||||||
|
`Microsoft.Extensions.Logging.Abstractions` + `Microsoft.Extensions.Configuration.Binder`.
|
||||||
|
- **The sync runs in-process.** `PeriodicHostedService` (the generic
|
||||||
|
multi-job `BackgroundService`) moved to `Fuchs/Services/` and is registered in
|
||||||
|
`Program.cs` as a hosted service. The single `MfrSync` job calls
|
||||||
|
`UpdateIfNecessary_async` → `UpdateRequested_async` → `GetInvoiceFiles_async`.
|
||||||
|
- **A config flag replaces the machine-name guard.** Registration is gated by
|
||||||
|
`Fds:SyncEnabled` (default `false` when unset): `true` in production
|
||||||
|
`appsettings.json`, `false` in `appsettings.Development.json`, so developer
|
||||||
|
machines never poll the ERP. Interval (`Fds:ExecutionFrequency_Minutes`, default
|
||||||
|
15) and debug verbosity (`Fds:DebugDetails`) also come from the `Fds` section.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- One process, one configuration surface, one logging pipeline. The sync inherits
|
||||||
|
the web app's OpenTelemetry, DI, and lifetime automatically.
|
||||||
|
- **Instance fan-out is a consideration:** the sync now runs in *every* web instance
|
||||||
|
where `Fds:SyncEnabled` is true. The intranet is deployed single-instance, so this
|
||||||
|
is acceptable; if Fuchs is ever scaled out, gate the sync to a single instance
|
||||||
|
(leader election / dedicated instance flag) to avoid concurrent MFR polling.
|
||||||
|
- Enabling/disabling the sync per environment is now a config change, not a
|
||||||
|
redeploy of a separate service.
|
||||||
|
- `Fuchs_DataService` is intentionally kept as a separate project (not folded into
|
||||||
|
`Fuchs`) so the sync logic stays isolated and unit-testable; `Fuchs.Tests` covers
|
||||||
|
it via `InternalsVisibleTo`.
|
||||||
|
- **The `Squid-Box.SevenZipSharp` native dependency (and the bundled `7z.dll`) was
|
||||||
|
removed** from both `Fuchs_DataService` and `Fuchs`. The only live archive use — the
|
||||||
|
DATEV export — is a plain, unencrypted zip, now produced via the native
|
||||||
|
`OCORE.zip.filesToZipArchive` (`System.IO.Compression`). The 7-Zip-only paths
|
||||||
|
(`.7z`/LZMA2, AES-encrypted archives, extraction, `FastAppend`) had no callers.
|
||||||
|
Trade-off accepted: `System.IO.Compression` cannot produce `.7z` or password/AES
|
||||||
|
archives; if that is ever required, a compression library must be reintroduced.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Native `dotnet` Worker Service (separate process).** Would modernize off
|
||||||
|
Topshelf but keep the duplicate-config/duplicate-logging/second-process problem.
|
||||||
|
Rejected because the web app already hosts everything the sync needs.
|
||||||
|
- **Fold the code directly into `Fuchs`.** Rejected to preserve a clean, separately
|
||||||
|
testable sync library and avoid enlarging the web project.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-16
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||||
|
- "Fuchs/Services/SystemStatusService.cs"
|
||||||
|
- "Fuchs/Services/ISystemStatusService.cs"
|
||||||
|
- "Fuchs/Services/SystemStatusModels.cs"
|
||||||
|
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||||
|
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0011 — Admin module gated on `fds_sys` > 4
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Operators needed an in-app view of the running deployment's health: which host it
|
||||||
|
runs on, whether SQL Server / Azure Key Vault / Azure Blob Storage / the MFR ERP are
|
||||||
|
reachable, how the email service is configured (including the dev/test
|
||||||
|
`Fuchs:Email:OverrideRecipient` redirect), and a way to send a test email. The
|
||||||
|
`StartupSelfTestService` already probes most of these once at startup and writes the
|
||||||
|
result to the log — but that is invisible to a logged-in operator and cannot be re-run
|
||||||
|
on demand. This is privileged, infrastructure-revealing information (server names,
|
||||||
|
account names, connectivity state) that must not be exposed to ordinary users.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- Add an **Admin** module (`admin`) alongside the existing invoice/reminder/report/banking
|
||||||
|
modules, following the same frontend module contract (`init:admin` → `admin/auth`
|
||||||
|
returns `{ manage }` → lazy-load `/web/fis.admin.de.js` + `/web/fis.admin.css` →
|
||||||
|
`init2()`).
|
||||||
|
- Access is gated on the caller's **`fds_sys` module authorization being strictly greater
|
||||||
|
than 4** (`fis_getModuleAuth('fds_sys', authuser) > 4`). The menu button is only rendered,
|
||||||
|
and the module script only fetched, for such users; **every** Admin data endpoint
|
||||||
|
additionally re-checks the level server-side (defense in depth) and returns 401 otherwise.
|
||||||
|
`admin/auth` is the sole endpoint that answers for unauthorized users too (it returns
|
||||||
|
`manage: 0`), so the frontend can decide whether to render the module at all.
|
||||||
|
- Backend diagnostics live in a new DI-registered `ISystemStatusService` (scoped). It exposes
|
||||||
|
a passive `GetInfo()` snapshot and live, never-throwing connectivity probes
|
||||||
|
(`database`, `keyvault`, `blob`, `mfr`) plus `SendTestEmailAsync`. The test email goes
|
||||||
|
through the normal `IComService` pipeline, so the `OverrideRecipient` safety net applies
|
||||||
|
exactly as for any other outbound mail.
|
||||||
|
- The service never returns secrets — only presence flags and non-sensitive values
|
||||||
|
(server/catalog/login name, storage account name, error text). Responses are serialized
|
||||||
|
camelCase to match the frontend's lowercase convention.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- `fds_sys` is now a security-relevant authorization key: granting it a value > 4 exposes
|
||||||
|
infrastructure status and the ability to send test emails. Provision it deliberately.
|
||||||
|
- `SystemStatusService` is intentionally **separate** from `StartupSelfTestService` rather
|
||||||
|
than a shared refactor: the startup service's probe methods are `protected virtual` and its
|
||||||
|
tests override them, so folding both onto one probe surface would have broken that contract.
|
||||||
|
The two therefore duplicate a little probe logic (Key Vault secret read, MFR `GetEntities`,
|
||||||
|
SQL `SELECT`); keep them behaviourally aligned when either changes.
|
||||||
|
- New probes (or new status facts) belong in `SystemStatusService` behind the same
|
||||||
|
`SystemProbeResult` / `SystemInfoSnapshot` shapes; add the component id to
|
||||||
|
`SystemStatusService.Components` and a card in `fis.admin.js`.
|
||||||
|
- Blob connectivity is checked via a new `IBlobStorageService.CheckConnectivityAsync`
|
||||||
|
(account-info request); it too never throws.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Reuse `StartupSelfTestService` directly.** Rejected — see Consequences; its virtual/overridden
|
||||||
|
probe surface is owned by its tests, and it is a one-shot `BackgroundService`, not a
|
||||||
|
request-scoped query service.
|
||||||
|
- **Gate only in the frontend (hide the menu button).** Rejected — the endpoints would still be
|
||||||
|
reachable by crafting the POST. Server-side enforcement on every endpoint is required.
|
||||||
|
- **A separate `fds_admin`/new module-auth key.** Rejected — `fds_sys` already models
|
||||||
|
system-level privilege; reusing it avoids a parallel permission to provision.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-07-17
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "Fuchs/Services/FuchsPdfService.cs"
|
||||||
|
- "Fuchs/Services/InvoiceService.cs"
|
||||||
|
- "Fuchs/Services/ERechnungSettings.cs"
|
||||||
|
- "eRechnungLib/**"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0012 — eRechnung uses a single PDF/A engine (eRechnungLib owns PDF/A-3)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADR 0005 established that invoices are emitted as eRechnung by embedding the CII
|
||||||
|
XML into the FuchsPdf-rendered visual PDF via `eRechnungLib.ToZugferd(...)`.
|
||||||
|
|
||||||
|
Two hard requirements then surfaced: the emitted invoice must (a) satisfy the
|
||||||
|
**ZUGFeRD 2.4 / Factur-X** standard for **DATEV** ingestion, and (b) be a
|
||||||
|
**formally verifiable PDF/A-3** (veraPDF-clean).
|
||||||
|
|
||||||
|
A conflict became apparent in the rendering pipeline. `FuchsPdf.DocToPdfBytes`
|
||||||
|
post-processes its MigraDoc/PdfSharp output to **PDF/A via Spire**
|
||||||
|
(`OCORE…pdfAFileContent`). `eRechnungLib`'s `FacturXPdfBuilder` **also** produces
|
||||||
|
a PDF/A layer (raises to PDF 1.7, writes `pdfaid` XMP, adds an sRGB output
|
||||||
|
intent, embeds `factur-x.xml` in `/AF`). Feeding a Spire-made PDF/A into
|
||||||
|
eRechnungLib stacks **two** PDF/A conversions → duplicate/*conflicting* output
|
||||||
|
intents and `pdfaid` markers, which veraPDF rejects. Spire's output is also
|
||||||
|
PDF/A-1/2 and does **not** carry the `/AF` associated-file structure ZUGFeRD
|
||||||
|
requires (PDF/A-3).
|
||||||
|
|
||||||
|
Separately, `eRechnungLib` shipped **no** sRGB ICC profile, so its output intent
|
||||||
|
was silently omitted (`PDFA-ICC` warning) — never formally PDF/A-3 conformant.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- **eRechnungLib is the single PDF/A engine for eRechnung output.** The invoice
|
||||||
|
visual PDF is rendered by `FuchsPdf` **without** the Spire PDF/A step and handed
|
||||||
|
to `eRechnungLib.ToZugferd(ZugferdProfile.EN16931, rawPdfBytes)`, which owns the
|
||||||
|
one PDF/A-3 conversion and embeds the CII XML. The render-only path is
|
||||||
|
`FuchsPdf.DocToPdfBytesRaw` / `IPdfService.DocToPdfBytesRaw` (fonts still
|
||||||
|
embedded via `OCOREFontResolver`, no PDF/A post-processing).
|
||||||
|
- **Spire stays only for on-screen preview rasterisation** (`DocToImageCollection`
|
||||||
|
/ `BytesToImageCollection` for `sprep`/`sedit`). It is **not** part of the
|
||||||
|
eRechnung file's PDF/A path. `DocToPdfBytes` (render + Spire PDF/A) is unchanged
|
||||||
|
and remains the path for non-eRechnung documents (e.g. reminders).
|
||||||
|
- **A bundled sRGB ICC profile is required.** `eRechnungLib` ships
|
||||||
|
`Resources/Color/sRGB.icc` (sRGB IEC61966-2.1) so the PDF/A output intent is
|
||||||
|
always attached. A caller may override it per conversion via
|
||||||
|
`ConversionOptions.IccProfile`.
|
||||||
|
- **Profile is EN 16931.** MINIMUM / BASIC WL are not offered for real invoices —
|
||||||
|
DATEV needs at least EN 16931 (COMFORT) for full booking.
|
||||||
|
- **Formal conformance is verified by an external online service** (veraPDF for
|
||||||
|
PDF/A-3 + a ZUGFeRD/EN 16931 validator), behind the configurable
|
||||||
|
`Fuchs:ERechnung:Validation:ServiceUrl` seam. Until the URL is provisioned,
|
||||||
|
verification reports "not configured / skipped".
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The eRechnung invoice PDF and a plain Spire PDF/A must never both be produced
|
||||||
|
for the same document — pick the render-only path when emitting eRechnung.
|
||||||
|
- The incoming visual PDF must itself be PDF/A-friendly (fonts embedded —
|
||||||
|
handled; letterhead images must be **RGB, not CMYK**; transparency is allowed
|
||||||
|
because we target PDF/A-**3**).
|
||||||
|
- `Fuchs:ERechnung:Enabled` gates emission and stays `false` until the
|
||||||
|
`FdsInvoiceData` → `eRechnungLib.Model.Invoice` mapping is wired (the ADR 0005
|
||||||
|
follow-up). Open item for that mapping: the buyer address is currently a
|
||||||
|
free-text block (`SendToAddress`); EN 16931 needs **structured** buyer
|
||||||
|
fields (name/postcode/city/country, VAT id), so structured customer master
|
||||||
|
data must feed the mapping. Seller data (currently hard-coded in `FuchsPdf`:
|
||||||
|
name, address, tax number, IBAN/BIC) must be lifted into the seller model.
|
||||||
|
- `Fuchs.csproj` must add a project reference to `eRechnungLib` when the flow is
|
||||||
|
wired (not present yet).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep Spire PDF/A and have eRechnungLib only embed the XML:** rejected — Spire
|
||||||
|
produces the wrong PDF/A part (1/2, no `/AF`) and a second conversion collides
|
||||||
|
with eRechnungLib's own output intent/XMP, failing veraPDF.
|
||||||
|
- **Drop Spire entirely:** rejected — Spire is still needed to rasterise PDFs to
|
||||||
|
the on-screen invoice/reminder preview images; PdfSharp/eRechnungLib cannot.
|
||||||
|
- **Ship no ICC and rely on callers:** rejected — formal PDF/A-3 requires an
|
||||||
|
output intent; bundling a profile makes conformance the default.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
This folder holds **Architecture Decision Records (ADRs)** — short, immutable
|
||||||
|
records of a specific technical choice, why it was made, and what it implies
|
||||||
|
going forward.
|
||||||
|
|
||||||
|
## What belongs here
|
||||||
|
|
||||||
|
A decision, not a how-to. If it answers "why do we do X this way, and what
|
||||||
|
else did we consider," it's a decision. If it explains "how subsystem X
|
||||||
|
currently works," that belongs in [`../Concepts`](../Concepts/README.md)
|
||||||
|
instead (and a decision often triggers a concept doc to be created/updated).
|
||||||
|
|
||||||
|
## File naming
|
||||||
|
|
||||||
|
`NNNN-kebab-case-title.md`, four-digit zero-padded, sequential across the
|
||||||
|
whole folder (`0001-...`, `0002-...`). Never reuse or renumber.
|
||||||
|
|
||||||
|
## Required YAML frontmatter
|
||||||
|
|
||||||
|
Every decision file starts with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
status: Accepted # Proposed | Accepted | Superseded
|
||||||
|
date: 2026-07-03 # date the decision was accepted
|
||||||
|
applyTo: # glob(s) — files/areas this decision governs
|
||||||
|
- "Fuchs/Notifications/**"
|
||||||
|
supersededBy: "" # filename of the decision that replaced this one, if any
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agents (Claude, Copilot, Codex) must scan the YAML frontmatter of every file
|
||||||
|
in this folder first** (cheap — no need to read the body) and only read the
|
||||||
|
full body of decisions whose `applyTo` glob matches the files they're about
|
||||||
|
to touch, or whose subject is otherwise clearly relevant to the task. This
|
||||||
|
keeps decision-following cheap even as the folder grows.
|
||||||
|
|
||||||
|
## Body template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# NNNN — Title
|
||||||
|
|
||||||
|
## Context
|
||||||
|
What problem/situation forced a choice.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
What was decided, stated plainly.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
What this implies for future code — constraints, follow-ups, trade-offs
|
||||||
|
accepted knowingly.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
Options that were rejected and why (optional but preferred).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Decisions are immutable once `Accepted`.** Do not edit the Decision/
|
||||||
|
Consequences of an existing file to reverse it. Instead, write a new
|
||||||
|
decision, set its `applyTo`/subject accordingly, and set the old file's
|
||||||
|
`status: Superseded` + `supersededBy: NNNN-new-file.md`.
|
||||||
|
- **Follow existing decisions.** Before implementing anything in an area
|
||||||
|
covered by an `Accepted` decision, read it and conform to it. If you
|
||||||
|
believe a decision is wrong, raise it with the user rather than silently
|
||||||
|
deviating.
|
||||||
|
- **Capture new decisions as they happen.** Whenever the user (or the code
|
||||||
|
you're writing) settles a non-obvious architectural or cross-cutting
|
||||||
|
choice — not a routine implementation detail — add a decision here in the
|
||||||
|
same change, and create/update the matching concept doc in `../Concepts`.
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Evaluation — Backend-cached invoice editing over SignalR
|
# Evaluation — Backend-cached invoice editing over SignalR
|
||||||
|
|
||||||
|
> **⚠️ Superseded (2026-07-10).** This note's recommendation (keep the editor
|
||||||
|
> stateless; do **not** build the SignalR/server-cached model) was reversed by the
|
||||||
|
> product owner. Invoice draft editing is now backend-authoritative over an in-memory
|
||||||
|
> cache — see **ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)**,
|
||||||
|
> [`Decisions/0007-targeted-draft-signalr-groups.md`](Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
|
> and the concept doc [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md).
|
||||||
|
> The analysis below is retained for the historical rationale and the risks it flagged
|
||||||
|
> (server-held state, scaling/backplane, reconnect) — which the new design addresses or
|
||||||
|
> accepts explicitly as documented limitations.
|
||||||
|
|
||||||
|
|
||||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# Invoice Lifecycle — From First Click to Customer Email
|
||||||
|
|
||||||
|
Concept document describing the full, end-to-end invoicing process in the
|
||||||
|
Fuchs Intranet: how an office user turns a completed service request into an
|
||||||
|
invoice, how the invoice is drafted/previewed/edited, how it is finalised, and
|
||||||
|
how it reaches the customer by email (including resend and reminders). This
|
||||||
|
complements [`INVOICE_SET_PRICING.md`](INVOICE_SET_PRICING.md) (pricing/display
|
||||||
|
rules for set items) and [`EVAL_live_invoice_editing.md`](EVAL_live_invoice_editing.md)
|
||||||
|
(why the editor is stateless).
|
||||||
|
|
||||||
|
> Scope note: this document describes the **implemented** flow in `Fuchs`
|
||||||
|
> (ASP.NET Core MVC intranet, jQuery front-end). It does not cover the Razor
|
||||||
|
> Pages areas of the workspace — the intranet/invoice module predates those and
|
||||||
|
> is intentionally kept as-is (see `.github/copilot-instructions.md`).
|
||||||
|
|
||||||
|
## 1. Actors & building blocks
|
||||||
|
|
||||||
|
| Layer | Files | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| **Browser UI** | `js/intranet/modules/fis.req.js`, `fis.inv_shared.js`, `fis.inv.js` (bundled to `wwwroot/web/fis.req.de.js` / `fis.inv.de.js` via gulp `min:js`) | Request list, invoice editor dialog, PDF preview rendering, all user interaction |
|
||||||
|
| **Controller** | `Controllers/IntranetController.Requests.cs`, `.Invoices.cs`, `.Invoices2.cs`, `.Reminder.cs` | Thin action dispatch (`req/*`, `inv/*`, `rem/*`), auth checks, request/response shaping |
|
||||||
|
| **Services (DI)** | `Services/IInvoiceService` + `InvoiceService`, `Services/IComService` + `ProcessWebComService`, `Services/IPdfService` + `FuchsPdfService`, `Services/IReminderService` + `ReminderService` | Register/render/store invoices, send email, render PDF documents |
|
||||||
|
| **Data model** | `code/FdsInvoiceData.cs` | Pure data holder: parses the posted `invc` JSON, builds SQL parameters (`BuildInvoiceParams`), exposes registration fields |
|
||||||
|
| **PDF rendering** | `code/FuchsPdf.cs` (`ApplyInvoice`, `CreatePage_Letter`, `AddGirocode`), `code/InvoiceSetPricing.cs` | MigraDoc layout, draft watermark, GiroCode SEPA QR, set-pricing line transformation |
|
||||||
|
| **Database** | `Fuchs_Database` SSDT project — `fds__prepInvoice`, `fds__createInvoice`, `fds__setInvoice`, `fds__createInvoice_Details`, `fds__setInvoiceFinal`, `fds__setInvoiceFile`, `fds__setInvoiceSent`, `fds__getInvoice`, `fds__newInvoiceId` | SQL-first persistence, invoice numbering, finalisation, sent/paid status |
|
||||||
|
|
||||||
|
All backend I/O is SQL-first via OCORE helpers (`getSQLDataSet_async`,
|
||||||
|
`setSQLValue_async`) and stored procedures — there is no EF Core in this path
|
||||||
|
(see `.github/instructions/ocore.instructions.md`).
|
||||||
|
|
||||||
|
## 2. High-level lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Service request\ncompleted] --> B[Create invoice\nfrom request]
|
||||||
|
B --> C[Draft invoice\nregistered]
|
||||||
|
C --> D[Preview / Edit\nloop]
|
||||||
|
D -->|adjust items,\naddress, set-mode...| D
|
||||||
|
D -->|confirm| E[Finalise\ninvoice]
|
||||||
|
E --> F[Render + store\nfinal PDF]
|
||||||
|
F --> G[Email to\ncustomer]
|
||||||
|
G --> H[Mark as sent]
|
||||||
|
H -.optional.-> I[Resend email]
|
||||||
|
H -.optional.-> J[Reminder /\nMahnung]
|
||||||
|
E -.optional.-> K[Storno / Credit\nnote]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Entry point — from the request list
|
||||||
|
|
||||||
|
Invoices are **not** created from a standalone "new invoice" wizard; they are
|
||||||
|
always created *from* one or more completed service requests.
|
||||||
|
|
||||||
|
1. The office user opens the **Aufträge** (requests) list, rendered by
|
||||||
|
`fis.req.js`. `$req.init2/init3` load the list from `req/reql`
|
||||||
|
(`HandleRequestList` → `fds__getRequests_list[2]`).
|
||||||
|
2. Selecting a request row and clicking the invoice icon calls
|
||||||
|
`$inv.cInv` (bound in `fis.req.js` via `.click({ id: rw.Id }, $inv.cInv)`),
|
||||||
|
which — after an auth check (`fds_inv` level 2) — calls `$inv.cInv2({ id })`.
|
||||||
|
3. `$inv.cInv2` posts to **`req/get`** (`HandleRequestGet` →
|
||||||
|
`fds__getRequest_details`, mode `r`) to load the request(s) plus any
|
||||||
|
already-linked invoice, then renders the invoice editor dialog
|
||||||
|
(`invoice_layout`) and wires `$inv.eM` (the contextual top menu: save,
|
||||||
|
set-mode switch, §13b toggle, contact-person edit).
|
||||||
|
4. Multiple requests can be bundled onto a single invoice (the editor supports
|
||||||
|
several request "blocks", each becoming its own item group).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User (browser)
|
||||||
|
participant JS as fis.req.js / fis.inv_shared.js
|
||||||
|
participant C as IntranetController
|
||||||
|
participant DB as SQL (fds__*)
|
||||||
|
|
||||||
|
U->>JS: click invoice icon on request row
|
||||||
|
JS->>JS: $inv.cInv -> $inv.cInv2({id})
|
||||||
|
JS->>C: POST req/get {id, mode:'r'}
|
||||||
|
C->>DB: EXEC fds__getRequest_details
|
||||||
|
DB-->>C: admin, requests, items, inv
|
||||||
|
C-->>JS: JSON {admin, requests, inv}
|
||||||
|
JS->>U: render invoice editor dialog (items, totals, address, email)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Drafting, preview and editing
|
||||||
|
|
||||||
|
The invoice editor is **stateless**: the browser holds the working model
|
||||||
|
(`table.invi` jQuery `.data()`), the server never caches a partial invoice
|
||||||
|
between requests. Every preview/save round-trip posts the *entire* invoice
|
||||||
|
payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||||
|
|
||||||
|
### 4.1 What the user can change
|
||||||
|
- **Line items** — quantities, prices, notes, combine into one sum
|
||||||
|
(`$inv.rendersrq`, `$inv.quantChange`).
|
||||||
|
- **Recipient fields** — invoice title, email, provision location/period (inline
|
||||||
|
edit fields, `fm(...)` helper in `fis.inv_shared.js`). The **recipient address**
|
||||||
|
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
|
||||||
|
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
|
||||||
|
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||||
|
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||||
|
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||||
|
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
||||||
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
|
while the set header itself has no own price; once a set is converted via
|
||||||
|
the item switch (`item.setprice`), the conversion is one-way and totals
|
||||||
|
recompute from that point on.
|
||||||
|
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||||
|
|
||||||
|
All of this recalculates client-side totals live via the `fds.inv` event
|
||||||
|
(`$inv.invSumUpdate`), which also builds the **backend item contract**
|
||||||
|
(`$inv.itemToContract`: `{ id, type, title, desc, qty, price_net, total_net,
|
||||||
|
vat }` plus set header/member tagging) that is posted to the server.
|
||||||
|
|
||||||
|
### 4.2 Posting a draft / preview
|
||||||
|
|
||||||
|
| User action | JS entry point | Endpoint | Controller handler | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** |
|
||||||
|
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdf` → renders preview images |
|
||||||
|
| Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft |
|
||||||
|
| Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row |
|
||||||
|
|
||||||
|
Before posting, `$inv.invcPayload(d)` normalizes the editor's internal field
|
||||||
|
names into the exact names `FdsInvoiceData.BuildInvoiceParams` reads (totals
|
||||||
|
from `sms.ttn/ttb`, `invoicetitle→title`, `loc→provisionlocation`,
|
||||||
|
`paymentterms→paymentterm`, etc. — see `INVOICE_SET_PRICING.md` for the full
|
||||||
|
mapping table). The posted shape is always `{ admin, req, sms, new }`.
|
||||||
|
|
||||||
|
On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
|
||||||
|
JSON into SQL parameters and calls, in one batch:
|
||||||
|
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
||||||
|
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
|
||||||
|
e.g. `setmode:setonly`, `§13b`).
|
||||||
|
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||||
|
→ `fds__createInvoice_Details` again.
|
||||||
|
|
||||||
|
`GenerateInvoicePdf` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`
|
||||||
|
and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally
|
||||||
|
uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into
|
||||||
|
ordered, priced/unpriced lines. While the invoice is **not yet final**
|
||||||
|
(`IsDraft == true`), the rendered PDF carries a diagonal **"Entwurf"/draft
|
||||||
|
overlay watermark** (`CreatePage_Letter(..., draft: true)` stamps
|
||||||
|
`Data/overlay.png`) and — importantly — **no GiroCode payment QR** is added
|
||||||
|
(`AddGirocode` is only called `if (!inv.IsDraft && payAmount > 0 ...)`).
|
||||||
|
The preview images are returned as base64 (`DocToImageCollectionAsync`) and
|
||||||
|
shown in a modal (`$ocms.dlg(... form:false, button:'Rechnung erstellen' ...)`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant JS as fis.inv_shared.js
|
||||||
|
participant C as IntranetController.Requests
|
||||||
|
participant S as InvoiceService
|
||||||
|
participant DB as SQL
|
||||||
|
|
||||||
|
U->>JS: edit items / address / set-mode
|
||||||
|
JS->>JS: fds.inv event -> invSumUpdate (recalculate + build items[])
|
||||||
|
U->>JS: click "Vorschau" (preview)
|
||||||
|
JS->>C: POST req/sprep or req/sedit {invc: JSON, id?}
|
||||||
|
C->>S: RegisterInvoiceAsync(invoice, change, invId)
|
||||||
|
S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
|
||||||
|
DB-->>S: registered invoice row (Id, ...)
|
||||||
|
S->>S: GenerateInvoicePdf (draft=true -> watermark, no GiroCode)
|
||||||
|
S-->>C: MigraDoc Document
|
||||||
|
C-->>JS: {id, img[] (base64 pages), total}
|
||||||
|
JS->>U: show PDF preview modal (confirm / cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
From the preview modal the user can loop back to editing (close and adjust),
|
||||||
|
**cancel** (deletes the draft via `req/sdel`), or **confirm** to finalise.
|
||||||
|
|
||||||
|
## 5. Finalisation ("Rechnung erstellen")
|
||||||
|
|
||||||
|
Confirming the preview modal posts to **`req/sconf`**
|
||||||
|
(`HandleRequestSconf`):
|
||||||
|
|
||||||
|
1. `EXEC fds__setInvoiceFinal @Id, @authuser` — guarded by
|
||||||
|
`isFinal = 0 AND isSent = 0` so it can only fire once per draft. This is
|
||||||
|
also where the **real, sequential invoice number** is assigned:
|
||||||
|
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
|
||||||
|
(the draft only ever had the internal numeric `Id`). `Version` is bumped
|
||||||
|
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
|
||||||
|
2. On success (`IsFinal == true` returned), the controller:
|
||||||
|
- Reloads the invoice via `LoadInvoiceAsync`.
|
||||||
|
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
|
||||||
|
(`draft` now reflects `IsFinal`, so the watermark disappears and the
|
||||||
|
GiroCode payment QR is added) → `fds__setInvoiceFile` persists the PDF
|
||||||
|
bytes on the invoice row → `IBlobStorageService.UploadInvoicePdfAsync`
|
||||||
|
archives a copy to blob storage.
|
||||||
|
- Re-fetches the invoice (`fds__getInvoice`) to get the final
|
||||||
|
`SendToEmail` / `DocumentName` / `InvoiceBalance`.
|
||||||
|
3. If a recipient email is present and the PDF rendered, the controller emails
|
||||||
|
it immediately (see §6) and marks the invoice `Sent` via
|
||||||
|
`fds__setInvoiceSent @auto=true`.
|
||||||
|
4. The browser opens the stored PDF in a new tab (`req/idoc`), returns to the
|
||||||
|
request list, and reloads it (`$ocms.init('req')`, `$inv.rReload()`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[POST req/sconf] --> B{fds__setInvoiceFinal\nisFinal=0 AND isSent=0?}
|
||||||
|
B -- no --> Z[500 - Aktion war nicht erfolgreich]
|
||||||
|
B -- yes --> C[Assign real InvoiceId\nR-yyyy-nnnn, bump Version]
|
||||||
|
C --> D[LoadInvoiceAsync]
|
||||||
|
D --> E[Render final PDF\n[no watermark, + GiroCode]]
|
||||||
|
E --> F[fds__setInvoiceFile\npersist PDF bytes]
|
||||||
|
F --> G[Upload to blob storage\nIBlobStorageService]
|
||||||
|
G --> H{SendToEmail set\nand PDF non-empty?}
|
||||||
|
H -- yes --> I[IComService.SendEmailAsync\nattach PDF inline]
|
||||||
|
I --> J[fds__setInvoiceSent auto=true]
|
||||||
|
H -- no --> K[Skip email - PDF only]
|
||||||
|
J --> L[Open PDF in new tab / reload request list]
|
||||||
|
K --> L
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Sending the invoice by email
|
||||||
|
|
||||||
|
Email delivery is handled by `IComService` / `ProcessWebComService`, which
|
||||||
|
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||||
|
|
||||||
|
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
|
||||||
|
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
|
||||||
|
and payment instructions (IBAN/BIC) with the invoice's payment term
|
||||||
|
(`fdInv.PaymentTerms`, e.g. `10wd` → "10 Werktagen").
|
||||||
|
- The rendered PDF bytes are attached as `{ [DocumentName] = filebyte }` — a
|
||||||
|
single-entry dictionary of filename → bytes.
|
||||||
|
- `SendEmailAsync(reference, subject, htmlBody, recipient, displayName,
|
||||||
|
attachments)` is called with `reference = "inv_<InvoiceId>"`,
|
||||||
|
`subject = "SanitärFuchs - <DocumentName>"`.
|
||||||
|
- Inside `ProcessWebComService.SendEmailAsync`:
|
||||||
|
- The recipient email is validated; a configured
|
||||||
|
`Fuchs:Email:OverrideRecipient` (dev/test safety net, must stay empty in
|
||||||
|
Production) redirects **all** outbound mail to a single address.
|
||||||
|
- A signature is appended to the HTML body.
|
||||||
|
- If the mailer is disabled (`_settings.Enabled == false`), sending is a
|
||||||
|
documented no-op.
|
||||||
|
- Attachments are base64-encoded inline (`{ filename, mimeType,
|
||||||
|
contentBase64 }`) and POSTed to the Mailer API.
|
||||||
|
- Only on a **successful** send does the controller call
|
||||||
|
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
|
||||||
|
mailer call leaves the invoice finalised but *not marked sent* (visible via
|
||||||
|
the "sis" — "als versendet markieren" — manual action, see §7).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as IntranetController
|
||||||
|
participant Com as IComService (ProcessWebComService)
|
||||||
|
participant API as ProcessWeb Mailer API
|
||||||
|
participant DB as SQL
|
||||||
|
|
||||||
|
C->>C: BuildInvoiceBody(balance, paymentTerms)
|
||||||
|
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
|
||||||
|
Com->>Com: validate email, apply OverrideRecipient (dev), append signature
|
||||||
|
alt mailer enabled
|
||||||
|
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
|
||||||
|
API-->>Com: success/failure
|
||||||
|
else mailer disabled
|
||||||
|
Com-->>Com: no-op (logged)
|
||||||
|
end
|
||||||
|
Com-->>C: sent: true/false
|
||||||
|
opt sent == true
|
||||||
|
C->>DB: EXEC fds__setInvoiceSent @auto=true
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. After finalisation — manual actions
|
||||||
|
|
||||||
|
Once an invoice is `IsFinal`, the request/invoice list menus
|
||||||
|
(`$inv.iMn`/`$inv.iMnr` in `fis.inv_shared.js`) expose:
|
||||||
|
|
||||||
|
| Action | JS | Endpoint | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
|
||||||
|
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
|
||||||
|
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only |
|
||||||
|
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
|
||||||
|
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
|
||||||
|
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
|
||||||
|
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
|
||||||
|
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
|
||||||
|
|
||||||
|
Storno/credit invoices are **new invoice drafts** created from the original
|
||||||
|
one — they go through the exact same preview → finalise → email pipeline
|
||||||
|
described in §4–§6, just seeded from `fds__createStorno_*`/`fds__createCredit_simple`
|
||||||
|
instead of from a service request.
|
||||||
|
|
||||||
|
## 8. Reminders (Mahnungen) — downstream of a sent, unpaid invoice
|
||||||
|
|
||||||
|
Not part of the invoice creation flow itself, but the natural continuation
|
||||||
|
once an invoice is sent and remains unpaid:
|
||||||
|
|
||||||
|
- `$inv.ccRem(id, InvoiceId)` → `rem/lrem` (load prior reminder history) →
|
||||||
|
reminder editor (`$inv.ccRem_s2`) → `rem/get` → **preview** via `rem/prep`
|
||||||
|
(`$inv.rprev`, same stateless pattern as invoice preview) → **finalise**
|
||||||
|
via `rem/conf` → PDF opened, list reloaded.
|
||||||
|
- `$inv.dspRem(id)` lists existing reminders (`inv/getrem`); each can be
|
||||||
|
resent (`rem/resend`, mirrors `req/resend`) or downloaded.
|
||||||
|
- `$inv.srs(id)` → `rem/srs` marks a reminder as sent manually (mirrors
|
||||||
|
`inv/sis`).
|
||||||
|
- Reminder PDFs use the same draft-watermark/GiroCode rule as invoices
|
||||||
|
(`FuchsPdf.ApplyReminder`, `rem.IsDraft`).
|
||||||
|
|
||||||
|
This is handled by `IReminderService`/`ReminderService` and
|
||||||
|
`IntranetController.Reminder.cs`, following the identical
|
||||||
|
draft-preview-finalise-email shape as invoices — intentionally, so the two
|
||||||
|
flows share the same mental model for the office user.
|
||||||
|
|
||||||
|
## 9. End-to-end summary diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Requests
|
||||||
|
R1[Service request completed] --> R2[Open request list - req/reql]
|
||||||
|
R2 --> R3[Select request(s), click invoice icon]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Draft & Edit
|
||||||
|
R3 --> D1[req/get - load request + existing invoice]
|
||||||
|
D1 --> D2[Edit items, address, email,\nset-mode, §13b, contact]
|
||||||
|
D2 --> D3[req/save - persist draft only]
|
||||||
|
D2 --> D4[req/sprep or req/sedit -\nregister + render draft PDF]
|
||||||
|
D4 --> D5[Preview modal\ndraft watermark, no GiroCode]
|
||||||
|
D5 -->|edit more| D2
|
||||||
|
D5 -->|cancel| D6[req/sdel - delete draft]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Finalise & Send
|
||||||
|
D5 -->|confirm| F1[req/sconf -\nfds__setInvoiceFinal]
|
||||||
|
F1 --> F2[Assign real InvoiceId\nR-yyyy-nnnn]
|
||||||
|
F2 --> F3[Render final PDF\nno watermark, + GiroCode]
|
||||||
|
F3 --> F4[fds__setInvoiceFile +\nblob storage upload]
|
||||||
|
F4 --> F5{SendToEmail set?}
|
||||||
|
F5 -- yes --> F6[IComService.SendEmailAsync\nProcessWeb Mailer API]
|
||||||
|
F6 --> F7[fds__setInvoiceSent auto=true]
|
||||||
|
F5 -- no --> F8[PDF stored, not emailed]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph After Sending
|
||||||
|
F7 --> A1[req/resend - resend email]
|
||||||
|
F7 --> A2[inv/setpyd / setupd - payment status]
|
||||||
|
F7 --> A3[inv/storno or inv/credit -\nnew linked invoice draft]
|
||||||
|
F7 --> A4[rem/* - reminder / Mahnung flow]
|
||||||
|
A3 -.re-enters.-> D2
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Key invariants worth remembering
|
||||||
|
|
||||||
|
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
|
||||||
|
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
|
||||||
|
> **backend-authoritative** model where the server holds the draft in an in-memory
|
||||||
|
> cache (the single source of truth), the browser posts single edits and re-fetches on
|
||||||
|
> a SignalR signal, and totals are computed server-side. See ADR
|
||||||
|
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
|
> and [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md). Finalise/email
|
||||||
|
> (§5–§6) are unchanged. The remaining invariants below still hold.
|
||||||
|
|
||||||
|
- **Stateless editor** *(legacy — see the note above; superseded by ADR 0006)*: every
|
||||||
|
preview/save/finalise call re-posts the full `invc` JSON; the server never holds a
|
||||||
|
partial invoice in memory or session between requests (see `EVAL_live_invoice_editing.md`).
|
||||||
|
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||||
|
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||||
|
mode (set pricing) never changes what the customer owes.
|
||||||
|
- **Numbering happens only at finalisation**: the draft has an internal `Id`
|
||||||
|
but no `InvoiceId` until `fds__setInvoiceFinal` assigns
|
||||||
|
`R<year>-<sequence>` — so previews never "burn" an invoice number.
|
||||||
|
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
|
||||||
|
GiroCode; final = no watermark, GiroCode payment QR added when there's a
|
||||||
|
positive balance.
|
||||||
|
- **Final invoices can be emitted as eRechnung**: when `Fuchs:ERechnung:Enabled`,
|
||||||
|
the final PDF is a ZUGFeRD/Factur-X **PDF/A-3 hybrid** (eRechnungLib embeds the
|
||||||
|
CII XML into the render-only visual PDF; any failure falls back to the plain
|
||||||
|
PDF/A). Off by default. See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md).
|
||||||
|
- **Email is best-effort and tracked**: `IsSent` is only set `true`
|
||||||
|
automatically after a *successful* send; a failed send still leaves a
|
||||||
|
correctly finalised, stored invoice that staff can resend or mark sent
|
||||||
|
manually.
|
||||||
|
- **Storno/credit/reminder all reuse the same pipeline**: they are not special
|
||||||
|
cases in the UI/backend contract — they are just differently-seeded drafts
|
||||||
|
going through the identical preview → finalise → email sequence.
|
||||||
|
5
|
||||||
@@ -1,24 +1,54 @@
|
|||||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||||
|
|
||||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||||
invoice instead of being broken up into their member items and summed.
|
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
|
||||||
|
> reversible, non-mutating display toggles this document previously described into irreversible,
|
||||||
|
> per-service-request-block mutations — the text below reflects the redefinition.
|
||||||
|
|
||||||
Three display modes (switchable in the invoice editor):
|
There are **three** separate set-price operations. They fall into two families that must not be
|
||||||
|
confused, because they group items by different keys and differ in whether they mutate the data:
|
||||||
|
|
||||||
| Mode | Set line | Member items | Use as |
|
| # | Operation | Trigger | Grouped by | Effect |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** |
|
||||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
|
||||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
|
||||||
|
|
||||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
||||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
||||||
> lines, so switching modes is purely presentational. The set price always
|
for the full breakdown.
|
||||||
> equals the sum of its members (computed as a fallback when the set header
|
|
||||||
> carries no own price).
|
|
||||||
|
|
||||||
## Back-end (implemented + unit-tested)
|
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
|
||||||
|
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
|
||||||
|
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
|
||||||
|
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
|
||||||
|
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
|
||||||
|
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
|
||||||
|
> the set row is a real, editable line item precisely so the user can adjust the set value
|
||||||
|
> afterwards.
|
||||||
|
|
||||||
|
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
|
||||||
|
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
|
||||||
|
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
|
||||||
|
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
|
||||||
|
> token degrades safely (ignored) and is never (re-)persisted.
|
||||||
|
|
||||||
|
> **Totals are unaffected.** The invoice total is taken from the registration balance
|
||||||
|
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
|
||||||
|
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
|
||||||
|
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
|
||||||
|
> header.
|
||||||
|
|
||||||
|
## Back-end
|
||||||
|
|
||||||
|
> **Migration status (ADR 0009).** The code below still reflects the previous
|
||||||
|
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
|
||||||
|
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
|
||||||
|
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
|
||||||
|
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
|
||||||
|
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
|
||||||
|
> migration lands so it stays a faithful description of the code.
|
||||||
|
|
||||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||||
@@ -34,15 +64,15 @@ Three display modes (switchable in the invoice editor):
|
|||||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||||
|
|
||||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
||||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
||||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
||||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
||||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
session (inserts the set row, blanks or removes members) and pushes the new
|
||||||
persisted by `fds__createInvoice_Details` and read back by
|
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
||||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
||||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
||||||
is not sent).
|
these two modes.
|
||||||
|
|
||||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||||
@@ -51,12 +81,20 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
|||||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||||
|
|
||||||
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with
|
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
||||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
an item with `type === 'set'` is a header (`id` = its set id); a member item's
|
||||||
same block become its members** (`setId` = the header's id) until the next set
|
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
||||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||||
so this "header claims the following items in its block" rule is the convention
|
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
||||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
**not** re-derived from row order in the browser. `mfr__items` itself still has no
|
||||||
|
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
|
||||||
|
item list, so only items the server actually attributes to a set are tagged, and
|
||||||
|
unrelated items following a set in the list are never swept in. The header row's
|
||||||
|
own `SetItmId` self-references its own id (rather than being `null`); it is
|
||||||
|
explicitly excluded from being its own member both here (`sid !== citem.id`) and
|
||||||
|
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
|
||||||
|
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
|
||||||
|
`SetItmId` and group by service-request block.
|
||||||
|
|
||||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||||
@@ -80,20 +118,95 @@ no per-rate `vat_*` keys.
|
|||||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||||
the highest rate.
|
the highest rate.
|
||||||
|
|
||||||
The editor's running **total stays the member sum in every mode**, matching the
|
The editor's running **total is unaffected by any set-price operation**, matching
|
||||||
registration balance — switching modes is purely presentational.
|
the registration balance — each operation conserves the total (the set row's value
|
||||||
|
equals the members it blanks/removes; the set-item switch writes exactly the
|
||||||
|
members' sum onto the header).
|
||||||
|
|
||||||
### Why the switch lives in the editor
|
### Why the trigger lives in the editor
|
||||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
is being composed (front-end), but the operation itself is executed **server-side**
|
||||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
against the cached draft session — the editor merely names the target (a set-item
|
||||||
items — it does not re-implement the pricing rules.
|
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
|
||||||
|
the server's result. The back-end stays the single, tested authority for how each
|
||||||
|
operation rewrites the lines; the editor never re-implements the grouping, the
|
||||||
|
per-block aggregation, or the pricing rules (ADR 0008/0009).
|
||||||
|
|
||||||
|
## All set-price functions: before/after comparison
|
||||||
|
|
||||||
|
There are **three** distinct functions, and all three are **mutations** of the
|
||||||
|
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
|
||||||
|
transient, freely-reversible view flag. They differ in what they group by and
|
||||||
|
what they touch:
|
||||||
|
|
||||||
|
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
|
||||||
|
(one mfr set-item and its members) and is triggered per set row.
|
||||||
|
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
|
||||||
|
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
|
||||||
|
entirely, and are triggered once from the "Set-Preisanzeige" menu.
|
||||||
|
|
||||||
|
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
|
||||||
|
is discarding the draft or hand-editing the resulting rows.
|
||||||
|
|
||||||
|
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
|
||||||
|
|
||||||
|
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
|
||||||
|
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
|
||||||
|
shown — and the operation available — **only** on a row that is `type == "set"`
|
||||||
|
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
|
||||||
|
missing either condition never offers it. It gives a set-item its "own price": the
|
||||||
|
members' values are summed onto the header and the members' prices are set to
|
||||||
|
`null` (empty cell, excluded from the sum — not `0`). This
|
||||||
|
conversion is **one-way** — there is no patch to move a converted set back to
|
||||||
|
separately-priced members; the user would re-edit the individual line prices by
|
||||||
|
hand. It is the **only** function keyed on `SetItmId`.
|
||||||
|
|
||||||
|
| Aspect | Before the switch | After the switch |
|
||||||
|
|---|---|---|
|
||||||
|
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
|
||||||
|
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
|
||||||
|
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
|
||||||
|
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
|
||||||
|
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
|
||||||
|
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
|
||||||
|
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
|
||||||
|
|
||||||
|
### 2 & 3. The two menu modes (per service-request block, mutating)
|
||||||
|
|
||||||
|
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
|
||||||
|
`$inv.setSetmode`) and applied server-side per **service-request block**
|
||||||
|
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
|
||||||
|
Both are **irreversible** and rewrite the block's items in the cached session.
|
||||||
|
|
||||||
|
For each block, a dedicated, emphasised **set row** is inserted (see "The
|
||||||
|
dedicated set row" below) carrying the block's aggregated value as its price;
|
||||||
|
then, depending on the mode, the block's original items are either blanked or
|
||||||
|
removed:
|
||||||
|
|
||||||
|
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|
||||||
|
|---|---|---|
|
||||||
|
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
|
||||||
|
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
|
||||||
|
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
|
||||||
|
| `SetItmId` | ignored | ignored |
|
||||||
|
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
|
||||||
|
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
|
||||||
|
| Empty block | no-op | no-op |
|
||||||
|
|
||||||
|
### The dedicated set row
|
||||||
|
|
||||||
|
Both menu modes insert a **real, editable line item** (its own id, rendered
|
||||||
|
emphasised in the editor and the PDF), not a reused block-heading row and not a
|
||||||
|
render-only overlay. Because it is a genuine row in the cached dataset, the user
|
||||||
|
can adjust the set value afterwards with an ordinary item edit — that hand-edit
|
||||||
|
is the intended and only "undo" for the conversion (ADR
|
||||||
|
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
|
||||||
|
|
||||||
## Persistence note
|
## Persistence note
|
||||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||||
works end-to-end for previews and creation. `setmode` persists via
|
works end-to-end for previews and creation. The two menu modes bake their result
|
||||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
directly into the session's items (a set row plus blanked/removed members), so no
|
||||||
re-rendering from line items is not needed for correctness. Persisting the
|
`setmode:` display token is needed or persisted; the finalised document is
|
||||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
||||||
only required if a finalised invoice must be **re-generated** from stored items in
|
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
||||||
a different mode later — not done here.
|
invoice must be **re-generated** from stored items later — not done here.
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Fuchs Intranet — Das ist neu</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--blue:#1b4379; /* $fuchs_blau */
|
||||||
|
--blue-2:#2a5da3;
|
||||||
|
--accent:#56a532; /* $fuchs_akzent */
|
||||||
|
--accent-2:#74c14a;
|
||||||
|
--ink:#12243f;
|
||||||
|
--ink-2:#1a2f52;
|
||||||
|
--paper:#f4f6fa;
|
||||||
|
--card:#ffffff;
|
||||||
|
--text:#1c2430;
|
||||||
|
--muted:#586172;
|
||||||
|
--line:#e3e8f0; /* near $fuchs_lightgray */
|
||||||
|
--green:#56a532;
|
||||||
|
--shadow:0 18px 50px -20px rgba(18,36,63,.35);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{
|
||||||
|
margin:0;
|
||||||
|
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
|
||||||
|
color:var(--text);
|
||||||
|
background:var(--paper);
|
||||||
|
line-height:1.6;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
|
||||||
|
|
||||||
|
/* ---------- HERO ---------- */
|
||||||
|
.hero{
|
||||||
|
position:relative;
|
||||||
|
color:#fff;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 80% -10%, rgba(86,165,50,.38), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 0% 10%, rgba(42,93,163,.50), transparent 55%),
|
||||||
|
linear-gradient(160deg,#1b3a63 0%, #12243f 60%, #0b1727 100%);
|
||||||
|
overflow:hidden;
|
||||||
|
border-bottom:1px solid rgba(255,255,255,.06);
|
||||||
|
}
|
||||||
|
.hero::after{
|
||||||
|
content:"";position:absolute;inset:0;
|
||||||
|
background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,.25));
|
||||||
|
pointer-events:none;
|
||||||
|
}
|
||||||
|
.hero .wrap{position:relative;z-index:2;padding:78px 24px 92px}
|
||||||
|
.eyebrow{
|
||||||
|
display:inline-flex;align-items:center;gap:9px;
|
||||||
|
font-size:.8rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
|
||||||
|
color:var(--accent-2);
|
||||||
|
background:rgba(86,165,50,.12);
|
||||||
|
border:1px solid rgba(86,165,50,.28);
|
||||||
|
padding:7px 15px;border-radius:100px;
|
||||||
|
}
|
||||||
|
.eyebrow .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 14px var(--accent)}
|
||||||
|
h1{
|
||||||
|
font-size:clamp(2.1rem,5vw,3.6rem);
|
||||||
|
line-height:1.08;margin:22px 0 16px;font-weight:800;letter-spacing:-.02em;
|
||||||
|
}
|
||||||
|
h1 .grad{
|
||||||
|
background:linear-gradient(92deg,var(--accent-2),#fff 70%);
|
||||||
|
-webkit-background-clip:text;background-clip:text;color:transparent;
|
||||||
|
}
|
||||||
|
.lede{font-size:clamp(1.05rem,2.2vw,1.28rem);color:#c7cdda;max-width:640px;margin:0}
|
||||||
|
.hero-meta{
|
||||||
|
display:flex;flex-wrap:wrap;gap:26px;margin-top:38px;
|
||||||
|
padding-top:26px;border-top:1px solid rgba(255,255,255,.1);
|
||||||
|
}
|
||||||
|
.hero-meta div{min-width:120px}
|
||||||
|
.hero-meta b{display:block;font-size:1.7rem;font-weight:800;color:#fff}
|
||||||
|
.hero-meta span{font-size:.86rem;color:#98a1b3}
|
||||||
|
|
||||||
|
/* ---------- SECTIONS ---------- */
|
||||||
|
section{padding:64px 0}
|
||||||
|
.section-head{max-width:680px;margin-bottom:40px}
|
||||||
|
.section-head .kicker{color:var(--accent);font-weight:700;font-size:.82rem;letter-spacing:.12em;text-transform:uppercase}
|
||||||
|
h2{font-size:clamp(1.6rem,3.4vw,2.3rem);margin:10px 0 12px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.section-head p{color:var(--muted);font-size:1.06rem;margin:0}
|
||||||
|
|
||||||
|
/* ---------- FEATURE CARDS ---------- */
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px}
|
||||||
|
.card{
|
||||||
|
background:var(--card);
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius:18px;
|
||||||
|
padding:28px 26px;
|
||||||
|
box-shadow:var(--shadow);
|
||||||
|
position:relative;
|
||||||
|
transition:transform .25s ease, box-shadow .25s ease;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.card::before{
|
||||||
|
content:"";position:absolute;top:0;left:0;right:0;height:3px;
|
||||||
|
background:linear-gradient(90deg,var(--accent),var(--accent-2));
|
||||||
|
opacity:.9;
|
||||||
|
}
|
||||||
|
.card:hover{transform:translateY(-5px);box-shadow:0 26px 60px -24px rgba(20,24,33,.45)}
|
||||||
|
.card .ico{
|
||||||
|
width:48px;height:48px;border-radius:13px;display:grid;place-items:center;
|
||||||
|
background:linear-gradient(150deg,rgba(86,165,50,.16),rgba(116,193,74,.06));
|
||||||
|
border:1px solid rgba(86,165,50,.22);
|
||||||
|
font-size:1.5rem;margin-bottom:16px;
|
||||||
|
}
|
||||||
|
.card h3{margin:0 0 8px;font-size:1.18rem;font-weight:700}
|
||||||
|
.card p{margin:0;color:var(--muted);font-size:.97rem}
|
||||||
|
.card .tag{
|
||||||
|
display:inline-block;margin-top:16px;font-size:.75rem;font-weight:600;
|
||||||
|
color:var(--accent);background:rgba(86,165,50,.09);
|
||||||
|
border:1px solid rgba(86,165,50,.2);padding:4px 11px;border-radius:100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- BEFORE / AFTER ---------- */
|
||||||
|
.compare{background:linear-gradient(180deg,#fff,#f2f4f9);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||||
|
.table-scroll{overflow-x:auto;border-radius:16px;box-shadow:var(--shadow);border:1px solid var(--line)}
|
||||||
|
table{border-collapse:collapse;width:100%;min-width:640px;background:#fff}
|
||||||
|
th,td{text-align:left;padding:16px 20px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
thead th{background:var(--ink);color:#fff;font-weight:600;font-size:.92rem;letter-spacing:.01em}
|
||||||
|
thead th:first-child{border-top-left-radius:16px}
|
||||||
|
thead th:last-child{border-top-right-radius:16px}
|
||||||
|
tbody tr:last-child td{border-bottom:none}
|
||||||
|
td.feat{font-weight:700;color:var(--ink);width:24%}
|
||||||
|
td.old{color:var(--muted)}
|
||||||
|
td.old::before{content:"✕ ";color:#c4453b;font-weight:700}
|
||||||
|
td.new{color:#1c2733}
|
||||||
|
td.new::before{content:"✓ ";color:var(--green);font-weight:700}
|
||||||
|
tbody tr:nth-child(even){background:#fafbfe}
|
||||||
|
|
||||||
|
/* ---------- SPOTLIGHT ---------- */
|
||||||
|
.spot{display:grid;grid-template-columns:1.05fr .95fr;gap:38px;align-items:center}
|
||||||
|
.spot-card{
|
||||||
|
background:linear-gradient(160deg,var(--ink),var(--ink-2));
|
||||||
|
color:#fff;border-radius:22px;padding:34px;box-shadow:var(--shadow);
|
||||||
|
border:1px solid rgba(255,255,255,.07);
|
||||||
|
}
|
||||||
|
.spot-card h3{margin:0 0 14px;font-size:1.35rem}
|
||||||
|
.spot-card ul{margin:0;padding:0;list-style:none}
|
||||||
|
.spot-card li{position:relative;padding:9px 0 9px 30px;color:#cdd3df;border-bottom:1px dashed rgba(255,255,255,.09)}
|
||||||
|
.spot-card li:last-child{border-bottom:none}
|
||||||
|
.spot-card li::before{content:"→";position:absolute;left:0;color:var(--accent-2);font-weight:800}
|
||||||
|
.spot-text h2{margin-top:0}
|
||||||
|
.spot-text p{color:var(--muted)}
|
||||||
|
.chip{display:inline-block;font-size:.78rem;font-weight:600;color:var(--accent);background:rgba(86,165,50,.1);border:1px solid rgba(86,165,50,.22);padding:5px 12px;border-radius:100px;margin-bottom:14px}
|
||||||
|
|
||||||
|
/* ---------- KEY USER BOX ---------- */
|
||||||
|
.keyuser{background:var(--ink);color:#fff}
|
||||||
|
.keyuser .section-head p{color:#a7afbe}
|
||||||
|
.ku-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}
|
||||||
|
.ku{
|
||||||
|
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);
|
||||||
|
border-radius:16px;padding:24px;
|
||||||
|
}
|
||||||
|
.ku h3{margin:0 0 8px;font-size:1.05rem;color:#fff}
|
||||||
|
.ku h3 span{color:var(--accent-2)}
|
||||||
|
.ku p{margin:0;color:#a7afbe;font-size:.93rem}
|
||||||
|
|
||||||
|
/* ---------- EDITOR STEPS ---------- */
|
||||||
|
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-top:8px}
|
||||||
|
.step{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 20px;box-shadow:0 10px 30px -20px rgba(18,36,63,.3)}
|
||||||
|
.step .n{font-size:.78rem;font-weight:800;color:var(--accent);letter-spacing:.08em}
|
||||||
|
.step h4{margin:6px 0 6px;font-size:1.04rem}
|
||||||
|
.step p{margin:0;color:var(--muted);font-size:.93rem}
|
||||||
|
|
||||||
|
/* ---------- SET-PRICE PANEL ---------- */
|
||||||
|
.setpanel{
|
||||||
|
margin-top:34px;border:1px solid var(--line);border-radius:22px;
|
||||||
|
background:linear-gradient(160deg,#ffffff,#eef4ea);
|
||||||
|
padding:34px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden;
|
||||||
|
}
|
||||||
|
.setpanel::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--blue),var(--accent))}
|
||||||
|
.setpanel > .ttl{display:flex;align-items:center;gap:12px;margin-bottom:6px}
|
||||||
|
.setpanel > .ttl .badge{font-size:1.4rem}
|
||||||
|
.setpanel h3{margin:0;font-size:1.35rem;font-weight:800;color:var(--blue)}
|
||||||
|
.setpanel > p{margin:8px 0 0;color:var(--muted);max-width:720px}
|
||||||
|
.setgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px;margin-top:26px}
|
||||||
|
.setvar{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px 22px;box-shadow:0 12px 32px -22px rgba(18,36,63,.4);display:flex;flex-direction:column}
|
||||||
|
.setvar .num{
|
||||||
|
width:36px;height:36px;border-radius:11px;display:grid;place-items:center;
|
||||||
|
background:linear-gradient(150deg,var(--blue),var(--blue-2));color:#fff;font-weight:800;
|
||||||
|
font-size:1.05rem;margin-bottom:14px;
|
||||||
|
}
|
||||||
|
.setvar h4{margin:0 0 8px;font-size:1.06rem;line-height:1.3}
|
||||||
|
.setvar h4 small{display:block;font-size:.76rem;font-weight:600;color:var(--accent);letter-spacing:.04em;margin-top:3px}
|
||||||
|
.setvar p{margin:0 0 12px;color:var(--muted);font-size:.93rem}
|
||||||
|
.setvar .kv{margin-top:auto;font-size:.82rem;color:var(--blue);font-weight:600}
|
||||||
|
.pill{display:inline-block;font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:100px;margin-top:10px}
|
||||||
|
.pill.rev{color:#8a6d3b;background:rgba(210,150,40,.14);border:1px solid rgba(210,150,40,.35)}
|
||||||
|
.pill.one{color:#a03d2e;background:rgba(196,69,59,.12);border:1px solid rgba(196,69,59,.3)}
|
||||||
|
.setnote{
|
||||||
|
margin-top:24px;padding:16px 20px;border-radius:14px;
|
||||||
|
background:rgba(27,67,121,.06);border:1px solid rgba(27,67,121,.16);
|
||||||
|
color:#2a3b52;font-size:.92rem;
|
||||||
|
}
|
||||||
|
.setnote b{color:var(--blue)}
|
||||||
|
|
||||||
|
/* ---------- FOOTER ---------- */
|
||||||
|
footer{padding:44px 0;text-align:center;color:var(--muted);font-size:.9rem;border-top:1px solid var(--line)}
|
||||||
|
footer b{color:var(--ink)}
|
||||||
|
|
||||||
|
@media(max-width:760px){
|
||||||
|
.spot{grid-template-columns:1fr}
|
||||||
|
section{padding:48px 0}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<span class="eyebrow"><span class="dot"></span>Release-Übersicht · 2026</span>
|
||||||
|
<h1>Ihr Intranet wird<br><span class="grad">schneller, sicherer, transparenter.</span></h1>
|
||||||
|
<p class="lede">Die neue Generation des Fuchs Intranets bringt Live-Vorschau bei der Rechnungserstellung, Echtzeit-Rückmeldungen, extern validierte E-Rechnung (ZUGFeRD/DATEV und XRechnung) und eine durchgängig geprüfte Datenverarbeitung — ohne dass sich Ihr gewohnter Arbeitsablauf verändert.</p>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<div><b>E-Rechnung</b><span>ZUGFeRD / XRechnung · extern validiert</span></div>
|
||||||
|
<div><b>Echtzeit</b><span>Live-Vorschau & Benachrichtigungen</span></div>
|
||||||
|
<div><b>.NET 10</b><span>Moderne, geprüfte Plattform</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- WAS NEU IST -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Das Wichtigste auf einen Blick</span>
|
||||||
|
<h2>Die neuen Funktionen für Ihren Alltag</h2>
|
||||||
|
<p>Alle Neuerungen zielen auf dasselbe Ziel: weniger Fehler, mehr Überblick und Rechnungen, die auf Anhieb korrekt sind.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">👁️</div>
|
||||||
|
<h3>Live-Vorschau beim Bearbeiten</h3>
|
||||||
|
<p>Während Sie eine Rechnung bearbeiten, sehen Sie das fertige PDF sofort in Echtzeit — genau so, wie es der Kunde erhält. Kein Zwischenspeichern, kein Raten mehr.</p>
|
||||||
|
<span class="tag">Rechnungen & Zahlungserinnerungen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🧮</div>
|
||||||
|
<h3>Alle Beträge serverseitig berechnet</h3>
|
||||||
|
<p>Summen, Mehrwertsteuer, §13b-Umkehr und offene Beträge rechnet ab sofort der Server — geprüft und einheitlich. Der Bildschirm zeigt immer denselben Stand wie das PDF.</p>
|
||||||
|
<span class="tag">Keine Rechenfehler mehr</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🔔</div>
|
||||||
|
<h3>Benachrichtigungen in Echtzeit</h3>
|
||||||
|
<p>„Rechnung R2026-0001 wurde an den Kunden versandt." — Erfolg <em>und</em> Fehler erscheinen sofort als deutlich lesbare Meldung. Kein Nachschauen in Listen mehr.</p>
|
||||||
|
<span class="tag">Sofortiges Feedback</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🧾</div>
|
||||||
|
<h3>Extern geprüfte E-Rechnung</h3>
|
||||||
|
<p>Rechnungen werden automatisch als ZUGFeRD 2.4 / Factur-X (DATEV) ausgegeben — oder, sobald eine Leitweg-ID hinterlegt ist, als XRechnung 3.0 für den Behördenversand. Beide Formate sind bei einem externen Prüfdienst als fehlerfrei (0 Fehler) und PDF/A-3-konform bestätigt.</p>
|
||||||
|
<span class="tag">ZUGFeRD 2.4 · XRechnung 3.0 · Extern validiert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🕓</div>
|
||||||
|
<h3>Änderungshistorie & Verwerfen</h3>
|
||||||
|
<p>Jede Änderung an einem Entwurf wird protokolliert. Über „Änderungshistorie" sehen Sie, was passiert ist, und mit „Änderungen verwerfen" kehren Sie jederzeit zum gespeicherten Stand zurück.</p>
|
||||||
|
<span class="tag">Volle Nachvollziehbarkeit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🏦</div>
|
||||||
|
<h3>Mehr Bankformate</h3>
|
||||||
|
<p>Kontoauszüge werden jetzt auch im modernen ISO-20022-Format (CAMT) automatisch erkannt und eingelesen — zusätzlich zum bewährten MT940. Das Format wird selbstständig erkannt.</p>
|
||||||
|
<span class="tag">CAMT + MT940</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SPOTLIGHT: Live-Editor -->
|
||||||
|
<section style="padding-top:12px">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="spot">
|
||||||
|
<div class="spot-text">
|
||||||
|
<span class="chip">Highlight</span>
|
||||||
|
<h2>Der Rechnungs-Editor, der mitdenkt</h2>
|
||||||
|
<p>Früher rechnete der Browser — heute ist der Server die einzige verbindliche Quelle. Das klingt technisch, bedeutet für Sie aber vor allem: Was Sie sehen, stimmt. Immer.</p>
|
||||||
|
<p>Gilt für <strong>alle Rechnungsarten</strong> (Regel-, Abschlags-, Schluss- und Stornorechnung) und <strong>alle Mahnstufen</strong> — das Online-Bild und das PDF sind garantiert identisch, bis hin zur Positionsnummerierung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="spot-card">
|
||||||
|
<h3>Was der Editor jetzt automatisch tut</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Rechnet Netto, MwSt. und Brutto sofort korrekt neu</li>
|
||||||
|
<li>Prüft E-Mail, Adresse, Positionen und Steuersätze live</li>
|
||||||
|
<li>Erzeugt die PDF-Vorschau direkt aus dem aktuellen Stand</li>
|
||||||
|
<li>Nummeriert Positionen auch nach Umsortieren korrekt durch</li>
|
||||||
|
<li>Warnt rechtzeitig, bevor ein Entwurf abläuft</li>
|
||||||
|
<li>Speichert erst final, wenn Sie es bestätigen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ONLINE-EDITOR IM DETAIL -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Der Online-Editor im Detail</span>
|
||||||
|
<h2>Rechnungen direkt im Browser erstellen</h2>
|
||||||
|
<p>Der neue Rechnungs-Editor führt Sie Schritt für Schritt zur fertigen Rechnung — komfortabel zu bedienen und dabei jederzeit rechnerisch abgesichert. Er gilt für alle Rechnungsarten (Regel-, Abschlags-, Schluss- und Stornorechnung).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="n">BEARBEITEN</div><h4>Direkt im Feld</h4><p>Texte und Positionen bearbeiten Sie direkt an Ort und Stelle — ein Klick genügt.</p></div>
|
||||||
|
<div class="step"><div class="n">EMPFÄNGER</div><h4>Adresse als Formular</h4><p>Die Rechnungsadresse erfassen Sie strukturiert (Name, Straße, PLZ, Ort, Land, optional USt-IdNr., bei Behörden die Leitweg-ID) — vorausgefüllt aus den Kundendaten, ergänzt um Leistungsdatum bzw. Leistungszeitraum. Ein Hinweis zeigt, ob alles für die DATEV-/E-Rechnung passt; ist eine Leitweg-ID gesetzt, wird automatisch als XRechnung statt ZUGFeRD ausgegeben. Für Privatpersonen bleibt die USt-IdNr. einfach leer.</p></div>
|
||||||
|
<div class="step"><div class="n">ORDNEN</div><h4>Blöcke & Reihenfolge</h4><p>Positionen sind je Auftrag in Abschnitten gebündelt und lassen sich per Ziehen neu sortieren; die Nummerierung passt sich automatisch an.</p></div>
|
||||||
|
<div class="step"><div class="n">RECHNEN</div><h4>Summen & Steuer live</h4><p>Netto, Mehrwertsteuer, Brutto und die §13b-Umkehr werden bei jeder Änderung sofort und geprüft neu berechnet.</p></div>
|
||||||
|
<div class="step"><div class="n">PRÜFEN</div><h4>Vorschau auf Knopfdruck</h4><p>Die PDF-Vorschau entsteht direkt aus dem aktuellen Stand — was Sie sehen, ist exakt das, was der Kunde erhält.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SET-PREIS-VARIANTEN -->
|
||||||
|
<div class="setpanel">
|
||||||
|
<div class="ttl"><span class="badge">📦</span><h3>Set-Preis: drei Wege, Positionen zusammenzufassen</h3></div>
|
||||||
|
<p>Oft sollen mehrere Einzelpositionen zu <em>einem</em> Set-Preis zusammengefasst werden — etwa als Pauschale pro Auftrag. Dafür gibt es drei klar getrennte Funktionen. Bei allen bleibt die <strong>Rechnungssumme unverändert</strong>; die Set-Zeile trägt genau den Wert der zusammengefassten Positionen.</p>
|
||||||
|
|
||||||
|
<div class="setgrid">
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">1</div>
|
||||||
|
<h4>Einzelne Set-Position zusammenfassen<small>Zeilen-Schaltfläche · direkt an der Position</small></h4>
|
||||||
|
<p>Für eine einzelne Set-Position: Der Set-Kopf übernimmt die Summe seiner zugehörigen Teilpositionen, deren Einzelpreise werden dann leer dargestellt (kein Preis, nicht 0,00 €).</p>
|
||||||
|
<span class="kv">Wirkt auf: eine markierte Set-Position</span>
|
||||||
|
<span class="pill one">Einmalig — nicht per Klick umkehrbar</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">2</div>
|
||||||
|
<h4>„Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||||
|
<p>Für jeden Auftragsblock wird oben eine hervorgehobene Set-Zeile mit dem Gesamtwert des Blocks eingefügt. Die bisherigen Einzelpositionen <strong>bleiben sichtbar</strong>, jedoch ohne Einzelpreis (leeres Preisfeld). Die eingefügte Set-Zeile ist eine echte, nachträglich editierbare Position.</p>
|
||||||
|
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen bleiben erhalten</span>
|
||||||
|
<span class="pill rev">Einmalige Umwandlung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">3</div>
|
||||||
|
<h4>„Nur Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||||
|
<p>Wie „Set mit Preis" — aber die Einzelpositionen werden <strong>vollständig entfernt</strong>. Es bleibt allein die eine Set-Zeile mit dem Gesamtpreis des Blocks stehen. Ideal für eine schlanke Pauschal-Darstellung.</p>
|
||||||
|
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen werden entfernt</span>
|
||||||
|
<span class="pill rev">Einmalige Umwandlung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setnote">
|
||||||
|
<b>Gut zu wissen:</b> Die beiden Menü-Varianten (2 & 3) sind bewusste, <b>einmalige Umwandlungen</b> — es gibt keinen Umschalter zurück. Möchten Sie den Ausgangszustand wiederherstellen, verwerfen Sie einfach den Entwurf („Änderungen verwerfen"), oder passen Sie die entstandene Set-Zeile von Hand an. In jedem Fall gilt: die <b>Gesamtsumme ändert sich nicht</b>, und die PDF-Ausgabe zeigt exakt dasselbe wie der Online-Editor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BEFORE / AFTER -->
|
||||||
|
<section class="compare">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Alt gegen Neu</span>
|
||||||
|
<h2>Was sich konkret verbessert hat</h2>
|
||||||
|
<p>Ein direkter Vergleich der bisherigen Lösung mit der neuen Implementierung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bereich</th><th>Bisher (Legacy)</th><th>Neu</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rechnungsvorschau</td>
|
||||||
|
<td class="old">Kein Live-PDF — Ergebnis erst nach dem Speichern sichtbar</td>
|
||||||
|
<td class="new">Echtzeit-PDF-Vorschau schon während der Bearbeitung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Berechnung</td>
|
||||||
|
<td class="old">Beträge im Browser gerechnet — Abweichungen möglich</td>
|
||||||
|
<td class="new">Alle Werte serverseitig geprüft & einheitlich berechnet</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rückmeldungen</td>
|
||||||
|
<td class="old">Keine aktive Meldung — Status nur durch Nachschauen</td>
|
||||||
|
<td class="new">Sofortige Erfolgs- und Fehlermeldungen in Echtzeit</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Fehler im Hintergrund</td>
|
||||||
|
<td class="old">Nur im Protokoll — für den Nutzer unsichtbar</td>
|
||||||
|
<td class="new">Werden dem Nutzer verständlich angezeigt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rechnungsformat</td>
|
||||||
|
<td class="old">Reines PDF</td>
|
||||||
|
<td class="new">Zusätzlich extern validierte E-Rechnung (ZUGFeRD/DATEV oder XRechnung für Behörden) — 0 Fehler, PDF/A-3 bestätigt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Änderungsverlauf</td>
|
||||||
|
<td class="old">Nicht vorhanden</td>
|
||||||
|
<td class="new">Vollständige Historie & gezieltes Verwerfen je Entwurf</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Kontoauszüge</td>
|
||||||
|
<td class="old">Nur MT940</td>
|
||||||
|
<td class="new">MT940 <em>und</em> CAMT (ISO 20022) mit Auto-Erkennung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">System-Überblick</td>
|
||||||
|
<td class="old">Kein Einblick in den Systemzustand</td>
|
||||||
|
<td class="new">Admin-/Status-Modul mit Live-Prüfungen (für berechtigte Nutzer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Plattform</td>
|
||||||
|
<td class="old">Ältere VB-Codebasis</td>
|
||||||
|
<td class="new">Modernes .NET 10 — schneller, gepflegt, umfangreich getestet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- KEY USER -->
|
||||||
|
<section class="keyuser">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker" style="color:var(--accent-2)">Für den Key-User</span>
|
||||||
|
<h2>Mehr Kontrolle hinter den Kulissen</h2>
|
||||||
|
<p>Diese Punkte betreffen vor allem Sie als Key-User — sie sorgen dafür, dass der Betrieb stabil, nachvollziehbar und überprüfbar bleibt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku-grid">
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Admin- & Status-Modul</h3>
|
||||||
|
<p>Ein eigenes Modul zeigt den Zustand des Systems: Server, Datenbank, Schlüsseltresor, Speicher und die ERP-Anbindung werden live geprüft. Inklusive Test-E-Mail-Funktion — sichtbar nur für berechtigte Nutzer.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Durchgängige Nachvollziehbarkeit</h3>
|
||||||
|
<p>Jeder wichtige Geschäftsvorfall — erstellt, versandt, importiert, fehlgeschlagen — wird als Ereignis erfasst und in verständliche Meldungen übersetzt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Überwachung & Diagnose</h3>
|
||||||
|
<p>Moderne Telemetrie (OpenTelemetry) misst Abläufe, Laufzeiten und Fehler. Probleme lassen sich damit früher erkennen und schneller eingrenzen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Automatischer ERP-Abgleich</h3>
|
||||||
|
<p>Der Abgleich mit dem ERP-System (mfr) läuft zuverlässig im Hintergrund direkt in der Anwendung — mit automatischer Wiederholung bei kurzzeitigen Störungen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Sichere Konfiguration</h3>
|
||||||
|
<p>Zugangsdaten liegen im zentralen Azure Key Vault. Eine Test-Schutzfunktion verhindert, dass in Test-Umgebungen versehentlich echte Kunden angeschrieben werden.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Umfassend getestet</h3>
|
||||||
|
<p>Die Kernlogik ist durch eine breite, automatisierte Testabdeckung abgesichert — erfolgreiche wie fehlerhafte Abläufe werden geprüft, bevor Änderungen live gehen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- UNVERÄNDERT / VERTRAUT -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Vertraut geblieben</span>
|
||||||
|
<h2>Was sich für Sie <em>nicht</em> ändert</h2>
|
||||||
|
<p>Modernisiert wurde die Technik — nicht Ihre Arbeitsweise.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="ico">🗂️</div><h3>Gewohnte Module</h3><p>Rechnungen, Zahlungserinnerungen, Anfragen, Banking und Berichte finden Sie an denselben Stellen wie bisher.</p></div>
|
||||||
|
<div class="card"><div class="ico">📄</div><h3>Vertrautes Layout</h3><p>Briefkopf, Adressfenster und Rechnungslayout wurden 1:1 übernommen — Ihre Dokumente sehen aus wie gewohnt.</p></div>
|
||||||
|
<div class="card"><div class="ico">🔐</div><h3>Gleiche Anmeldung</h3><p>Login und Berechtigungen bleiben unverändert. Neue Funktionen erscheinen nur dort, wo Sie dafür berechtigt sind.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="wrap">
|
||||||
|
<p><b>Fuchs Intranet</b> — Neue Implementierung · Stand Juli 2026<br>
|
||||||
|
Sebastian Fuchs Bad und Heizung GmbH & Co. KG · Bereitgestellt von ProcessWeb</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
The items, if completed, should be ticked / checked as done.
|
||||||
|
|
||||||
|
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the Decisions ind \Docs\Decisions must be followed
|
||||||
|
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that whenever relevant new decisions should be captured, concept files should be created / updated
|
||||||
|
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the readme.md files in \Docs\Concept and \Docs\Decisions explain how to create, update, interpret the documents. Create those readme.md files. make sure that any concepts or decisions have a yaml header that contains applyTo key. The agents should scan those yaml headers first (saving tokens) and decide based on that if included/considered
|
||||||
|
[x] Add a first decision, that domain events (success and fails) should be identified and equiped with triggers that trigger notification to the EventService and pass on a context that allows a user understandable message like "Rechnung R2026-0001 wurde and Kunden unter test@test.de per Email versandt".
|
||||||
|
[x] Add a decision that reflects the current concept and implementation of Notification from Service in Backend over SignalR push to any logged in session to display in GUI. (failues with highlighting)
|
||||||
|
[x] Add a decision that the solution must be equiped with logging so that the diagnostics is possible without requiring a debugger or OTel collector.
|
||||||
|
[x] Add OpenTelemetry to the solution. Wire it in extensively without compromising performance.
|
||||||
|
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the unit tests should be as extensive as possible and cover false positive also. (could be imlcuded already
|
||||||
|
|
||||||
+12
-14
@@ -22,9 +22,9 @@
|
|||||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
|
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<Content Include="favicon.ico" />
|
<Content Include="favicon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -34,27 +34,27 @@
|
|||||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.16.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.16.0" />
|
||||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||||
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||||
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
|
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
|
||||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
|
||||||
<!-- Updated packages -->
|
<!-- Updated packages -->
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<!-- New packages (needed for .NET 10) -->
|
<!-- New packages (needed for .NET 10) -->
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="App_Data\cache\" />
|
<Folder Include="App_Data\cache\" />
|
||||||
@@ -65,14 +65,12 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
|
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
|
||||||
<UsingTask TaskName="ZipDir" TaskFactory="CodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
<UsingTask TaskName="ZipDir" TaskFactory="RoslynCodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
||||||
<ParameterGroup>
|
<ParameterGroup>
|
||||||
<Source ParameterType="System.String" Required="true" />
|
<Source ParameterType="System.String" Required="true" />
|
||||||
<Destination ParameterType="System.String" Required="true" />
|
<Destination ParameterType="System.String" Required="true" />
|
||||||
</ParameterGroup>
|
</ParameterGroup>
|
||||||
<Task>
|
<Task>
|
||||||
<Reference Include="System.IO.Compression" />
|
|
||||||
<Reference Include="System.IO.Compression.FileSystem" />
|
|
||||||
<Code Type="Fragment" Language="cs">
|
<Code Type="Fragment" Language="cs">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
if (System.IO.File.Exists(Destination)) {
|
if (System.IO.File.Exists(Destination)) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
public enum DomainEventType
|
||||||
|
{
|
||||||
|
InvoiceDraftCreated,
|
||||||
|
InvoiceDraftUpdated,
|
||||||
|
InvoiceFileCreated,
|
||||||
|
InvoiceSentToCustomer,
|
||||||
|
InvoiceResentToCustomer,
|
||||||
|
InvoiceMarkedSent,
|
||||||
|
InvoiceCreationFailed,
|
||||||
|
InvoiceFileCreationFailed,
|
||||||
|
InvoiceSendFailed,
|
||||||
|
ReminderDraftCreated,
|
||||||
|
ReminderDraftUpdated,
|
||||||
|
ReminderFileCreated,
|
||||||
|
ReminderSentToCustomer,
|
||||||
|
ReminderResentToCustomer,
|
||||||
|
ReminderMarkedSent,
|
||||||
|
ReminderCreationFailed,
|
||||||
|
ReminderFileCreationFailed,
|
||||||
|
ReminderSendFailed,
|
||||||
|
BankingTransactionsImported,
|
||||||
|
BankingImportFailed,
|
||||||
|
BankingTransactionMarkedDone,
|
||||||
|
UserIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DomainEvent(
|
||||||
|
DomainEventType Type,
|
||||||
|
string UserAccountId,
|
||||||
|
string Title,
|
||||||
|
IReadOnlyDictionary<string, object?> Context)
|
||||||
|
{
|
||||||
|
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
|
||||||
|
/// SignalR group named after the draft token so only the editing browser is notified.
|
||||||
|
/// Like <see cref="EventService.PublishAsync"/>, delivery failures are logged and
|
||||||
|
/// swallowed — a missed coordination ping must never fail the underlying operation
|
||||||
|
/// (the client also re-syncs on reconnect and on its next POST).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DraftNotifier : IDraftNotifier
|
||||||
|
{
|
||||||
|
private readonly IHubContext<DraftPreviewHub> _hub;
|
||||||
|
private readonly ILogger<DraftNotifier> _logger;
|
||||||
|
|
||||||
|
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
|
||||||
|
|
||||||
|
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
|
||||||
|
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
|
||||||
|
|
||||||
|
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
|
||||||
|
///
|
||||||
|
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
|
||||||
|
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
|
||||||
|
/// signals must be <b>targeted</b> at the one browser editing a given draft.
|
||||||
|
/// Targeting is done with a SignalR group named after the draft's session token —
|
||||||
|
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
|
||||||
|
///
|
||||||
|
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
|
||||||
|
/// (see ADR 0006). The hub only manages group membership and delivers the server's
|
||||||
|
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public sealed class DraftPreviewHub : Hub
|
||||||
|
{
|
||||||
|
/// <summary>Subscribes this connection to a draft's signal group.</summary>
|
||||||
|
public Task JoinDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.AddToGroupAsync(Context.ConnectionId, token);
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
|
||||||
|
public Task LeaveDraft(string token) =>
|
||||||
|
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||||
|
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
public sealed class EventService : IEventService
|
||||||
|
{
|
||||||
|
private readonly IHubContext<NotificationHub> _hub;
|
||||||
|
private readonly ILogger<EventService> _logger;
|
||||||
|
|
||||||
|
public EventService(IHubContext<NotificationHub> hub, ILogger<EventService> logger)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GuiNotification notification = BuildNotification(domainEvent);
|
||||||
|
await _hub.Clients
|
||||||
|
.All
|
||||||
|
.SendAsync("notification", notification, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Notification publish failed for {EventType}", domainEvent.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId)
|
||||||
|
{
|
||||||
|
var type = changed ? DomainEventType.InvoiceDraftUpdated : DomainEventType.InvoiceDraftCreated;
|
||||||
|
return PublishAsync(new DomainEvent(type, userAccountId, "Rechnungsentwurf", InvoiceContext(invoice)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId)
|
||||||
|
{
|
||||||
|
var ctx = InvoiceContext(invoice);
|
||||||
|
ctx["fileName"] = fileName;
|
||||||
|
return PublishAsync(new DomainEvent(DomainEventType.InvoiceFileCreated, userAccountId, "Rechnungsdatei", ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false)
|
||||||
|
{
|
||||||
|
var ctx = InvoiceContext(invoice);
|
||||||
|
ctx["email"] = email;
|
||||||
|
return PublishAsync(new DomainEvent(
|
||||||
|
resent ? DomainEventType.InvoiceResentToCustomer : DomainEventType.InvoiceSentToCustomer,
|
||||||
|
userAccountId,
|
||||||
|
"Rechnung versandt",
|
||||||
|
ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> ctx = new()
|
||||||
|
{
|
||||||
|
["id"] = invoiceId,
|
||||||
|
["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoiceId : invoiceNumber
|
||||||
|
};
|
||||||
|
return PublishAsync(new DomainEvent(DomainEventType.InvoiceMarkedSent, userAccountId, "Rechnung markiert", ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "")
|
||||||
|
=> PublishAsync(new DomainEvent(
|
||||||
|
DomainEventType.InvoiceCreationFailed,
|
||||||
|
userAccountId,
|
||||||
|
"Rechnung",
|
||||||
|
new Dictionary<string, object?> { ["id"] = invoiceId, ["message"] = message }));
|
||||||
|
|
||||||
|
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
||||||
|
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||||
|
|
||||||
|
public Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId)
|
||||||
|
{
|
||||||
|
var type = changed ? DomainEventType.ReminderDraftUpdated : DomainEventType.ReminderDraftCreated;
|
||||||
|
return PublishAsync(new DomainEvent(type, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
||||||
|
{
|
||||||
|
var ctx = ReminderContext(reminder);
|
||||||
|
ctx["fileName"] = fileName;
|
||||||
|
return PublishAsync(new DomainEvent(DomainEventType.ReminderFileCreated, userAccountId, "Mahndatei", ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false)
|
||||||
|
{
|
||||||
|
var ctx = ReminderContext(reminder);
|
||||||
|
ctx["email"] = email;
|
||||||
|
return PublishAsync(new DomainEvent(
|
||||||
|
resent ? DomainEventType.ReminderResentToCustomer : DomainEventType.ReminderSentToCustomer,
|
||||||
|
userAccountId,
|
||||||
|
"Mahnung versandt",
|
||||||
|
ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> ctx = new()
|
||||||
|
{
|
||||||
|
["id"] = reminderId,
|
||||||
|
["title"] = string.IsNullOrWhiteSpace(reminderTitle) ? reminderId : reminderTitle
|
||||||
|
};
|
||||||
|
return PublishAsync(new DomainEvent(DomainEventType.ReminderMarkedSent, userAccountId, "Mahnung markiert", ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReminderIssueAsync(string message, string userAccountId, string reminderId = "")
|
||||||
|
=> PublishAsync(new DomainEvent(
|
||||||
|
DomainEventType.ReminderCreationFailed,
|
||||||
|
userAccountId,
|
||||||
|
"Mahnung",
|
||||||
|
new Dictionary<string, object?> { ["id"] = reminderId, ["message"] = message }));
|
||||||
|
|
||||||
|
public Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId)
|
||||||
|
=> PublishAsync(new DomainEvent(
|
||||||
|
DomainEventType.BankingTransactionsImported,
|
||||||
|
userAccountId,
|
||||||
|
"Banking",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["from"] = from,
|
||||||
|
["to"] = to,
|
||||||
|
["rows"] = rows,
|
||||||
|
["fileName"] = fileName
|
||||||
|
}));
|
||||||
|
|
||||||
|
public Task BankingImportIssueAsync(string message, string fileName, string userAccountId)
|
||||||
|
=> PublishAsync(new DomainEvent(
|
||||||
|
DomainEventType.BankingImportFailed,
|
||||||
|
userAccountId,
|
||||||
|
"Banking",
|
||||||
|
new Dictionary<string, object?> { ["fileName"] = fileName, ["message"] = message }));
|
||||||
|
|
||||||
|
public Task BankingTransactionMarkedDoneAsync(string taId, DateTime? valueDate, decimal? amount, string userAccountId)
|
||||||
|
=> PublishAsync(new DomainEvent(
|
||||||
|
DomainEventType.BankingTransactionMarkedDone,
|
||||||
|
userAccountId,
|
||||||
|
"Banking",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["taId"] = taId,
|
||||||
|
["valueDate"] = valueDate,
|
||||||
|
["amount"] = amount
|
||||||
|
}));
|
||||||
|
|
||||||
|
public Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> ctx = context == null
|
||||||
|
? new Dictionary<string, object?>()
|
||||||
|
: new Dictionary<string, object?>(context);
|
||||||
|
ctx["message"] = message;
|
||||||
|
return PublishAsync(new DomainEvent(DomainEventType.UserIssue, userAccountId, title, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiNotification BuildNotification(DomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
string message = domainEvent.Type switch
|
||||||
|
{
|
||||||
|
DomainEventType.InvoiceDraftCreated =>
|
||||||
|
$"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde erstellt.",
|
||||||
|
DomainEventType.InvoiceDraftUpdated =>
|
||||||
|
$"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde aktualisiert.",
|
||||||
|
DomainEventType.InvoiceFileCreated =>
|
||||||
|
$"Rechnungsdatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||||
|
DomainEventType.InvoiceSentToCustomer =>
|
||||||
|
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.",
|
||||||
|
DomainEventType.InvoiceResentToCustomer =>
|
||||||
|
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde erneut an {Ctx(domainEvent, "email")} versandt.",
|
||||||
|
DomainEventType.InvoiceMarkedSent =>
|
||||||
|
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde als versandt markiert.",
|
||||||
|
DomainEventType.InvoiceCreationFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.InvoiceFileCreationFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.InvoiceSendFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.ReminderDraftCreated =>
|
||||||
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
||||||
|
DomainEventType.ReminderDraftUpdated =>
|
||||||
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde aktualisiert.",
|
||||||
|
DomainEventType.ReminderFileCreated =>
|
||||||
|
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||||
|
DomainEventType.ReminderSentToCustomer =>
|
||||||
|
$"Mahnung {Ctx(domainEvent, "title")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.",
|
||||||
|
DomainEventType.ReminderResentToCustomer =>
|
||||||
|
$"Mahnung {Ctx(domainEvent, "title")} wurde erneut an {Ctx(domainEvent, "email")} versandt.",
|
||||||
|
DomainEventType.ReminderMarkedSent =>
|
||||||
|
$"Mahnung {Ctx(domainEvent, "title")} wurde als versandt markiert.",
|
||||||
|
DomainEventType.ReminderCreationFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.ReminderFileCreationFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.ReminderSendFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.BankingTransactionsImported =>
|
||||||
|
BankingImportMessage(domainEvent),
|
||||||
|
DomainEventType.BankingImportFailed =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.BankingTransactionMarkedDone =>
|
||||||
|
BankingTransactionMarkedDoneMessage(domainEvent),
|
||||||
|
DomainEventType.UserIssue =>
|
||||||
|
Ctx(domainEvent, "message"),
|
||||||
|
_ => domainEvent.Title
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GuiNotification(
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
domainEvent.Type.ToString(),
|
||||||
|
domainEvent.Title,
|
||||||
|
message,
|
||||||
|
IsFailure(domainEvent.Type) ? "error" : "info",
|
||||||
|
domainEvent.CreatedAt,
|
||||||
|
domainEvent.Context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFailure(DomainEventType type) =>
|
||||||
|
type is DomainEventType.InvoiceCreationFailed
|
||||||
|
or DomainEventType.InvoiceFileCreationFailed
|
||||||
|
or DomainEventType.InvoiceSendFailed
|
||||||
|
or DomainEventType.ReminderCreationFailed
|
||||||
|
or DomainEventType.ReminderFileCreationFailed
|
||||||
|
or DomainEventType.ReminderSendFailed
|
||||||
|
or DomainEventType.BankingImportFailed
|
||||||
|
or DomainEventType.UserIssue;
|
||||||
|
|
||||||
|
private static string BankingImportMessage(DomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
int rows = int.TryParse(Ctx(domainEvent, "rows"), out int r) ? r : 0;
|
||||||
|
string movement = rows == 1 ? "Kontobewegung" : "Kontobewegungen";
|
||||||
|
string period = BankingPeriod(domainEvent);
|
||||||
|
return string.IsNullOrEmpty(period)
|
||||||
|
? $"{rows} {movement} wurden importiert."
|
||||||
|
: $"{movement} für {period} wurden importiert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BankingPeriod(DomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
DateTime? from = DateCtx(domainEvent, "from");
|
||||||
|
DateTime? to = DateCtx(domainEvent, "to");
|
||||||
|
if (from == null && to == null) return "";
|
||||||
|
if (from != null && to != null)
|
||||||
|
{
|
||||||
|
string fromFmt = from.Value.Year == to.Value.Year
|
||||||
|
? from.Value.ToString("d.M.")
|
||||||
|
: from.Value.ToString("d.M.yyyy");
|
||||||
|
string toFmt = from.Value.Year == to.Value.Year
|
||||||
|
? to.Value.ToString("dd.MM.")
|
||||||
|
: to.Value.ToString("dd.MM.yyyy");
|
||||||
|
return $"{fromFmt} - {toFmt}";
|
||||||
|
}
|
||||||
|
return (from ?? to)!.Value.ToString("dd.MM.yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? DateCtx(DomainEvent domainEvent, string key)
|
||||||
|
{
|
||||||
|
if (!domainEvent.Context.TryGetValue(key, out var value) || value == null) return null;
|
||||||
|
if (value is DateTime dt) return dt;
|
||||||
|
if (value is DateTimeOffset dto) return dto.DateTime;
|
||||||
|
return DateTime.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BankingTransactionMarkedDoneMessage(DomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
DateTime? valueDate = DateCtx(domainEvent, "valueDate");
|
||||||
|
string amount = AmountCtx(domainEvent, "amount");
|
||||||
|
string datePart = valueDate == null ? "" : $" vom {valueDate.Value:dd.MM.yy}";
|
||||||
|
string amountPart = string.IsNullOrEmpty(amount) ? "" : $" über {amount}€";
|
||||||
|
return $"Bank-Transaktion{datePart}{amountPart} wurde als erledigt markiert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AmountCtx(DomainEvent domainEvent, string key)
|
||||||
|
{
|
||||||
|
if (!domainEvent.Context.TryGetValue(key, out var value) || value == null) return "";
|
||||||
|
decimal? amount = value switch
|
||||||
|
{
|
||||||
|
decimal d => d,
|
||||||
|
double d => (decimal)d,
|
||||||
|
_ => decimal.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
return amount?.ToString("0.00", Fuchs_intranet.DeCulture) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> InvoiceContext(FdsInvoiceData invoice)
|
||||||
|
{
|
||||||
|
string invoiceNumber = invoice.InvoiceId;
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = invoice.Id,
|
||||||
|
["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoice.Id : invoiceNumber,
|
||||||
|
["documentName"] = invoice.InvoiceRegistration?.getString("DocumentName") ?? "",
|
||||||
|
["email"] = invoice.InvoiceRegistration?.getString("SendToEmail") ?? "",
|
||||||
|
["title"] = invoice.InvoiceTitle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> ReminderContext(FdsReminderData reminder)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = reminder.Id,
|
||||||
|
["invoiceNumber"] = reminder.InvoiceId,
|
||||||
|
["title"] = string.IsNullOrWhiteSpace(reminder.ReminderTitle) ? reminder.Id : reminder.ReminderTitle,
|
||||||
|
["documentName"] = reminder.ReminderRegistration?.getString("DocumentName") ?? "",
|
||||||
|
["email"] = reminder.InvoiceEmail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Ctx(DomainEvent domainEvent, string key)
|
||||||
|
{
|
||||||
|
if (!domainEvent.Context.TryGetValue(key, out var value)) return "";
|
||||||
|
return value?.ToString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
public sealed record GuiNotification(
|
||||||
|
string Id,
|
||||||
|
string Type,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
string Severity,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
IReadOnlyDictionary<string, object?> Context);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends <b>system-internal</b> draft-editing signals to the one browser editing a
|
||||||
|
/// given draft, over the <see cref="DraftPreviewHub"/> group keyed by session token
|
||||||
|
/// (see ADR 0006 / 0007). These are coordination pings, not business notifications:
|
||||||
|
/// user-facing success/failure messages (e.g. "Zwischenstand gespeichert") still go
|
||||||
|
/// through <see cref="IEventService"/> / <see cref="NotificationHub"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDraftNotifier
|
||||||
|
{
|
||||||
|
/// <summary>The cached draft reached a new <paramref name="version"/> — the client should re-fetch its state.</summary>
|
||||||
|
Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>The draft is about to expire in <paramref name="secondsLeft"/>s unless saved — warn the user.</summary>
|
||||||
|
Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>The draft session was removed (evicted/expired/discarded) — the client must close the editor and show why.</summary>
|
||||||
|
Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
public interface IEventService
|
||||||
|
{
|
||||||
|
Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId);
|
||||||
|
Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId);
|
||||||
|
Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false);
|
||||||
|
Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId);
|
||||||
|
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
||||||
|
|
||||||
|
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
||||||
|
Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId);
|
||||||
|
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
||||||
|
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
||||||
|
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
||||||
|
Task ReminderIssueAsync(string message, string userAccountId, string reminderId = "");
|
||||||
|
|
||||||
|
Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId);
|
||||||
|
Task BankingImportIssueAsync(string message, string fileName, string userAccountId);
|
||||||
|
Task BankingTransactionMarkedDoneAsync(string taId, DateTime? valueDate, decimal? amount, string userAccountId);
|
||||||
|
|
||||||
|
Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Fuchs.Notifications;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public sealed class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
namespace Fuchs.Observability;
|
namespace Fuchs.Observability;
|
||||||
@@ -39,8 +39,21 @@ public static class FuchsTelemetry
|
|||||||
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
||||||
public static readonly Counter<long> Mt940RowsParsed =
|
public static readonly Counter<long> Mt940RowsParsed =
|
||||||
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
||||||
|
public static readonly Counter<long> BankingEntriesSkipped =
|
||||||
|
Meter.CreateCounter<long>("fuchs.banking.entries.skipped", "{entry}",
|
||||||
|
"Number of bank statement entries/statements dropped during parsing, tagged by reason.");
|
||||||
|
public static readonly Counter<long> BankingFieldsTruncated =
|
||||||
|
Meter.CreateCounter<long>("fuchs.banking.fields.truncated", "{field}",
|
||||||
|
"Number of parsed fields truncated to fit the destination column width.");
|
||||||
public static readonly Counter<long> MfrCalls =
|
public static readonly Counter<long> MfrCalls =
|
||||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||||
|
public static readonly Counter<long> BlobUploadsSucceeded =
|
||||||
|
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||||
|
public static readonly Counter<long> BlobUploadsFailed =
|
||||||
|
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||||
|
public static readonly Counter<long> SystemProbes =
|
||||||
|
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
|
||||||
|
"Admin system-status connectivity probes executed, tagged by component and outcome.");
|
||||||
|
|
||||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||||
public static readonly Histogram<double> PdfRenderDuration =
|
public static readonly Histogram<double> PdfRenderDuration =
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user