Compare commits
28
Commits
1a3bf30442
...
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 |
@@ -1,16 +1,21 @@
|
||||
# Copilot Instructions
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file (`.github/copilot-instructions.md`) and the Claude Code instructions
|
||||
> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**.
|
||||
> Whenever you change one, make the equivalent change in the other in the same
|
||||
> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared
|
||||
> This file (`.github/copilot-instructions.md`), the Claude Code instructions
|
||||
> (`/CLAUDE.md`), and the Codex instructions (`/CODEX.md`) are **three views of
|
||||
> the same project rules and must stay in sync**.
|
||||
> 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,
|
||||
> secrets, observability) must match.
|
||||
|
||||
## Project Overview
|
||||
- **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`.
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
|
||||
- Project structure (relative to `Fuchs/`):
|
||||
- `Controllers/` — `IntranetController` partials (no area)
|
||||
- `code/` — business logic, PDF, email, widgets, data models
|
||||
@@ -42,8 +47,8 @@
|
||||
|
||||
## 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.
|
||||
- `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`.
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
|
||||
- `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`, `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.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
|
||||
@@ -74,6 +79,11 @@
|
||||
- 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
|
||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Scratch / build-verification output
|
||||
/tmp/
|
||||
|
||||
# SSDT / SQL database project caches (regenerated)
|
||||
*.dbmdl
|
||||
*.jfm
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
[submodule "OCORE"]
|
||||
path = OCORE
|
||||
url = https://git.processweb.de/Stefan/OCORE.git
|
||||
branch = main
|
||||
[submodule "OCORE_web"]
|
||||
path = OCORE_web
|
||||
url = https://git.processweb.de/Stefan/OCORE_web.git
|
||||
branch = main
|
||||
[submodule "OCORE_web_pdf"]
|
||||
path = OCORE_web_pdf
|
||||
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
|
||||
branch = main
|
||||
[submodule "OCORE_Charting"]
|
||||
path = OCORE_Charting
|
||||
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
@@ -52,33 +52,48 @@ public sealed class CamtParser
|
||||
/// <summary>
|
||||
/// Parses all CAMT XML files found inside a ZIP archive and returns
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<CamtStatement> ParseZip(byte[] bytes)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
return ParseZip(ms);
|
||||
}
|
||||
public List<CamtStatement> ParseZip(byte[] bytes) => ParseZip(bytes, out _);
|
||||
|
||||
/// <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 skipped = new List<string>();
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
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 buffer = new MemoryStream();
|
||||
entryStream.CopyTo(buffer);
|
||||
var entryBytes = buffer.ToArray();
|
||||
if (!LooksLikeXml(entryBytes))
|
||||
{
|
||||
skipped.Add($"{entry.Name}: not XML content");
|
||||
continue;
|
||||
try { result.AddRange(Parse(entryBytes)); }
|
||||
catch (FormatException) { /* skip malformed XML entries */ }
|
||||
}
|
||||
try { result.AddRange(Parse(entryBytes)); }
|
||||
catch (FormatException ex) { skipped.Add($"{entry.Name}: {ex.Message}"); }
|
||||
}
|
||||
skippedEntries = skipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
# CLAUDE.md — Project instructions for Claude Code
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file and **`.github/copilot-instructions.md`** are two views of the same
|
||||
> project rules and **must stay in sync**. When you change a shared rule
|
||||
> This file, **`CODEX.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 **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
|
||||
> project facts must match `copilot-instructions.md`.
|
||||
> project facts must match `CODEX.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`). `MT940Parser` is an external referenced project.
|
||||
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project.
|
||||
|
||||
## Build & Test (workflow)
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- 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: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
## 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`. 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.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
@@ -69,8 +70,15 @@
|
||||
## 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.
|
||||
|
||||
@@ -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")]
|
||||
@@ -129,4 +129,51 @@ public class BankingParseToDatatableTests
|
||||
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
||||
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 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,20 @@ public class DocumentMetadataBuilderTests
|
||||
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("")]
|
||||
|
||||
@@ -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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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.Linq;
|
||||
using Fuchs.intranet;
|
||||
@@ -31,16 +31,25 @@ public class InvoiceOptionsTests
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||
|
||||
[Fact]
|
||||
public void DefaultSetPrice_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||
public void ExplicitDefaultSetPrice_EmitsToken()
|
||||
// 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]
|
||||
[InlineData("itemprices", "setmode:itemprices")]
|
||||
[InlineData("setonly", "setmode:setonly")]
|
||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
||||
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
|
||||
public void SetMode_EmitsToken(string mode, string expected)
|
||||
=> 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]
|
||||
public void UnknownSetMode_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||
@@ -103,9 +112,11 @@ public class InvoiceOptionsTests
|
||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||
{
|
||||
// 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,
|
||||
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 Fuchs.intranet;
|
||||
using Xunit;
|
||||
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("set_only", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
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]
|
||||
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
|
||||
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 ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
|
||||
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]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
|
||||
{
|
||||
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", "B", "80.00", "80.00")
|
||||
};
|
||||
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 ───────────────────────────────────────
|
||||
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
||||
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
|
||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||
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");
|
||||
Assert.False(note.ShowPrice); // text member stays blank
|
||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
||||
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
|
||||
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) ─
|
||||
|
||||
@@ -55,6 +55,18 @@ public class MFRClientConfigTests
|
||||
Assert.Equal("", config.LogoutAddress);
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -48,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 readonly HttpMessageHandler _handler;
|
||||
@@ -55,6 +62,41 @@ public class ProcessWebComServiceTests
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -129,6 +171,102 @@ public class ProcessWebComServiceTests
|
||||
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()
|
||||
@@ -141,7 +279,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -153,7 +291,7 @@ public class ProcessWebComServiceTests
|
||||
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string subject = json["subject"]!.ToString();
|
||||
string subject = json["communication"]!["subject"]!.ToString();
|
||||
Assert.Contains("realcustomer@example.de", subject);
|
||||
Assert.Contains("Rechnung 123", subject);
|
||||
}
|
||||
@@ -169,7 +307,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -181,7 +319,7 @@ public class ProcessWebComServiceTests
|
||||
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["recipient"]!.ToString());
|
||||
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
||||
}
|
||||
|
||||
// ── Override enforcement across every real appsettings*.json environment ───
|
||||
@@ -194,9 +332,9 @@ public class ProcessWebComServiceTests
|
||||
// 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 "recipient" field - there is no distinct
|
||||
// Note: this API has a single "communication.to" field - there is no distinct
|
||||
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||
// SendEmailAsync / payload.recipient). "to/cc/bcc cleared" is therefore fully
|
||||
// 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()
|
||||
@@ -236,7 +374,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["recipient"]!.ToString();
|
||||
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
@@ -266,7 +404,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["attachments"]!;
|
||||
var att = (JArray)json["communication"]!["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.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 });
|
||||
|
||||
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);
|
||||
var uploadResults = new List<object>();
|
||||
foreach (var fle in Request.Form.Files)
|
||||
{
|
||||
using var stream = fle.OpenReadStream();
|
||||
@@ -34,6 +35,9 @@ public partial class IntranetController
|
||||
|
||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||
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)
|
||||
{
|
||||
@@ -48,16 +52,80 @@ public partial class IntranetController
|
||||
dtwa.CommandAfterError = new SqlCommand(
|
||||
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
|
||||
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",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
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",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
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);
|
||||
}
|
||||
return Ok();
|
||||
|
||||
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 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":
|
||||
{
|
||||
@@ -122,11 +190,21 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("taid")) return BadRequest400();
|
||||
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;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
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":
|
||||
@@ -139,7 +217,55 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
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":
|
||||
@@ -155,14 +281,47 @@ public partial class IntranetController
|
||||
}
|
||||
|
||||
default:
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) =>
|
||||
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 = "") =>
|
||||
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)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
||||
return ok ? Ok() : StatusCode(500);
|
||||
return ok ? await JSONAsync(new { ok = true }) : StatusCode(500);
|
||||
}
|
||||
|
||||
case "setupd":
|
||||
@@ -50,7 +50,7 @@ public partial class IntranetController
|
||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
||||
return ok ? Ok() : StatusCode(500);
|
||||
return ok ? await JSONAsync(new { ok = true }) : StatusCode(500);
|
||||
}
|
||||
|
||||
case "setvat":
|
||||
@@ -72,7 +72,7 @@ public partial class IntranetController
|
||||
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
|
||||
if (!string.IsNullOrEmpty(sqlEx))
|
||||
_logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID);
|
||||
return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx });
|
||||
return string.IsNullOrEmpty(sqlEx) ? await JSONAsync(new { ok = true }) : StatusCode(500, new { error = sqlEx });
|
||||
}
|
||||
|
||||
case "sis":
|
||||
@@ -86,8 +86,15 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt2.Exception))
|
||||
{
|
||||
_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":
|
||||
@@ -149,11 +156,20 @@ public partial class IntranetController
|
||||
using (var mfr = _mfrFactory.Create())
|
||||
await mfr.Update__entitytable(EntityTypes.Invoice,
|
||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
// ── 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:
|
||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public partial class IntranetController
|
||||
_logger.LogInformation("HandleInvoicePget reset complete for tgtid={TgtId} invoices={InvCount} serviceRequests={SrqCount} user={User}",
|
||||
tgtid, invIds.Count, srqIds.Count, UserAccountID);
|
||||
}
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleInvoiceGet(string fn, string id, string code)
|
||||
@@ -78,16 +78,30 @@ public partial class IntranetController
|
||||
var ldic = BuildInvoiceRequestList(sqldset);
|
||||
var adminDic = sqldset.Table("admin").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)
|
||||
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}",
|
||||
invoiceId, ldic.Count, has13b, UserAccountID);
|
||||
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +404,20 @@ public partial class IntranetController
|
||||
var d = sitm.toObjectDictionary();
|
||||
double net = Convert.ToDouble(d.no("value_total", 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?>
|
||||
{
|
||||
["Id"] = d["Id"],
|
||||
@@ -399,8 +427,11 @@ public partial class IntranetController
|
||||
["svcnet_val"] = d.no("value_service", 0),
|
||||
["net"] = d.no("value", 0),
|
||||
["quantity"] = d["Quantity"],
|
||||
["quantityhours"] = quantityHours,
|
||||
["UnitString"] = unitString,
|
||||
["Type"] = d["Type"],
|
||||
["Note"] = null,
|
||||
["NameOrNumber"] = "",
|
||||
["htmltext"] = d["Text"],
|
||||
["position"] = d["Position"],
|
||||
["SortOrder"] = d["SortOrder"]
|
||||
|
||||
@@ -22,7 +22,9 @@ public partial class IntranetController
|
||||
{
|
||||
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(
|
||||
SQL_VarChar("@InvId", Form("id")),
|
||||
SQL_VarChar("@type", Form("type")),
|
||||
@@ -32,41 +34,61 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "rem" },
|
||||
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() });
|
||||
}
|
||||
|
||||
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 fdRem = await _reminders.RegisterReminderAsync(
|
||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||
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));
|
||||
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 "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 dt2 = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
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":
|
||||
{
|
||||
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);
|
||||
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"
|
||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
||||
: 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 "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":
|
||||
{
|
||||
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(
|
||||
"EXECUTE [dbo].[fds__lookupReminders] @InvId, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
|
||||
tablenames: new[] { "ov", "rem" },
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"EXECUTE [dbo].[fds__setReminderFinal] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
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();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
@@ -127,23 +166,43 @@ public partial class IntranetController
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
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();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
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 await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
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);
|
||||
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");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
@@ -158,7 +217,8 @@ public partial class IntranetController
|
||||
|
||||
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 dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||
@@ -178,14 +238,39 @@ public partial class IntranetController
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] 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"))}",
|
||||
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true);
|
||||
}
|
||||
return Ok();
|
||||
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 StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
}
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
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) =>
|
||||
|
||||
@@ -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")
|
||||
.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
|
||||
{
|
||||
|
||||
@@ -27,13 +27,16 @@ public partial class IntranetController
|
||||
|
||||
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(
|
||||
"EXECUTE [dbo].[fds__toggleRequestHidden] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_BigInt("@Id", Form("id"))),
|
||||
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();
|
||||
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":
|
||||
{
|
||||
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(
|
||||
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)
|
||||
? 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":
|
||||
{
|
||||
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(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: false, invId: "", UserAccountID, DbSec);
|
||||
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));
|
||||
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":
|
||||
{
|
||||
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(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||
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));
|
||||
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":
|
||||
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,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
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 "idoc": return await HandleRequestIdoc(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)
|
||||
{
|
||||
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();
|
||||
_logger.LogDebug("HandleRequestList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
|
||||
if (mode == "s" && Form("tgt").Contains(':'))
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
@@ -122,7 +149,10 @@ public partial class IntranetController
|
||||
}
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||
{
|
||||
_logger.LogWarning("HandleRequestList: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
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()!);
|
||||
foreach (var r in req)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestPget(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid)) return BadRequest400();
|
||||
if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid))
|
||||
{
|
||||
_logger.LogWarning("HandleRequestPget: missing/invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
_logger.LogDebug("HandleRequestPget tgtid={TgtId} user={User}", tgtid, UserAccountID);
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fds__getRequestTreeIds](@srqid);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
@@ -170,20 +213,25 @@ public partial class IntranetController
|
||||
if (iid > 0 && !ids.Contains(iid)) ids.Add(iid);
|
||||
}
|
||||
}
|
||||
_logger.LogDebug("HandleRequestPget tgtid={TgtId} resolved {Count} related ids: {Ids}", tgtid, ids.Count, string.Join(",", ids));
|
||||
|
||||
var schemaDic = new Dictionary<string, fds.FdsMfrClient.DatabaseSchema>
|
||||
{
|
||||
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
|
||||
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
|
||||
};
|
||||
using var mfr = _mfrFactory.Create();
|
||||
await mfr.Update__entitytable(EntityTypes.ServiceRequest,
|
||||
bool ok = await mfr.Update__entitytable(EntityTypes.ServiceRequest,
|
||||
fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic);
|
||||
return Ok();
|
||||
_logger.LogInformation("HandleRequestPget MFR update complete tgtid={TgtId} ids={Count} success={Success} user={User}",
|
||||
tgtid, ids.Count, ok, UserAccountID);
|
||||
return await JSONAsync(new { ok });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestGet(string fn, string id, string code)
|
||||
{
|
||||
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[] tn = modeVal switch
|
||||
{
|
||||
@@ -210,7 +258,8 @@ public partial class IntranetController
|
||||
|
||||
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(
|
||||
SQL_BigInt("@servicerequestid", Form("id")),
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
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();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
@@ -285,31 +337,72 @@ public partial class IntranetController
|
||||
body, email.Trim(), "", inv);
|
||||
if (sent)
|
||||
{
|
||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
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();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
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 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)
|
||||
{
|
||||
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);
|
||||
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");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
|
||||
if (ct == null)
|
||||
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
|
||||
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
@@ -335,14 +429,35 @@ public partial class IntranetController
|
||||
{
|
||||
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
||||
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")}",
|
||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||
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 StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
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) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Web;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -33,6 +34,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
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> _allowedGet = new()
|
||||
{
|
||||
@@ -59,7 +65,12 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders)
|
||||
IReminderService reminders,
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
IReminderDraftService reminderDrafts,
|
||||
IDraftNotifier draftNotifier,
|
||||
ISystemStatusService systemStatus)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -72,6 +83,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
_reminderDrafts = reminderDrafts;
|
||||
_draftNotifier = draftNotifier;
|
||||
_systemStatus = systemStatus;
|
||||
}
|
||||
|
||||
/// <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 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 ────────────────────────────────────────────────────────
|
||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||
@@ -140,7 +156,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
{
|
||||
IActionResult? result = fn.ToLower() switch
|
||||
{
|
||||
"ping" => Ok(),
|
||||
"ping" => await JSONAsync(new { ok = true }),
|
||||
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
|
||||
"todos" => new PhysicalFileResult(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
|
||||
@@ -150,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rem" => await Do_Process_Reminder(fn, id, code),
|
||||
"rep" => await Do_Process_Reports(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),
|
||||
"spwc" => await HandleSendPasswordCode(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);
|
||||
else
|
||||
_logger.LogDebug("Do completed fn={Fn}/{Id} result={ResultType}", fn, id, result.GetType().Name);
|
||||
return result ?? Ok();
|
||||
return result ?? await JSONAsync(new { ok = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -251,7 +281,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
UserAccountID, HttpContext.Connection.RemoteIpAddress);
|
||||
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
|
||||
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
// ── Password helpers ──────────────────────────────────────────────────────
|
||||
@@ -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);
|
||||
}
|
||||
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)
|
||||
@@ -319,7 +349,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
{
|
||||
_logger.LogWarning("HandleSendPassword: TOTP verification failed for email={Email}", email);
|
||||
}
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleAccount(string fn, string id, string code)
|
||||
@@ -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);
|
||||
}
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
case "changepassword":
|
||||
string? npw = Request.Form["npw"];
|
||||
@@ -396,10 +426,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
},
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("Password changed successfully for user={User}", UserAccountID);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
_logger.LogWarning("HandleAccount unknown action={Action} user={User}", id, UserAccountID);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleMfr(string fn, string id, string code)
|
||||
@@ -425,7 +455,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
}
|
||||
_logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}",
|
||||
UserAccountID, UserIdent.Authorization);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleMfrUpdate(string fn, string id, string code)
|
||||
@@ -440,7 +470,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
using var mfrSingle = _mfrFactory.Create();
|
||||
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
|
||||
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"]))
|
||||
{
|
||||
@@ -449,7 +479,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
using var mfr = _mfrFactory.Create();
|
||||
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
|
||||
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
|
||||
return Ok();
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
_logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID);
|
||||
return BadRequest400();
|
||||
|
||||
@@ -0,0 +1,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 |
|
||||
|---|---|---|
|
||||
| **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`. |
|
||||
| **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 |
|
||||
@@ -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 — BackgroundService with PeriodicTimer │
|
||||
│ PeriodicHostedService — in Fuchs/Services; BackgroundService + │
|
||||
│ PeriodicTimer, registered in Program.cs │
|
||||
│ when Fds:SyncEnabled (ADR 0010) │
|
||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
||||
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||
│ FdsDebug.cs — Debug/file logging │
|
||||
│ │
|
||||
│ Jobs: MfrSync (every N min) │
|
||||
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||
│ → UpdateIfNecessary_async (entity table sync) │
|
||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||
│ → 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)
|
||||
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`).
|
||||
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()`).
|
||||
`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.
|
||||
|
||||
### 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
|
||||
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`.
|
||||
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`).
|
||||
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
|
||||
|
||||
> **⚠️ 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
|
||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||
|
||||
@@ -90,12 +90,20 @@ 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, address, email, provision
|
||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
||||
- **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) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
|
||||
presentational — totals never change.
|
||||
(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
|
||||
@@ -122,7 +130,7 @@ 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:itemprices`, `§13b`).
|
||||
e.g. `setmode:setonly`, `§13b`).
|
||||
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||
→ `fds__createInvoice_Details` again.
|
||||
|
||||
@@ -337,9 +345,18 @@ flowchart TD
|
||||
|
||||
## 10. Key invariants worth remembering
|
||||
|
||||
- **Stateless editor**: 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`).
|
||||
> **⚠️ 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.
|
||||
@@ -349,6 +366,10 @@ flowchart TD
|
||||
- **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
|
||||
|
||||
@@ -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]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||
> (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 |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
| # | Operation | Trigger | Grouped by | Effect |
|
||||
|---|---|---|---|---|
|
||||
| **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** |
|
||||
| **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** |
|
||||
| **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
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
||||
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
||||
for the full breakdown.
|
||||
|
||||
## 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:
|
||||
`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
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
||||
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
||||
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
||||
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
||||
session (inserts the set row, blanks or removes members) and pushes the new
|
||||
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
||||
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
||||
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
||||
these two modes.
|
||||
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`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
|
||||
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
|
||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
||||
an item with `type === 'set'` is a header (`id` = its set id); a member item's
|
||||
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
||||
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
||||
**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`)
|
||||
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
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
The editor's running **total is unaffected by any set-price operation**, matching
|
||||
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
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
### Why the trigger lives in the editor
|
||||
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||
is being composed (front-end), but the operation itself is executed **server-side**
|
||||
against the cached draft session — the editor merely names the target (a set-item
|
||||
`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
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. Persisting the
|
||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
||||
only required if a finalised invoice must be **re-generated** from stored items in
|
||||
a different mode later — not done here.
|
||||
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||
works end-to-end for previews and creation. The two menu modes bake their result
|
||||
directly into the session's items (a set row plus blanked/removed members), so no
|
||||
`setmode:` display token is needed or persisted; the finalised document is
|
||||
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
||||
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
||||
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
|
||||
|
||||
+7
-10
@@ -22,9 +22,9 @@
|
||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="favicon.ico" />
|
||||
</ItemGroup>
|
||||
@@ -34,18 +34,17 @@
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||
<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.16.0" />
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" 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="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
@@ -53,8 +52,8 @@
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -66,14 +65,12 @@
|
||||
|
||||
|
||||
<!-- 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>
|
||||
<Source ParameterType="System.String" Required="true" />
|
||||
<Destination ParameterType="System.String" Required="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
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
|
||||
{
|
||||
}
|
||||
@@ -39,12 +39,21 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
||||
public static readonly Counter<long> Mt940RowsParsed =
|
||||
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 =
|
||||
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) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
@@ -36,6 +37,14 @@ public class Program
|
||||
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
||||
builder.AddSecretManagement();
|
||||
|
||||
// Apply the Spire.PDF license as early as possible — Spire evaluates its license
|
||||
// lazily on the first PDF operation per process and caches the result, so it must be
|
||||
// set before any Spire use (self-test, first render) or the evaluation watermark sticks
|
||||
// for the whole process. Sourced from the SpirePdf-License managed secret (config key
|
||||
// SpirePdf_License, plus tolerated spelling variants); falls back to the embedded key.
|
||||
FuchsPdf.SetLicense(
|
||||
FuchsPdfService.ResolveLicenseFromConfiguration(builder.Configuration, out _));
|
||||
|
||||
// Assemble connection strings from templates + resolved credentials.
|
||||
// In Development, "_Dev"-suffixed credential keys are preferred so a reachable
|
||||
// Key Vault can never override them with production DB credentials.
|
||||
@@ -50,8 +59,34 @@ public class Program
|
||||
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
||||
|
||||
// In-process MFR ERP sync — formerly the standalone Fuchs_DataService Windows Service
|
||||
// (Topshelf), now hosted here as a BackgroundService. Gated by Fds:SyncEnabled so
|
||||
// developer machines (appsettings.Development.json sets it false) never poll the ERP;
|
||||
// interval and debug verbosity come from the Fds config section.
|
||||
if (builder.Configuration.GetValue("Fds:SyncEnabled", false))
|
||||
{
|
||||
builder.Services.AddHostedService(sp =>
|
||||
{
|
||||
var mfr = sp.GetRequiredService<fds.IFdsMfr>();
|
||||
var interval = TimeSpan.FromMinutes(
|
||||
builder.Configuration.GetValue("Fds:ExecutionFrequency_Minutes", 15d));
|
||||
bool debug = builder.Configuration.GetValue("Fds:DebugDetails", false);
|
||||
var jobs = new[]
|
||||
{
|
||||
new PeriodicJobDefinition("MfrSync", interval, async ct =>
|
||||
{
|
||||
await mfr.UpdateIfNecessary_async(debug, ct);
|
||||
await mfr.UpdateRequested_async(debug, ct);
|
||||
await mfr.GetInvoiceFiles_async(debug, ct);
|
||||
})
|
||||
};
|
||||
return new PeriodicHostedService(jobs, sp.GetRequiredService<ILogger<PeriodicHostedService>>());
|
||||
});
|
||||
}
|
||||
|
||||
// MVC with Razor view support
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Fuchs intranet singleton
|
||||
builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance);
|
||||
@@ -85,8 +120,19 @@ public class Program
|
||||
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
|
||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
||||
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
|
||||
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
|
||||
builder.Services.AddSingleton<IERechnungService, ERechnungService>(); // stateless: maps + embeds ZUGFeRD
|
||||
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
|
||||
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||
// module can display it as a non-refreshable widget (must be registered before the service).
|
||||
builder.Services.AddSingleton<StartupCheckReporter>();
|
||||
builder.Services.AddHostedService<StartupSelfTestService>();
|
||||
|
||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
|
||||
@@ -96,6 +142,25 @@ public class Program
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
// Read-only system diagnostics for the Admin module (config snapshot + connectivity probes
|
||||
// + test-email). Restricted to fds_sys > 4 in IntranetController.Admin; see the concept doc.
|
||||
builder.Services.AddScoped<ISystemStatusService, SystemStatusService>();
|
||||
|
||||
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
|
||||
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
|
||||
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
|
||||
builder.Services.AddSingleton<IInvoiceDraftCache, InvoiceDraftCache>();
|
||||
builder.Services.AddSingleton<IDraftNotifier, DraftNotifier>();
|
||||
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
|
||||
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
|
||||
|
||||
// Live, backend-authoritative reminder draft editing (ADR 0006) — the reminder
|
||||
// mirror of the invoice draft services above, sharing the DraftPreviewHub/notifier.
|
||||
builder.Services.AddSingleton<IReminderDraftCache, ReminderDraftCache>();
|
||||
builder.Services.AddScoped<IReminderDraftService, ReminderDraftEditService>();
|
||||
builder.Services.AddHostedService<ReminderDraftExpiryService>();
|
||||
|
||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||
@@ -141,6 +206,11 @@ public class Program
|
||||
|
||||
private static void ConfigureApp(WebApplication app)
|
||||
{
|
||||
// OCORE's internal logging helpers (e.g. DatatableWriterAsync's exception logging)
|
||||
// call the static OCORE.Logging.Logger via a null-conditional — without this, those
|
||||
// calls silently no-op instead of reaching Debug output / ErrorLog.txt.
|
||||
OCORE.Logging.Logger = app.Logger;
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/error");
|
||||
@@ -165,6 +235,8 @@ public class Program
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
app.MapHub<DraftPreviewHub>("/draftpreview");
|
||||
|
||||
// Intranet routes (root-level — this IS the website)
|
||||
app.MapControllerRoute(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>x64</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>Q:\PWProjects\Fuchs_Intranet</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user