Enhance logging in FdsSqlOptions and related classes

- Updated FdsSqlOptions to accept an optional ILogger parameter for improved error logging.
- Modified FdsMfr and FdsMfrClient classes to pass the logger instance to FdsSqlOptions.
- Added detailed error logging in various methods to capture SQL execution issues and file handling errors.
- Improved documentation for FdsSqlOptions to clarify logging behavior.
- Updated Archive class to log compression errors, enhancing traceability of failures.
- Adjusted project configuration to suppress specific warnings related to transitive dependencies.
- Added NuGet.config to define package sources for dependency management.
- Updated submodule references for OCORE and related projects.
This commit is contained in:
Stefan
2026-07-03 20:22:05 +02:00
parent 1a3bf30442
commit 882e97509a
57 changed files with 2121 additions and 106 deletions
+13 -4
View File
@@ -1,16 +1,20 @@
# Copilot Instructions # Copilot Instructions
> ## ⚠️ Instruction Sync > ## ⚠️ Instruction Sync
> This file (`.github/copilot-instructions.md`) and the Claude Code instructions > This file (`.github/copilot-instructions.md`), the Claude Code instructions
> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**. > (`/CLAUDE.md`), and the Codex instructions (`/CODEX.md`) are **three views of
> Whenever you change one, make the equivalent change in the other in the same > the same project rules and must stay in sync**.
> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared > Whenever you change one, make the equivalent change in the other two in the same
> commit. `CLAUDE.md` and `CODEX.md` may add tool-specific workflow notes, but the shared
> project facts (architecture, coding standards, configuration, libraries, > project facts (architecture, coding standards, configuration, libraries,
> secrets, observability) must match. > secrets, observability) must match.
## Project Overview ## Project Overview
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`. - **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}``IntranetController.Index`; `/do/{fn?}/{id?}/{code?}``IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}``IntranetController.Index`; `/do/{fn?}/{id?}/{code?}``IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
- Project structure (relative to `Fuchs/`): - Project structure (relative to `Fuchs/`):
- `Controllers/``IntranetController` partials (no area) - `Controllers/``IntranetController` partials (no area)
- `code/` — business logic, PDF, email, widgets, data models - `code/` — business logic, PDF, email, widgets, data models
@@ -74,6 +78,11 @@
- Name tests `MethodName_Scenario_ExpectedResult`. - Name tests `MethodName_Scenario_ExpectedResult`.
- DB-bound paths that can't be unit-tested should at least have their pure logic covered. - DB-bound paths that can't be unit-tested should at least have their pure logic covered.
## Decisions & Concepts
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
## Azure Key Vault — Secret Naming ## Azure Key Vault — Secret Naming
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces). - Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`. - Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
+3
View File
@@ -7,6 +7,9 @@
bin/ bin/
obj/ obj/
# Scratch / build-verification output
/tmp/
# SSDT / SQL database project caches (regenerated) # SSDT / SQL database project caches (regenerated)
*.dbmdl *.dbmdl
*.jfm *.jfm
+28
View File
@@ -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"
}
}
]
}
+129
View File
@@ -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
View File
@@ -51,34 +51,49 @@ public sealed class CamtParser
/// <summary> /// <summary>
/// Parses all CAMT XML files found inside a ZIP archive and returns /// Parses all CAMT XML files found inside a ZIP archive and returns
/// the combined list of statements. Non-XML entries and malformed XML /// the combined list of statements. Non-XML entries and malformed XML
/// entries are silently skipped. Used for camt.052 deliveries where the /// entries are skipped. Used for camt.052 deliveries where the
/// bank wraps one or more intraday reports in a single ZIP file. /// bank wraps one or more intraday reports in a single ZIP file.
/// </summary> /// </summary>
public List<CamtStatement> ParseZip(byte[] bytes) public List<CamtStatement> ParseZip(byte[] bytes) => ParseZip(bytes, out _);
{
using var ms = new MemoryStream(bytes);
return ParseZip(ms);
}
/// <inheritdoc cref="ParseZip(byte[])"/> /// <inheritdoc cref="ParseZip(byte[])"/>
public List<CamtStatement> ParseZip(Stream stream) public List<CamtStatement> ParseZip(Stream stream) => ParseZip(stream, out _);
/// <summary>
/// Same as <see cref="ParseZip(byte[])"/>, but also reports which entries were
/// skipped and why (non-.xml name, non-XML content, malformed XML), so callers
/// can log a reason instead of silently losing part of a delivery.
/// </summary>
public List<CamtStatement> ParseZip(byte[] bytes, out List<string> skippedEntries)
{
using var ms = new MemoryStream(bytes);
return ParseZip(ms, out skippedEntries);
}
/// <inheritdoc cref="ParseZip(byte[], out List{string})"/>
public List<CamtStatement> ParseZip(Stream stream, out List<string> skippedEntries)
{ {
var result = new List<CamtStatement>(); var result = new List<CamtStatement>();
var skipped = new List<string>();
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
{ {
if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
continue; continue; // non-XML entries (e.g. checksums, manifests) are expected, not worth reporting
using var entryStream = entry.Open(); using var entryStream = entry.Open();
using var buffer = new MemoryStream(); using var buffer = new MemoryStream();
entryStream.CopyTo(buffer); entryStream.CopyTo(buffer);
var entryBytes = buffer.ToArray(); var entryBytes = buffer.ToArray();
if (!LooksLikeXml(entryBytes)) if (!LooksLikeXml(entryBytes))
{
skipped.Add($"{entry.Name}: not XML content");
continue; continue;
}
try { result.AddRange(Parse(entryBytes)); } try { result.AddRange(Parse(entryBytes)); }
catch (FormatException) { /* skip malformed XML entries */ } catch (FormatException ex) { skipped.Add($"{entry.Name}: {ex.Message}"); }
} }
skippedEntries = skipped;
return result; return result;
} }
+12 -4
View File
@@ -1,12 +1,12 @@
# CLAUDE.md — Project instructions for Claude Code # CLAUDE.md — Project instructions for Claude Code
> ## ⚠️ Instruction Sync > ## ⚠️ Instruction Sync
> This file and **`.github/copilot-instructions.md`** are two views of the same > This file, **`CODEX.md`**, and **`.github/copilot-instructions.md`** are three
> project rules and **must stay in sync**. When you change a shared rule > views of the same project rules and **must stay in sync**. When you change a shared rule
> (architecture, coding standards, configuration, libraries, secrets, > (architecture, coding standards, configuration, libraries, secrets,
> observability, testing), make the equivalent change in **both files in the > observability, testing), make the equivalent change in **all three files in the
> same commit**. This file may add Claude Code / workflow specifics; the shared > same commit**. This file may add Claude Code / workflow specifics; the shared
> project facts must match `copilot-instructions.md`. > project facts must match `CODEX.md` and `copilot-instructions.md`.
## Project Overview ## Project Overview
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`. - **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
@@ -15,6 +15,7 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
@@ -69,8 +70,15 @@
## Secrets (Azure Key Vault) ## Secrets (Azure Key Vault)
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`. - Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
## Decisions & Concepts
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
## Documentation map ## Documentation map
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes). - `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide. - `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above.
- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above.
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract. - `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance. - `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
+84
View File
@@ -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`). `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`. 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.
+66
View File
@@ -377,4 +377,70 @@ public class BankingDualFormatTests
Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]); Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]);
Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]); Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]);
} }
/// <summary>
/// Schema whose string columns carry the same MaxLength as the real
/// <c>fds__tt__bankingtransactions</c> table type. Building it here lets the tests
/// catch width-overflow bugs that <see cref="BankingService.BuildDefaultSchema"/> (which
/// uses unconstrained strings) cannot. Only the columns exercised below are constrained.
/// </summary>
private static DataTable DbLikeSchema()
{
var t = new DataTable();
t.Columns.Add("AccountIdentification", typeof(string)).MaxLength = 50;
t.Columns.Add("Amount", typeof(decimal));
t.Columns.Add("ValueDate", typeof(DateTime));
t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here
t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 60;
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()
{
const string longName = "Ein sehr langer Zahlungspflichtiger Name der weit ueber sechzig Zeichen hinausgeht GmbH Co KG";
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(60, ((string)t.Rows[0]["NameOfPayer"]).Length); // truncated to column width, row kept
Assert.Equal(longName[..60], t.Rows[0]["NameOfPayer"]);
}
} }
@@ -22,8 +22,9 @@ public partial class IntranetController
return await JSONAsync(new { manage = 1 }); return await JSONAsync(new { manage = 1 });
case "up": case "up":
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}", _logger.LogInformation("Banking statement upload: {FileCount} file(s) user={User}",
Request.Form.Files.Count, UserAccountID); Request.Form.Files.Count, UserAccountID);
var uploadResults = new List<object>();
foreach (var fle in Request.Form.Files) foreach (var fle in Request.Form.Files)
{ {
using var stream = fle.OpenReadStream(); using var stream = fle.OpenReadStream();
@@ -34,6 +35,9 @@ public partial class IntranetController
var tbl = _banking.ParseToDatatable(stream, schemaDt); var tbl = _banking.ParseToDatatable(stream, schemaDt);
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", ""); var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
var (importFrom, importTo) = BankingDateRange(tbl);
bool importFailed = false;
string importFailure = "";
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString) var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
{ {
@@ -48,16 +52,80 @@ public partial class IntranetController
dtwa.CommandAfterError = new SqlCommand( dtwa.CommandAfterError = new SqlCommand(
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};"); $"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
dtwa.OnError += (_, exc, _) => dtwa.OnError += (_, exc, _) =>
{
importFailed = true;
importFailure = exc.Message;
_logger.LogError(exc,
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
fle.FileName, dtwa.DestinationTableName, UserAccountID);
_intranet.debug_log("IntranetController.bam.up - sql exception", _intranet.debug_log("IntranetController.bam.up - sql exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
};
dtwa.OnCommandAfterError += (_, exc) => dtwa.OnCommandAfterError += (_, exc) =>
{
importFailed = true;
importFailure = exc.Message;
_logger.LogError(exc,
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
"rescueTable={RescueTable} user={User}",
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
_intranet.debug_log("IntranetController.bam.up - command-after exception", _intranet.debug_log("IntranetController.bam.up - command-after exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
};
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})", _logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
tbl.Rows.Count, UserAccountID); tbl.Rows.Count, UserAccountID);
dtwa.DoSubmit(); dtwa.DoSubmit();
if (dtwa.SubmitException != null)
{
importFailed = true;
importFailure = dtwa.SubmitException.Message;
_logger.LogError(dtwa.SubmitException,
"Banking upload submit exception — file={File} destTable={DestTable} user={User}",
fle.FileName, dtwa.DestinationTableName, UserAccountID);
}
if (importFailed)
{
_logger.LogError(
"Banking import failed — file={File} rows={Rows} reason={Reason} user={User}",
fle.FileName, tbl.Rows.Count, importFailure, UserAccountID);
await _events.BankingImportIssueAsync(
$"Kontobewegungen aus {fle.FileName} konnten nicht importiert werden: {importFailure}",
fle.FileName, UserAccountID);
}
else if (tbl.Rows.Count == 0)
{
// Parsing produced zero rows — check the preceding "Bank statement parsed"
// warning from BankingService for the reason (missing account element,
// unsupported schema variant, empty file, ...).
_logger.LogWarning(
"Banking import: 0 rows parsed from {File} — nothing to import. user={User}",
fle.FileName, UserAccountID);
await _events.BankingImportIssueAsync(
$"Aus {fle.FileName} konnten keine Kontobewegungen importiert werden.",
fle.FileName, UserAccountID);
}
else
{
_logger.LogInformation(
"Banking import succeeded — file={File} rows={Rows} from={From} to={To} user={User}",
fle.FileName, tbl.Rows.Count, importFrom, importTo, UserAccountID);
await _events.BankingTransactionsImportedAsync(
importFrom, importTo, tbl.Rows.Count, fle.FileName, UserAccountID);
}
uploadResults.Add(new
{
fileName = fle.FileName,
rows = tbl.Rows.Count,
success = !importFailed && tbl.Rows.Count > 0,
error = importFailed ? importFailure : ""
});
} }
return Ok(); // Return a JSON body: the frontend posts with dataType 'json', so an
// empty 200 would be reported as a parse error and surface the generic
// "auth failed" alert even though the import actually succeeded.
return await JSONAsync(new { ok = true, files = uploadResults });
case "qtl": case "qtl":
{ {
@@ -126,7 +194,9 @@ public partial class IntranetController
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;", "EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" }); return res.Result is true
? await JSONAsync(new { ok = true })
: StatusCode(500, new { error = "not successful" });
} }
case "ati": case "ati":
@@ -139,7 +209,9 @@ public partial class IntranetController
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;", "EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" }); return res.Result is true
? await JSONAsync(new { ok = true })
: StatusCode(500, new { error = "not successful" });
} }
case "vfi": case "vfi":
@@ -165,4 +237,26 @@ public partial class IntranetController
protected string Form(string key, string fallback = "") => protected string Form(string key, string fallback = "") =>
Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback; Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback;
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;
}
} }
@@ -86,7 +86,14 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt2.Exception)) if (!string.IsNullOrEmpty(dt2.Exception))
{
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID); _logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
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) ? Ok() : StatusCode(500); return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
} }
@@ -43,10 +43,11 @@ public partial class IntranetController
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec); new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdRem.Id)) if (!string.IsNullOrEmpty(fdRem.Id))
{ {
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" }); return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
case "conf": return await HandleReminderConf(fn, id, code); case "conf": return await HandleReminderConf(fn, id, code);
@@ -59,6 +60,12 @@ public partial class IntranetController
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;", "EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
if (string.IsNullOrEmpty(dt2.Exception))
await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID);
else
await _events.ReminderIssueAsync(
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
UserAccountID, Form("id"));
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
} }
@@ -127,16 +134,35 @@ public partial class IntranetController
email.Trim(), "", remdoc); email.Trim(), "", remdoc);
if (sent) if (sent)
{ {
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID);
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true)); var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
await getSQLDatatable_async( await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;", "EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
_intranet.Intranet__SQLConnectionString, pls, _intranet.Intranet__SQLConnectionString, pls,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
} }
else
{
_logger.LogError(
"Reminder email send failed — reminderId={ReminderId} email={Email} user={User}",
remId, email.Trim(), UserAccountID);
await _events.ReminderIssueAsync(
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht an {email.Trim()} versandt werden.",
UserAccountID, remId);
}
}
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 Ok(); return Ok();
} }
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code) private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
@@ -178,14 +204,39 @@ public partial class IntranetController
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
await _comService.SendEmailAsync($"rem_{remId}", bool sent = await _comService.SendEmailAsync($"rem_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))), BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
email.Trim(), "", remdoc); email.Trim(), "", remdoc);
if (sent)
{
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true);
}
else
{
_logger.LogError(
"Reminder resend email send failed — reminderId={ReminderId} email={Email} user={User}",
remId, email.Trim(), UserAccountID);
await _events.ReminderIssueAsync(
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht erneut an {email.Trim()} versandt werden.",
UserAccountID, remId);
}
} }
return Ok(); return Ok();
} }
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
}
private async Task<IActionResult> ReminderIssueResult(string message, string reminderId = "")
{
// Mirrors the SignalR toast in a durable app log: without this, a reminder
// save/create/send failure was only visible as a GUI notification nobody was
// necessarily watching at the time.
_logger.LogError("Reminder issue — reminderId={ReminderId} user={User} message={Message}",
reminderId, UserAccountID, message);
await _events.ReminderIssueAsync(message, UserAccountID, reminderId);
return StatusCode(500, new { error = message });
} }
private static string BuildReminderBody(double amountOpen) => private static string BuildReminderBody(double amountOpen) =>
@@ -36,7 +36,15 @@ public partial class IntranetController
ri["params"] = dset.Tables("params") ri["params"] = dset.Tables("params")
.toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'"); .toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'");
} }
catch { ri["params"] = Array.Empty<Dictionary<string, object>>(); } catch (Exception ex)
{
// Without this, a genuinely broken params filter/query is indistinguishable
// from the expected "this report has no params" case in the response.
_logger.LogWarning(ex,
"Report catalog: failed to load params for object_id={ObjectId} user={User}",
ri["object_id"], UserAccountID);
ri["params"] = Array.Empty<Dictionary<string, object>>();
}
} }
return await JSONAsync(new return await JSONAsync(new
{ {
@@ -49,9 +49,11 @@ public partial class IntranetController
var fdInv = await _invoices.RegisterInvoiceAsync( var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!), new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec); change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
await _events.InvoiceDraftRegisteredAsync(fdInv, !string.IsNullOrEmpty(Form("id")), UserAccountID);
return !string.IsNullOrEmpty(fdInv.Id) return !string.IsNullOrEmpty(fdInv.Id)
? await JSONAsync(new { id = fdInv.Id }) ? await JSONAsync(new { id = fdInv.Id })
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" }); : await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden.");
} }
case "sprep": case "sprep":
@@ -62,10 +64,11 @@ public partial class IntranetController
change: false, invId: "", UserAccountID, DbSec); change: false, invId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id)) if (!string.IsNullOrEmpty(fdInv.Id))
{ {
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
case "sedit": case "sedit":
@@ -76,10 +79,11 @@ public partial class IntranetController
change: true, invId: Form("id"), UserAccountID, DbSec); change: true, invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id)) if (!string.IsNullOrEmpty(fdInv.Id))
{ {
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
} }
case "sdel": case "sdel":
@@ -141,13 +145,20 @@ public partial class IntranetController
} }
} }
private static List<Dictionary<string, object?>> AttachReports(SQLDataSet dset) private List<Dictionary<string, object?>> AttachReports(SQLDataSet dset)
{ {
var req = new List<Dictionary<string, object?>>(dset.Tables("requests").toArrayofObjectDictionaries()!); var req = new List<Dictionary<string, object?>>(dset.Tables("requests").toArrayofObjectDictionaries()!);
foreach (var r in req) foreach (var r in req)
{ {
try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); } try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); }
catch { /* no reports table */ } catch (Exception ex)
{
// "reports" table absent is expected for some queries; but a real failure while
// joining (e.g. malformed filter) looked identical to that with no way to tell them apart.
_logger.LogWarning(ex,
"AttachReports: failed to join reports for requestId={RequestId} user={User}",
r["Id"], UserAccountID);
}
} }
return req; return req;
} }
@@ -285,15 +296,34 @@ public partial class IntranetController
body, email.Trim(), "", inv); body, email.Trim(), "", inv);
if (sent) if (sent)
{ {
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true)); var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;", await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
_intranet.Intranet__SQLConnectionString, pls, _intranet.Intranet__SQLConnectionString, pls,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
} }
else
{
_logger.LogError(
"Invoice email send failed — invoiceId={InvoiceId} email={Email} user={User}",
invId, email.Trim(), UserAccountID);
await _events.InvoiceIssueAsync(
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht an {email.Trim()} versandt werden.",
UserAccountID, invId);
}
}
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);
} }
return Ok(); return Ok();
} }
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
@@ -309,7 +339,7 @@ public partial class IntranetController
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return ct != null return ct != null
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true) ? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" }); : await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
} }
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
@@ -335,14 +365,35 @@ public partial class IntranetController
{ {
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0)); double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen"); string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
await _comService.SendEmailAsync( bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}", $"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
BuildInvoiceBody(bal, terms), email.Trim(), "", BuildInvoiceBody(bal, terms), email.Trim(), "",
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte }); new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
if (sent)
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
else
{
_logger.LogError(
"Invoice resend email send failed — invoiceId={InvoiceId} email={Email} user={User}",
invId, email.Trim(), UserAccountID);
await _events.InvoiceIssueAsync(
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht erneut an {email.Trim()} versandt werden.",
UserAccountID, invId);
}
} }
return Ok(); return Ok();
} }
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
}
private async Task<IActionResult> InvoiceIssueResult(string message, string invoiceId = "")
{
// Mirrors the SignalR toast in a durable app log: without this, an invoice save/create/send
// failure was only visible as a GUI notification nobody was necessarily watching at the time.
_logger.LogError("Invoice issue — invoiceId={InvoiceId} user={User} message={Message}",
invoiceId, UserAccountID, message);
await _events.InvoiceIssueAsync(message, UserAccountID, invoiceId);
return StatusCode(500, new { error = message });
} }
private static string BuildInvoiceBody(double balance, string paymentTerms) => private static string BuildInvoiceBody(double balance, string paymentTerms) =>
+6 -2
View File
@@ -1,5 +1,6 @@
using System.Web; using System.Web;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -33,6 +34,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IReportService _reports; private readonly IReportService _reports;
private readonly IInvoiceService _invoices; private readonly IInvoiceService _invoices;
private readonly IReminderService _reminders; private readonly IReminderService _reminders;
private readonly IEventService _events;
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" }; private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new() private readonly List<string> _allowedGet = new()
{ {
@@ -59,7 +61,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IWidgetService widgets, IWidgetService widgets,
IReportService reports, IReportService reports,
IInvoiceService invoices, IInvoiceService invoices,
IReminderService reminders) IReminderService reminders,
IEventService events)
{ {
_intranet = intranet; _intranet = intranet;
_mfr = mfr; _mfr = mfr;
@@ -72,6 +75,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_reports = reports; _reports = reports;
_invoices = invoices; _invoices = invoices;
_reminders = reminders; _reminders = reminders;
_events = events;
} }
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary> /// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
@@ -102,7 +106,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID); public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID);
public FIS_SQLOptions SqlOpt(string fn, string id, string code) => public FIS_SQLOptions SqlOpt(string fn, string id, string code) =>
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code }); new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code }, _logger);
// ── Action helpers ──────────────────────────────────────────────────────── // ── Action helpers ────────────────────────────────────────────────────────
protected IActionResult Unauthorized401() => StatusCode(401); protected IActionResult Unauthorized401() => StatusCode(401);
+66
View File
@@ -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,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,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`.
+71
View File
@@ -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`.
+11
View File
@@ -0,0 +1,11 @@
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.
+34
View File
@@ -0,0 +1,34 @@
namespace Fuchs.Notifications;
public enum DomainEventType
{
InvoiceDraftCreated,
InvoiceDraftUpdated,
InvoiceFileCreated,
InvoiceSentToCustomer,
InvoiceResentToCustomer,
InvoiceMarkedSent,
InvoiceCreationFailed,
InvoiceFileCreationFailed,
InvoiceSendFailed,
ReminderDraftCreated,
ReminderFileCreated,
ReminderSentToCustomer,
ReminderResentToCustomer,
ReminderMarkedSent,
ReminderCreationFailed,
ReminderFileCreationFailed,
ReminderSendFailed,
BankingTransactionsImported,
BankingImportFailed,
UserIssue
}
public sealed record DomainEvent(
DomainEventType Type,
string UserAccountId,
string Title,
IReadOnlyDictionary<string, object?> Context)
{
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
+275
View File
@@ -0,0 +1,275 @@
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 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 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.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.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 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() ?? "";
}
}
+10
View File
@@ -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);
+25
View File
@@ -0,0 +1,25 @@
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 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 UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null);
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Fuchs.Notifications;
[Authorize]
public sealed class NotificationHub : Hub
{
}
+6
View File
@@ -39,6 +39,12 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent."); Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
public static readonly Counter<long> Mt940RowsParsed = public static readonly Counter<long> Mt940RowsParsed =
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed."); Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
public static readonly Counter<long> BankingEntriesSkipped =
Meter.CreateCounter<long>("fuchs.banking.entries.skipped", "{entry}",
"Number of bank statement entries/statements dropped during parsing, tagged by reason.");
public static readonly Counter<long> BankingFieldsTruncated =
Meter.CreateCounter<long>("fuchs.banking.fields.truncated", "{field}",
"Number of parsed fields truncated to fit the destination column width.");
public static readonly Counter<long> MfrCalls = public static readonly Counter<long> MfrCalls =
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated."); Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
public static readonly Counter<long> BlobUploadsSucceeded = public static readonly Counter<long> BlobUploadsSucceeded =
+4
View File
@@ -1,5 +1,6 @@
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Logging; using Fuchs.Logging;
using Fuchs.Notifications;
using Fuchs.Observability; using Fuchs.Observability;
using OCORE_web.Secrets; using OCORE_web.Secrets;
using Fuchs.Services; using Fuchs.Services;
@@ -52,6 +53,7 @@ public class Program
// MVC with Razor view support // MVC with Razor view support
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddSignalR();
// Fuchs intranet singleton // Fuchs intranet singleton
builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance); builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance);
@@ -96,6 +98,7 @@ public class Program
builder.Services.AddScoped<IReportService, FuchsReportService>(); builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>(); builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IReminderService, ReminderService>(); builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>();
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage. // Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService. // Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
@@ -165,6 +168,7 @@ public class Program
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapHub<NotificationHub>("/notifications");
// Intranet routes (root-level — this IS the website) // Intranet routes (root-level — this IS the website)
app.MapControllerRoute( app.MapControllerRoute(
+119 -19
View File
@@ -1,5 +1,6 @@
using System.Data; using System.Data;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using CAMTParser; using CAMTParser;
using Fuchs.Observability; using Fuchs.Observability;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -35,6 +36,7 @@ public class BankingService : IBankingService
using var act = FuchsTelemetry.StartActivity("banking.parse"); using var act = FuchsTelemetry.StartActivity("banking.parse");
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema(); var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
var diag = new ParseDiagnostics();
// Buffer once so we can sniff the format and (re)parse from the bytes. // Buffer once so we can sniff the format and (re)parse from the bytes.
byte[] bytes; byte[] bytes;
@@ -48,46 +50,125 @@ public class BankingService : IBankingService
if (CamtParser.LooksLikeZip(bytes)) if (CamtParser.LooksLikeZip(bytes))
{ {
format = "camt.zip"; format = "camt.zip";
try { MapCamtEntries(tbl, new CamtParser().ParseZip(bytes)); } try
{
var statements = new CamtParser().ParseZip(bytes, out var skippedEntries);
if (skippedEntries.Count > 0)
{
diag.ZipEntriesSkipped = skippedEntries.Count;
_logger.LogWarning(
"CAMT ZIP: {Count} entry(ies) skipped: {Entries}",
skippedEntries.Count, string.Join("; ", skippedEntries));
}
MapCamtEntries(tbl, statements, diag);
}
catch (Exception ex) { _logger.LogError(ex, "CAMT ZIP statement parse failed."); } catch (Exception ex) { _logger.LogError(ex, "CAMT ZIP statement parse failed."); }
} }
else if (CamtParser.LooksLikeXml(bytes)) else if (CamtParser.LooksLikeXml(bytes))
{ {
format = "camt"; format = "camt";
try { MapCamtEntries(tbl, new CamtParser().Parse(bytes)); } try { MapCamtEntries(tbl, new CamtParser().Parse(bytes), diag); }
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); } catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
} }
else else
{ {
format = "mt940"; format = "mt940";
using var msMt = new MemoryStream(bytes); using var msMt = new MemoryStream(bytes);
FillFromMt940(tbl, msMt); FillFromMt940(tbl, msMt, diag);
} }
tbl.AcceptChanges(); tbl.AcceptChanges();
sw.Stop(); sw.Stop();
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format)); FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format));
if (diag.StatementsSkippedNoAccount > 0)
FuchsTelemetry.BankingEntriesSkipped.Add(diag.StatementsSkippedNoAccount,
new KeyValuePair<string, object?>("reason", "noAccount"));
if (diag.EntriesSkippedError > 0)
FuchsTelemetry.BankingEntriesSkipped.Add(diag.EntriesSkippedError,
new KeyValuePair<string, object?>("reason", "error"));
if (diag.ZipEntriesSkipped > 0)
FuchsTelemetry.BankingEntriesSkipped.Add(diag.ZipEntriesSkipped,
new KeyValuePair<string, object?>("reason", "zipEntry"));
if (diag.FieldsTruncated > 0)
FuchsTelemetry.BankingFieldsTruncated.Add(diag.FieldsTruncated);
act?.SetTag("fuchs.banking.format", format); act?.SetTag("fuchs.banking.format", format);
act?.SetTag("fuchs.banking.rows", tbl.Rows.Count); act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
_logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms", act?.SetTag("fuchs.banking.statements_skipped_no_account", diag.StatementsSkippedNoAccount);
format, tbl.Rows.Count, sw.ElapsedMilliseconds); act?.SetTag("fuchs.banking.entries_skipped_error", diag.EntriesSkippedError);
act?.SetTag("fuchs.banking.fields_truncated", diag.FieldsTruncated);
// A statement/upload that yields zero rows almost always means the import silently
// failed upstream (wrong account element for this bank's schema variant, empty file,
// unsupported CAMT flavor) rather than that the statement legitimately had no bookings.
// Surface that as a warning so it doesn't require deliberately grepping info-level logs.
var logLevel = tbl.Rows.Count == 0 ? LogLevel.Warning : LogLevel.Information;
_logger.Log(logLevel,
"Bank statement parsed: format={Format} rows={Rows} statementsSkippedNoAccount={StatementsSkipped} " +
"entriesSkippedError={EntriesSkipped} zipEntriesSkipped={ZipSkipped} fieldsTruncated={Truncated} in {Ms} ms",
format, tbl.Rows.Count, diag.StatementsSkippedNoAccount, diag.EntriesSkippedError,
diag.ZipEntriesSkipped, diag.FieldsTruncated, sw.ElapsedMilliseconds);
if (diag.TruncatedByColumn.Count > 0)
_logger.LogWarning("Bank statement fields truncated to column width: {Columns}",
string.Join(", ", diag.TruncatedByColumn.Select(kv => $"{kv.Key}×{kv.Value}")));
return tbl; return tbl;
} }
// ── MT940 ───────────────────────────────────────────────────────────────── /// <summary>Per-parse counters used to summarize what got dropped or altered, so a single log line can explain a zero- or low-row result.</summary>
private void FillFromMt940(DataTable tbl, Stream stream) private sealed class ParseDiagnostics
{ {
void SetNfo(DataRow nr, string key, object? value) public int StatementsSkippedNoAccount;
public int EntriesSkippedError;
public int ZipEntriesSkipped;
public int FieldsTruncated;
public readonly Dictionary<string, int> TruncatedByColumn = new();
}
/// <summary>
/// Assigns a value to a row cell, but only if the column exists and the value
/// is non-null. String values are truncated to the column's <see cref="DataColumn.MaxLength"/>
/// so that an over-long field (e.g. a remittance line, a long counterparty name, or a
/// foreign IBAN) can never overflow the destination column. Without this guard, a single
/// over-long value throws on assignment and — because the per-entry mapping is wrapped in a
/// catch — silently drops the whole transaction, which can empty an entire import.
/// Truncations are counted in <paramref name="diag"/> rather than logged per-cell, to avoid
/// flooding the log on a file with many long fields.
/// </summary>
private static void SetCell(DataTable tbl, DataRow nr, string key, object? value, ParseDiagnostics diag)
{
if (value == null || !tbl.Columns.Contains(key)) return;
var col = tbl.Columns[key]!;
if (col.DataType == typeof(string) && col.MaxLength > 0 &&
value is string s && s.Length > col.MaxLength)
{ {
if (tbl.Columns.Contains(key) && value != null) nr[key] = value; value = s[..col.MaxLength];
diag.FieldsTruncated++;
diag.TruncatedByColumn[key] = diag.TruncatedByColumn.GetValueOrDefault(key) + 1;
} }
nr[key] = value;
}
// ── MT940 ─────────────────────────────────────────────────────────────────
private void FillFromMt940(DataTable tbl, Stream stream, ParseDiagnostics diag)
{
void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag);
using var ps = new Parser(stream: stream); using var ps = new Parser(stream: stream);
try try
{ {
foreach (var statement in ps.Parse()) foreach (var statement in ps.Parse())
{ {
if (string.IsNullOrEmpty(statement.AccountIdentification)) continue; if (string.IsNullOrEmpty(statement.AccountIdentification))
{
diag.StatementsSkippedNoAccount++;
_logger.LogWarning(
"MT940 statement skipped: no AccountIdentification ({LineCount} line(s) dropped).",
statement.Lines.Count);
continue;
}
foreach (var line in statement.Lines) foreach (var line in statement.Lines)
{ {
try try
@@ -128,7 +209,13 @@ public class BankingService : IBankingService
tbl.Rows.Add(nr); tbl.Rows.Add(nr);
} }
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); } catch (Exception ex)
{
diag.EntriesSkippedError++;
_logger.LogWarning(ex,
"MT940 line parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.",
statement.AccountIdentification, line.EntryDate, line.Amount);
}
} }
} }
} }
@@ -136,16 +223,20 @@ public class BankingService : IBankingService
} }
// ── CAMT (ISO 20022) ─────────────────────────────────────────────────────── // ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
private void MapCamtEntries(DataTable tbl, List<CamtStatement> statements) private void MapCamtEntries(DataTable tbl, List<CamtStatement> statements, ParseDiagnostics diag)
{ {
void SetNfo(DataRow nr, string key, object? value) void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag);
{
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
foreach (var stmt in statements) foreach (var stmt in statements)
{ {
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue; if (string.IsNullOrEmpty(stmt.AccountIdentification))
{
diag.StatementsSkippedNoAccount++;
_logger.LogWarning(
"CAMT statement skipped: no AccountIdentification ({EntryCount} entrie(s) dropped, docType={DocType}).",
stmt.Entries.Count, stmt.DocumentType);
continue;
}
foreach (var e in stmt.Entries) foreach (var e in stmt.Entries)
{ {
try try
@@ -155,7 +246,10 @@ public class BankingService : IBankingService
if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount); if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate); if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate); if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate);
SetNfo(nr, "FundsCode", e.Currency); // FundsCode is a single-character MT940 funds code (VARCHAR(1)); CAMT has no
// equivalent, so it is left unset. The ISO currency (e.Currency, e.g. "EUR")
// must NOT be written here — it overflows the 1-char column and, before the
// width guard in SetCell, silently dropped every CAMT transaction.
SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation); SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
SetNfo(nr, "BankReference", e.BankReference); SetNfo(nr, "BankReference", e.BankReference);
SetNfo(nr, "EndToEndReference", e.EndToEndReference); SetNfo(nr, "EndToEndReference", e.EndToEndReference);
@@ -175,7 +269,13 @@ public class BankingService : IBankingService
tbl.Rows.Add(nr); tbl.Rows.Add(nr);
} }
catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); } catch (Exception ex)
{
diag.EntriesSkippedError++;
_logger.LogWarning(ex,
"CAMT entry parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.",
stmt.AccountIdentification, e.EntryDate, e.Amount);
}
} }
} }
} }
+5 -1
View File
@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using System.Diagnostics; using System.Diagnostics;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Observability; using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -23,14 +24,16 @@ public class InvoiceService : IInvoiceService
private readonly Fuchs_intranet _intranet; private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf; private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage; private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly ILogger<InvoiceService> _logger; private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage, public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
ILogger<InvoiceService> logger) IEventService events, ILogger<InvoiceService> logger)
{ {
_intranet = intranet; _intranet = intranet;
_pdf = pdf; _pdf = pdf;
_blobStorage = blobStorage; _blobStorage = blobStorage;
_events = events;
_logger = logger; _logger = logger;
} }
@@ -150,6 +153,7 @@ public class InvoiceService : IInvoiceService
string fileName = invoice.InvoiceRegistration?.getString("DocumentName") string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf"; .ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration); await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration);
await _events.InvoiceFileCreatedAsync(invoice, fileName, userAccountId);
return ba; return ba;
} }
+10 -4
View File
@@ -234,16 +234,22 @@ public class ProcessWebComService : IComService
} }
} }
private static string BuildSignature() private string BuildSignature()
{ {
string sigPath = Path.Combine(AppContext.BaseDirectory,
"email_signature", "sanitaerfuchs_email_signature.txt");
try try
{ {
string sigPath = Path.Combine(AppContext.BaseDirectory,
"email_signature", "sanitaerfuchs_email_signature.txt");
if (File.Exists(sigPath)) if (File.Exists(sigPath))
return SignatureIntro + File.ReadAllText(sigPath); return SignatureIntro + File.ReadAllText(sigPath);
} }
catch { /* signature is optional */ } catch (Exception ex)
{
// The signature is optional (emails still send without it), but a read failure here
// usually means a misconfigured deployment (permissions, locked file) that would
// otherwise go unnoticed indefinitely — every email would just quietly lack a signature.
_logger.LogWarning(ex, "Failed to read email signature file at {SignaturePath}", sigPath);
}
return ""; return "";
} }
+5 -1
View File
@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using System.Diagnostics; using System.Diagnostics;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Observability; using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -23,14 +24,16 @@ public class ReminderService : IReminderService
private readonly Fuchs_intranet _intranet; private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf; private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage; private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly ILogger<ReminderService> _logger; private readonly ILogger<ReminderService> _logger;
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage, public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
ILogger<ReminderService> logger) IEventService events, ILogger<ReminderService> logger)
{ {
_intranet = intranet; _intranet = intranet;
_pdf = pdf; _pdf = pdf;
_blobStorage = blobStorage; _blobStorage = blobStorage;
_events = events;
_logger = logger; _logger = logger;
} }
@@ -153,6 +156,7 @@ public class ReminderService : IReminderService
string fileName = reminder.ReminderRegistration?.getString("DocumentName") string fileName = reminder.ReminderRegistration?.getString("DocumentName")
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf"; .ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration); await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration);
await _events.ReminderFileCreatedAsync(reminder, fileName, userAccountId);
return ba; return ba;
} }
+18
View File
@@ -1,5 +1,8 @@
@using System.Security.Claims @using System.Security.Claims
@using Microsoft.Data.SqlClient
@using Newtonsoft.Json @using Newtonsoft.Json
@inject IConfiguration Configuration
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment HostEnvironment
@{ @{
bool isAuth = User.Identity?.IsAuthenticated ?? false; bool isAuth = User.Identity?.IsAuthenticated ?? false;
@@ -16,12 +19,26 @@
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet"; string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
string fullName = ViewData["FullName"] as string ?? ""; string fullName = ViewData["FullName"] as string ?? "";
string pageTitle = ViewData["Title"] as string ?? "Intranet"; string pageTitle = ViewData["Title"] as string ?? "Intranet";
string? debugDbTarget = null;
if (HostEnvironment.IsDevelopment())
{
var connectionString = Configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (!string.IsNullOrWhiteSpace(connectionString))
{
var builder = new SqlConnectionStringBuilder(connectionString);
debugDbTarget = $"{builder.DataSource} / {builder.InitialCatalog}";
}
}
} }
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@if (!string.IsNullOrWhiteSpace(debugDbTarget))
{
<meta name="fuchs-debug-database" content="@debugDbTarget" />
}
<title>@pageTitle</title> <title>@pageTitle</title>
<script src="~/web/tools.js" asp-append-version="true"></script> <script src="~/web/tools.js" asp-append-version="true"></script>
@@ -92,6 +109,7 @@
</div> </div>
</main> </main>
<footer> <footer>
<div id="notification_frame"></div>
@await RenderSectionAsync("BodyFooter", required: false) @await RenderSectionAsync("BodyFooter", required: false)
</footer> </footer>
} }
+1
View File
@@ -40,6 +40,7 @@
"outputFileName": "wwwroot/web/fis.min.js", "outputFileName": "wwwroot/web/fis.min.js",
"inputFiles": [ "inputFiles": [
"js/intranet/oci_texts_basic_de.js", "js/intranet/oci_texts_basic_de.js",
"node_modules/@microsoft/signalr/dist/browser/signalr.min.js",
"js/intranet/oci_texts_gui_de.js", "js/intranet/oci_texts_gui_de.js",
"js/intranet/oci_texts_val_de.js", "js/intranet/oci_texts_val_de.js",
"web/loadcss/loadCSS.js", "web/loadcss/loadCSS.js",
+10 -2
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -218,14 +219,21 @@ public class FuchsUserIdentity
// --------------------------- SQL options ------------------------------------- // --------------------------- SQL options -------------------------------------
/// <summary> /// <summary>
/// Fuchs-specific SQL options — adds debug logging on error. /// Fuchs-specific SQL options — logs every SQL error both to the app's structured
/// <see cref="ILogger"/>/OpenTelemetry pipeline and to the <c>fds__admin_logdebug</c> SQL
/// table (via <see cref="Fuchs_intranet.debug_log"/>). Handlers that don't separately inspect
/// the result's <c>.Exception</c> would otherwise return an empty/200 response on a failing
/// stored procedure with zero application-log signal — the DB table alone requires someone to
/// go looking for it.
/// </summary> /// </summary>
public class FIS_SQLOptions : sqloptions public class FIS_SQLOptions : sqloptions
{ {
public FIS_SQLOptions(Dictionary<string, object>? context = null) public FIS_SQLOptions(Dictionary<string, object>? context = null, ILogger? logger = null)
{ {
OnError = (procedure, ex, data) => OnError = (procedure, ex, data) =>
{ {
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}",
procedure, ex.Message, context);
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); } try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); }
catch { } catch { }
}; };
+55
View File
@@ -29,6 +29,61 @@ main nav ul > li a[role=button] {
text-align: center; text-align: center;
} }
#notification_frame {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 2000;
width: min(24rem, calc(100vw - 2rem));
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.notification_item {
position: relative;
background: #fff;
border-left: 0.35rem solid $fuchs_blau;
border-radius: 0.35rem;
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
color: #222;
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
pointer-events: auto;
&.warn {
border-left-color: #c78300;
}
&.error {
border-left-color: #b92525;
}
.notification_title {
font-weight: bold;
line-height: 1.25;
margin-bottom: 0.2rem;
}
.notification_message {
font-size: 0.9rem;
line-height: 1.3;
}
.notification_close {
position: absolute;
top: 0.35rem;
right: 0.45rem;
border: 0;
background: transparent;
color: #444;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0.1rem 0.25rem;
}
}
.wdg_frame { .wdg_frame {
background-color: #FFF; background-color: #FFF;
border: 1px solid #ccc; border: 1px solid #ccc;
+42 -1
View File
@@ -228,4 +228,45 @@ $fis.ov = function () {
}); });
}, loading: ovf }, loading: ovf
}); });
}; };
$fis.notifications = {
connection: null,
init: function () {
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
return;
}
this.ensureFrame();
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/notifications')
.withAutomaticReconnect()
.build();
this.connection.on('notification', (notification) => {
this.push(notification);
});
this.connection.start().catch(() => {
this.connection = null;
});
},
ensureFrame: function () {
if ($('#notification_frame').length < 1) {
$('<div/>', { id: 'notification_frame' }).appendTo($('footer:first').length ? 'footer:first' : 'body');
}
},
push: function (notification) {
this.ensureFrame();
notification = notification || {};
let item = $('<div/>', { class: 'notification_item' })
.addClass((notification.severity || 'info').toLowerCase())
.append($('<button/>', { type: 'button', class: 'notification_close', text: '×' }))
.append($('<div/>', { class: 'notification_title', text: notification.title || 'Info' }))
.append($('<div/>', { class: 'notification_message', text: notification.message || '' }));
item.find('.notification_close').on('click', function () {
item.remove();
});
$('#notification_frame').prepend(item);
setTimeout(function () {
item.fadeOut(150, function () { item.remove(); });
}, 9000);
}
};
+1
View File
@@ -1,3 +1,4 @@
$(document).ready(function () { $(document).ready(function () {
$fis.notifications.init();
$fis.ov(); $fis.ov();
}); });
+315
View File
@@ -8,6 +8,7 @@
"name": "fuchs", "name": "fuchs",
"version": "1.1.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.0",
"fg-loadcss": "3.1.0", "fg-loadcss": "3.1.0",
"jquery": "4.0.0", "jquery": "4.0.0",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
@@ -51,6 +52,19 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -426,6 +440,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
@@ -1026,6 +1052,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events-universal": { "node_modules/events-universal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@@ -1036,6 +1071,15 @@
"bare-events": "^2.7.0" "bare-events": "^2.7.0"
} }
}, },
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/expand-tilde": { "node_modules/expand-tilde": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -1150,6 +1194,16 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/fg-loadcss": { "node_modules/fg-loadcss": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
@@ -2457,6 +2511,26 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -2677,6 +2751,33 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2789,6 +2890,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -2966,6 +3073,12 @@
"node": ">= 10.13.0" "node": ">= 10.13.0"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/slash": { "node_modules/slash": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
@@ -3281,6 +3394,27 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -3367,12 +3501,31 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/upper-case": { "node_modules/upper-case": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=", "integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
"dev": true "dev": true
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3588,6 +3741,22 @@
"source-map": "^0.5.1" "source-map": "^0.5.1"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@@ -3641,6 +3810,27 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true "dev": true
}, },
"node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
@@ -3706,6 +3896,18 @@
"is-negated-glob": "^1.0.0" "is-negated-glob": "^1.0.0"
} }
}, },
"@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"requires": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"@nodelib/fs.scandir": { "@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3864,6 +4066,14 @@
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true "dev": true
}, },
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"ansi-colors": { "ansi-colors": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
@@ -4296,6 +4506,11 @@
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true "dev": true
}, },
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"events-universal": { "events-universal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@@ -4305,6 +4520,11 @@
"bare-events": "^2.7.0" "bare-events": "^2.7.0"
} }
}, },
"eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="
},
"expand-tilde": { "expand-tilde": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -4396,6 +4616,15 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"requires": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"fg-loadcss": { "fg-loadcss": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
@@ -5356,6 +5585,14 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"normalize-path": { "normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5510,6 +5747,24 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"requires": {
"punycode": "^2.3.1"
}
},
"punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
"querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"queue-microtask": { "queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5587,6 +5842,11 @@
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true "dev": true
}, },
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"resolve": { "resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5697,6 +5957,11 @@
"sver": "^1.8.3" "sver": "^1.8.3"
} }
}, },
"set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"slash": { "slash": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
@@ -5946,6 +6211,22 @@
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
}, },
"tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"requires": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
}
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"tslib": { "tslib": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -6006,12 +6287,26 @@
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true "dev": true
}, },
"universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="
},
"upper-case": { "upper-case": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=", "integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
"dev": true "dev": true
}, },
"url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6180,6 +6475,20 @@
"source-map": "^0.5.1" "source-map": "^0.5.1"
} }
}, },
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"which": { "which": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@@ -6217,6 +6526,12 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true "dev": true
}, },
"ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"requires": {}
},
"xtend": { "xtend": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "fuchs", "name": "fuchs",
"version": "1.1.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.0",
"fg-loadcss": "3.1.0", "fg-loadcss": "3.1.0",
"jquery": "4.0.0", "jquery": "4.0.0",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
+50
View File
@@ -2230,6 +2230,56 @@ main nav ul > li a[role=button]:hover::after, main nav ul > li a[role=button].fb
text-align: center; text-align: center;
} }
#notification_frame {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 2000;
width: min(24rem, 100vw - 2rem);
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.notification_item {
position: relative;
background: #fff;
border-left: 0.35rem solid rgb(27, 67, 121);
border-radius: 0.35rem;
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
color: #222;
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
pointer-events: auto;
}
.notification_item.warn {
border-left-color: #c78300;
}
.notification_item.error {
border-left-color: #b92525;
}
.notification_item .notification_title {
font-weight: bold;
line-height: 1.25;
margin-bottom: 0.2rem;
}
.notification_item .notification_message {
font-size: 0.9rem;
line-height: 1.3;
}
.notification_item .notification_close {
position: absolute;
top: 0.35rem;
right: 0.45rem;
border: 0;
background: transparent;
color: #444;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0.1rem 0.25rem;
}
.wdg_frame { .wdg_frame {
background-color: #FFF; background-color: #FFF;
border: 1px solid #ccc; border: 1px solid #ccc;
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+42 -14
View File
@@ -13,15 +13,26 @@ using static OCORE.commons;
namespace fds; namespace fds;
/// <summary>
/// Logs every SQL error both to the worker's structured <see cref="ILogger"/> (so it reaches
/// whatever sink/OTel export is configured) and to <see cref="FdsDebug.DebugLog"/> (SQL/local-file
/// debug trail). Passing <paramref name="logger"/> is optional for call sites that don't have one
/// in scope, but every caller that does have one in scope should pass it — otherwise a failing
/// stored procedure only ever shows up in the debug trail, never in the worker's own logs.
/// </summary>
public class FdsSqlOptions : sqloptions public class FdsSqlOptions : sqloptions
{ {
private Dictionary<string, object>? _baseInfo; private Dictionary<string, object>? _baseInfo;
public FdsSqlOptions(Dictionary<string, object>? dic = null) public FdsSqlOptions(Dictionary<string, object>? dic = null, ILogger? logger = null)
{ {
_baseInfo = dic; _baseInfo = dic;
OnError = (procedure, ex, data) => OnError = (procedure, ex, data) =>
{
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — ctrl_nfo={CtrlInfo} sql={Data}",
procedure, ex.Message, dic, data);
FdsDebug.DebugLog(procedure, exc: ex, data: $"ctrl_nfo={dic}, sql={data}"); FdsDebug.DebugLog(procedure, exc: ex, data: $"ctrl_nfo={dic}, sql={data}");
};
} }
} }
@@ -88,7 +99,7 @@ public class FdsMfr : IFdsMfr
if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - start", filename: "DebugDetail.txt"); if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - start", filename: "DebugDetail.txt");
var dtbl = await getSQLDatatable_async( var dtbl = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__fn_getMFRInvoicesWithoutfiles];", "EXECUTE [dbo].[fds__fn_getMFRInvoicesWithoutfiles];",
FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions()); FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions(logger: _logger));
if (dtbl.Count > 0) if (dtbl.Count > 0)
{ {
var rows = dtbl.DataTable.Rows.Cast<DataRow>() var rows = dtbl.DataTable.Rows.Cast<DataRow>()
@@ -115,7 +126,7 @@ public class FdsMfr : IFdsMfr
SQL_VarChar("@Id", r.id), SQL_VarChar("@Id", r.id),
SQL_VarChar("@filename", r.docName), SQL_VarChar("@filename", r.docName),
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }), new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
options: new FdsSqlOptions()); options: new FdsSqlOptions(logger: _logger));
Interlocked.Increment(ref downloaded); Interlocked.Increment(ref downloaded);
} }
} }
@@ -142,7 +153,7 @@ public class FdsMfr : IFdsMfr
var pl = new List<SqlParameter> { SQL_VarChar("@reportid", reportid) }; var pl = new List<SqlParameter> { SQL_VarChar("@reportid", reportid) };
var sqldt = Task.Run(async () => await getSQLDatatable_async( var sqldt = Task.Run(async () => await getSQLDatatable_async(
"EXECUTE [dbo].[fds__getReportDocument] @reportid;", "EXECUTE [dbo].[fds__getReportDocument] @reportid;",
FdsShared.FDSConnectionString(), SqlParameterList: pl, options: new FdsSqlOptions())).Result; FdsShared.FDSConnectionString(), SqlParameterList: pl, options: new FdsSqlOptions(logger: _logger))).Result;
if (sqldt.Count > 0) if (sqldt.Count > 0)
{ {
@@ -166,7 +177,7 @@ public class FdsMfr : IFdsMfr
SQL_VarChar("@Id", reportid), SQL_VarChar("@Id", reportid),
SQL_VarChar("@filename", fln), SQL_VarChar("@filename", fln),
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }), new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
options: new FdsSqlOptions())).Wait(); options: new FdsSqlOptions(logger: _logger))).Wait();
} }
catch (Exception fsex) { FdsDebug.DebugLog("getReportDoc - mfr storefile", exc: fsex); } catch (Exception fsex) { FdsDebug.DebugLog("getReportDoc - mfr storefile", exc: fsex); }
} }
@@ -197,7 +208,7 @@ public class FdsMfr : IFdsMfr
var pl = new List<SqlParameter> { SQL_VarChar("@type", type), SQL_VarChar("@reportid", reportid) }; var pl = new List<SqlParameter> { SQL_VarChar("@type", type), SQL_VarChar("@reportid", reportid) };
var sqldt = Task.Run(async () => await getSQLDatatable_async( var sqldt = Task.Run(async () => await getSQLDatatable_async(
"EXECUTE [dbo].[fds__getFDSDocument] @type, @reportid;", "EXECUTE [dbo].[fds__getFDSDocument] @type, @reportid;",
FdsShared.FDSConnectionString(), SqlParameterList: pl, options: new FdsSqlOptions())).Result; FdsShared.FDSConnectionString(), SqlParameterList: pl, options: new FdsSqlOptions(logger: _logger))).Result;
if (sqldt.Count > 0) if (sqldt.Count > 0)
{ {
@@ -236,14 +247,14 @@ public class FdsMfr : IFdsMfr
SQL_Bit("@files", includeFiles), SQL_VarChar("@authuser", authUser) SQL_Bit("@files", includeFiles), SQL_VarChar("@authuser", authUser)
}, },
tablenames: new[] { "admin", "inv", "buchungen", "debitoren" }, tablenames: new[] { "admin", "inv", "buchungen", "debitoren" },
options: new FdsSqlOptions())).Result; options: new FdsSqlOptions(logger: _logger))).Result;
var bediFiles = new List<DatevDocument>(); var bediFiles = new List<DatevDocument>();
if (dset.Count >= 4) if (dset.Count >= 4)
{ {
var admin = dset.Tables("admin").Rows[0].toObjectDictionary(); var admin = dset.Tables("admin").Rows[0].toObjectDictionary();
DateTime startdate = (DateTime)admin["startdate"], enddate = (DateTime)admin["enddate"]; DateTime startdate = (DateTime)admin["startdate"]!, enddate = (DateTime)admin["enddate"]!;
string register = tgtdate.ToString("yyyy\\/MM") + (mode.ToLower() == "w" ? "_w" + tgtdate.ToString("dd") : ""); string register = tgtdate.ToString("yyyy\\/MM") + (mode.ToLower() == "w" ? "_w" + tgtdate.ToString("dd") : "");
var fls = new Dictionary<string, byte[]>(); var fls = new Dictionary<string, byte[]>();
@@ -251,10 +262,10 @@ public class FdsMfr : IFdsMfr
{ {
Formatkategorie = DatevFormatkategorie.Buchungsstapel, Formatkategorie = DatevFormatkategorie.Buchungsstapel,
Formatversion = DatevFormatversion.Buchungsstapel_9, Formatversion = DatevFormatversion.Buchungsstapel_9,
Beraternummer = (int)admin["beraternummer"], Beraternummer = (int)admin["beraternummer"]!,
Mandantennummer = (int)admin["mandantennummer"], Mandantennummer = (int)admin["mandantennummer"]!,
WJBeginn = (DateTime)admin["WJ-Beginn"], WJBeginn = (DateTime)admin["WJ-Beginn"]!,
Sachkontenlänge = (int)admin["Sachkontenlänge"], Sachkontenlänge = (int)admin["Sachkontenlänge"]!,
DatumVon = startdate, DatumVon = startdate,
DatumBis = enddate, DatumBis = enddate,
Bezeichnung = "fds_" + mode + tgtdate.ToString("yyMMdd") Bezeichnung = "fds_" + mode + tgtdate.ToString("yyMMdd")
@@ -284,7 +295,14 @@ public class FdsMfr : IFdsMfr
else else
fl = irow.no("file", null) as byte[]; fl = irow.no("file", null) as byte[];
} }
catch { } catch (Exception ex)
{
// Silently omitting this file from the DATEV export previously left no
// trace at all — the export "succeeded" while quietly missing a document.
_logger.LogWarning(ex,
"getDatevZip: failed to fetch invoice file — invoiceId={InvoiceId} fileName={FileName}",
irow.nz("InvoiceId"), fln);
}
if (fl != null && !string.IsNullOrEmpty(fln)) if (fl != null && !string.IsNullOrEmpty(fln))
{ {
fls.Add(fln, fl); fls.Add(fln, fl);
@@ -308,8 +326,18 @@ public class FdsMfr : IFdsMfr
stream!.Position = 0; stream!.Position = 0;
return archiveFile; return archiveFile;
} }
_logger.LogError(
"getDatevZip: CompressToStream returned false — archiveFile={ArchiveFile} fileCount={FileCount}",
archiveFile.FullName, fls.Count);
}
catch (Exception ex)
{
// Previously swallowed entirely: a DATEV export could fail at the archive
// step with zero trace anywhere, not even the DB debug log.
_logger.LogError(ex,
"getDatevZip: archive creation failed — archiveFile={ArchiveFile} fileCount={FileCount}",
archiveFile.FullName, fls.Count);
} }
catch { }
} }
} }
} }
+7 -7
View File
@@ -64,7 +64,7 @@ public class FdsMfrClient : IDisposable
public Dictionary<string, Dictionary<string, string>> NavProperties { get; } = new(); public Dictionary<string, Dictionary<string, string>> NavProperties { get; } = new();
private readonly DataSet _tableSet; private readonly DataSet _tableSet;
public DatabaseSchema(EntityTypes et) public DatabaseSchema(EntityTypes et, ILogger? logger = null)
{ {
_et = et; _et = et;
var dset = Task.Run(async () => await getSQLDataSet_async( var dset = Task.Run(async () => await getSQLDataSet_async(
@@ -72,7 +72,7 @@ public class FdsMfrClient : IDisposable
FdsShared.FDSConnectionString(), FdsShared.FDSConnectionString(),
SqlParameterList: new List<SqlParameter> { new("@tgttype", ThisEntityName) }, SqlParameterList: new List<SqlParameter> { new("@tgttype", ThisEntityName) },
tablenames: new[] { "entity", "complex_types", "navigation_properties", "tables" }, tablenames: new[] { "entity", "complex_types", "navigation_properties", "tables" },
options: new FdsSqlOptions())).Result; options: new FdsSqlOptions(logger: logger))).Result;
IsValid = dset.Count > 0; IsValid = dset.Count > 0;
HasEntity = dset.Tables("entity").Rows.Count == 1; HasEntity = dset.Tables("entity").Rows.Count == 1;
@@ -98,7 +98,7 @@ public class FdsMfrClient : IDisposable
string.Join(Environment.NewLine, sqlList), string.Join(Environment.NewLine, sqlList),
FdsShared.FDSConnectionString(), FdsShared.FDSConnectionString(),
tablenames: tableNames.ToArray(), tablenames: tableNames.ToArray(),
options: new FdsSqlOptions())).Result.DataSet; options: new FdsSqlOptions(logger: logger))).Result.DataSet;
} }
public DataSet TgtDataset(string setId) public DataSet TgtDataset(string setId)
@@ -147,7 +147,7 @@ public class FdsMfrClient : IDisposable
{ {
try try
{ {
var schema = schemaDic != null && schemaDic.TryGetValue(thisEntityName, out var s) ? s : new DatabaseSchema(et); var schema = schemaDic != null && schemaDic.TryGetValue(thisEntityName, out var s) ? s : new DatabaseSchema(et, _logger);
if (!schema.IsValid) if (!schema.IsValid)
{ {
dlg("Schema not found", "", ""); dlg("Schema not found", "", "");
@@ -179,7 +179,7 @@ public class FdsMfrClient : IDisposable
{ {
var lastDate = await getSQLValue_async( var lastDate = await getSQLValue_async(
schema.EntityConfig.nz("DateSQL").ne($"SELECT MAX([{schema.EntityConfig["DateColumn"]}]) FROM [dbo].[{schema.EntityTableName}];"), schema.EntityConfig.nz("DateSQL").ne($"SELECT MAX([{schema.EntityConfig["DateColumn"]}]) FROM [dbo].[{schema.EntityTableName}];"),
FdsShared.FDSConnectionString(), options: new FdsSqlOptions()); FdsShared.FDSConnectionString(), options: new FdsSqlOptions(logger: _logger));
filter.Add( filter.Add(
updateNeed == FdsMfr.UpdateNeed.Short && lastDate.Result is DateTime dt updateNeed == FdsMfr.UpdateNeed.Short && lastDate.Result is DateTime dt
? $"$filter={schema.EntityConfig["DateColumn"]} gt DateTime'{dt:yyyy-MM-dd}T00:00:00'" ? $"$filter={schema.EntityConfig["DateColumn"]} gt DateTime'{dt:yyyy-MM-dd}T00:00:00'"
@@ -395,7 +395,7 @@ public class FdsMfrClient : IDisposable
{ {
string sql = "SELECT * FROM [dbo].[fds__getUpdateableTables]()" string sql = "SELECT * FROM [dbo].[fds__getUpdateableTables]()"
+ (tgtEntityType.HasValue ? $" WHERE [entity_name] = '{tgtEntityType.Value}'" : "") + ";"; + (tgtEntityType.HasValue ? $" WHERE [entity_name] = '{tgtEntityType.Value}'" : "") + ";";
var updateableTables = await getSQLDatatable_async(sql, FdsShared.FDSConnectionString(), options: new FdsSqlOptions()); var updateableTables = await getSQLDatatable_async(sql, FdsShared.FDSConnectionString(), options: new FdsSqlOptions(logger: _logger));
dtf("UpdateableTables", updateableTables.Exception ?? "", $"({(updateableTables.Count > 0 ? updateableTables.DataTable.Rows.Count.ToString() : " no")} Rows)", null); dtf("UpdateableTables", updateableTables.Exception ?? "", $"({(updateableTables.Count > 0 ? updateableTables.DataTable.Rows.Count.ToString() : " no")} Rows)", null);
if (updateableTables.Count > 0 && updateableTables.DataTable.Columns.Contains("updateneed")) if (updateableTables.Count > 0 && updateableTables.DataTable.Columns.Contains("updateneed"))
@@ -446,7 +446,7 @@ public class FdsMfrClient : IDisposable
{ {
var updateableRequests = await getSQLDatatable_async( var updateableRequests = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fds__getUpdateableRequests]();", "SELECT * FROM [dbo].[fds__getUpdateableRequests]();",
FdsShared.FDSConnectionString(), options: new FdsSqlOptions()); FdsShared.FDSConnectionString(), options: new FdsSqlOptions(logger: _logger));
dtf("UpdateableRequests", updateableRequests.Exception ?? "", $"({(updateableRequests.Count > 0 ? updateableRequests.DataTable.Rows.Count.ToString() : " no")} Rows)", null); dtf("UpdateableRequests", updateableRequests.Exception ?? "", $"({(updateableRequests.Count > 0 ? updateableRequests.DataTable.Rows.Count.ToString() : " no")} Rows)", null);
if (updateableRequests.Count > 0) if (updateableRequests.Count > 0)
+1 -1
View File
@@ -9,7 +9,7 @@ namespace fds;
/// <summary> /// <summary>
/// Holds the application <see cref="IConfiguration"/> built from appsettings.json. /// Holds the application <see cref="IConfiguration"/> built from appsettings.json.
/// Call <see cref="Initialize"/> once at startup before accessing <see cref="Current"/>. /// Call <see cref="Initialize()"/> once at startup before accessing <see cref="Current"/>.
/// </summary> /// </summary>
public static class FdsConfig public static class FdsConfig
{ {
+9 -3
View File
@@ -5,7 +5,6 @@ namespace fds;
public class Archive : IDisposable public class Archive : IDisposable
{ {
public event Action? Saving;
public event Action? FileSaved; public event Action? FileSaved;
public event Action? FileStreamCreated; public event Action? FileStreamCreated;
@@ -140,8 +139,12 @@ public class Archive : IDisposable
_zipIn = null; _zipIn = null;
ZipInOK = false; ZipInOK = false;
} }
catch catch (Exception ex)
{ {
// Previously silent: callers (e.g. HandleDatevZip) saw only a bare failed result
// with no way to tell a compression error from any other reason ExitOK is false.
_logger.LogError(ex, "Archive.Compress failed — archiveFile={ArchiveFile} fileCount={FileCount}",
archiveFile.FullName, files.Count);
ExitOK = false; ExitOK = false;
} }
archiveFile.Refresh(); archiveFile.Refresh();
@@ -179,8 +182,11 @@ public class Archive : IDisposable
_zipIn = null; _zipIn = null;
ZipInOK = false; ZipInOK = false;
} }
catch catch (Exception ex)
{ {
// Previously silent: the DATEV export ZIP-to-stream path failed with no
// trace anywhere, only a bare false return.
_logger.LogError(ex, "Archive.CompressToStream failed — fileCount={FileCount}", files.Count);
ExitOK = false; ExitOK = false;
} }
return ExitOK; return ExitOK;
+4 -2
View File
@@ -10,11 +10,13 @@
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile> <DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
<NoWarn>1591</NoWarn> <!-- NU1608: transitive AngleSharp.Css/AngleSharp conflict inherited from OCORE_web -->
<NoWarn>1591;NU1608</NoWarn>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile> <DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
<NoWarn>1591</NoWarn> <!-- NU1608: transitive AngleSharp.Css/AngleSharp conflict inherited from OCORE_web -->
<NoWarn>1591;NU1608</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="appsettings.json"> <Content Include="appsettings.json">
+9
View File
@@ -4,6 +4,15 @@
<name>Fuchs_DataService</name> <name>Fuchs_DataService</name>
</assembly> </assembly>
<members> <members>
<member name="T:fds.FdsSqlOptions">
<summary>
Logs every SQL error both to the worker's structured <see cref="T:Microsoft.Extensions.Logging.ILogger"/> (so it reaches
whatever sink/OTel export is configured) and to <see cref="M:fds.FdsDebug.DebugLog(System.String,Microsoft.Data.SqlClient.SqlConnection,System.Exception,System.String,System.Object)"/> (SQL/local-file
debug trail). Passing <paramref name="logger"/> is optional for call sites that don't have one
in scope, but every caller that does have one in scope should pass it — otherwise a failing
stored procedure only ever shows up in the debug trail, never in the worker's own logs.
</summary>
</member>
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency"> <member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
<summary>Max parallel invoice-file downloads (independent per file).</summary> <summary>Max parallel invoice-file downloads (independent per file).</summary>
</member> </member>
-3
View File
@@ -15,9 +15,6 @@
<Project Path="CAMTParser/CAMTParser.csproj"> <Project Path="CAMTParser/CAMTParser.csproj">
<BuildType Solution="db-dev.processweb.de|*" Project="Release" /> <BuildType Solution="db-dev.processweb.de|*" Project="Release" />
<BuildType Solution="server02.processweb.de|*" Project="Debug" /> <BuildType Solution="server02.processweb.de|*" Project="Debug" />
<Build Solution="db-dev.processweb.de|*" Project="false" />
<Build Solution="Debug|*" Project="false" />
<Build Solution="server02.processweb.de|*" Project="false" />
</Project> </Project>
<Project Path="Fuchs.Tests/Fuchs.Tests.csproj"> <Project Path="Fuchs.Tests/Fuchs.Tests.csproj">
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" /> <BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
+1 -1
Submodule OCORE updated: 6a3926e3e1...dd1d327e36