Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaf062fd77 | ||
|
|
882e97509a |
@@ -1,16 +1,20 @@
|
||||
# Copilot Instructions
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file (`.github/copilot-instructions.md`) and the Claude Code instructions
|
||||
> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**.
|
||||
> Whenever you change one, make the equivalent change in the other in the same
|
||||
> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared
|
||||
> This file (`.github/copilot-instructions.md`), the Claude Code instructions
|
||||
> (`/CLAUDE.md`), and the Codex instructions (`/CODEX.md`) are **three views of
|
||||
> the same project rules and must stay in sync**.
|
||||
> Whenever you change one, make the equivalent change in the other two in the same
|
||||
> commit. `CLAUDE.md` and `CODEX.md` may add tool-specific workflow notes, but the shared
|
||||
> project facts (architecture, coding standards, configuration, libraries,
|
||||
> secrets, observability) must match.
|
||||
|
||||
## Project Overview
|
||||
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
|
||||
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Project structure (relative to `Fuchs/`):
|
||||
- `Controllers/` — `IntranetController` partials (no area)
|
||||
- `code/` — business logic, PDF, email, widgets, data models
|
||||
@@ -74,6 +78,11 @@
|
||||
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||
- DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
||||
|
||||
## Decisions & Concepts
|
||||
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
|
||||
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
|
||||
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
|
||||
|
||||
## Azure Key Vault — Secret Naming
|
||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Scratch / build-verification output
|
||||
/tmp/
|
||||
|
||||
# SSDT / SQL database project caches (regenerated)
|
||||
*.dbmdl
|
||||
*.jfm
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Fuchs: Debug ASP.NET Core",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build Fuchs",
|
||||
"program": "${workspaceFolder}/Fuchs/bin/Debug/net10.0/Fuchs.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/Fuchs",
|
||||
"stopAtEntry": false,
|
||||
"justMyCode": true,
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Fuchs/Views"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+129
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build Fuchs",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Fuchs/Fuchs.csproj",
|
||||
"--configuration",
|
||||
"Debug",
|
||||
"/p:RestoreIgnoreFailedSources=true",
|
||||
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "gulp: all",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"all"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: min",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"min"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "gulp: copy",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"copy"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: min:js",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"min:js"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: min:css",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"min:css"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: min:scss",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"min:scss"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: min:html",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"min:html"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "gulp: watch",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"gulp",
|
||||
"watch"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/Fuchs"
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+25
-10
@@ -51,34 +51,49 @@ public sealed class CamtParser
|
||||
|
||||
/// <summary>
|
||||
/// Parses all CAMT XML files found inside a ZIP archive and returns
|
||||
/// the combined list of statements. Non-XML entries and malformed XML
|
||||
/// entries are silently skipped. Used for camt.052 deliveries where the
|
||||
/// the combined list of statements. Non-XML entries and malformed XML
|
||||
/// entries are skipped. Used for camt.052 deliveries where the
|
||||
/// bank wraps one or more intraday reports in a single ZIP file.
|
||||
/// </summary>
|
||||
public List<CamtStatement> ParseZip(byte[] bytes)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
return ParseZip(ms);
|
||||
}
|
||||
public List<CamtStatement> ParseZip(byte[] bytes) => ParseZip(bytes, out _);
|
||||
|
||||
/// <inheritdoc cref="ParseZip(byte[])"/>
|
||||
public List<CamtStatement> ParseZip(Stream stream)
|
||||
public List<CamtStatement> ParseZip(Stream stream) => ParseZip(stream, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="ParseZip(byte[])"/>, but also reports which entries were
|
||||
/// skipped and why (non-.xml name, non-XML content, malformed XML), so callers
|
||||
/// can log a reason instead of silently losing part of a delivery.
|
||||
/// </summary>
|
||||
public List<CamtStatement> ParseZip(byte[] bytes, out List<string> skippedEntries)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
return ParseZip(ms, out skippedEntries);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ParseZip(byte[], out List{string})"/>
|
||||
public List<CamtStatement> ParseZip(Stream stream, out List<string> skippedEntries)
|
||||
{
|
||||
var result = new List<CamtStatement>();
|
||||
var skipped = new List<string>();
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
continue; // non-XML entries (e.g. checksums, manifests) are expected, not worth reporting
|
||||
using var entryStream = entry.Open();
|
||||
using var buffer = new MemoryStream();
|
||||
entryStream.CopyTo(buffer);
|
||||
var entryBytes = buffer.ToArray();
|
||||
if (!LooksLikeXml(entryBytes))
|
||||
{
|
||||
skipped.Add($"{entry.Name}: not XML content");
|
||||
continue;
|
||||
}
|
||||
try { result.AddRange(Parse(entryBytes)); }
|
||||
catch (FormatException) { /* skip malformed XML entries */ }
|
||||
catch (FormatException ex) { skipped.Add($"{entry.Name}: {ex.Message}"); }
|
||||
}
|
||||
skippedEntries = skipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# CLAUDE.md — Project instructions for Claude Code
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file and **`.github/copilot-instructions.md`** are two views of the same
|
||||
> project rules and **must stay in sync**. When you change a shared rule
|
||||
> This file, **`CODEX.md`**, and **`.github/copilot-instructions.md`** are three
|
||||
> views of the same project rules and **must stay in sync**. When you change a shared rule
|
||||
> (architecture, coding standards, configuration, libraries, secrets,
|
||||
> observability, testing), make the equivalent change in **both files in the
|
||||
> observability, testing), make the equivalent change in **all three files in the
|
||||
> same commit**. This file may add Claude Code / workflow specifics; the shared
|
||||
> project facts must match `copilot-instructions.md`.
|
||||
> project facts must match `CODEX.md` and `copilot-instructions.md`.
|
||||
|
||||
## Project Overview
|
||||
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
## Build & Test (workflow)
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
@@ -69,8 +70,15 @@
|
||||
## Secrets (Azure Key Vault)
|
||||
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
||||
|
||||
## Decisions & Concepts
|
||||
- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.**
|
||||
- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching.
|
||||
- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code.
|
||||
|
||||
## Documentation map
|
||||
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
||||
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
||||
- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above.
|
||||
- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above.
|
||||
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
||||
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# CODEX.md — Project instructions for Codex
|
||||
|
||||
> ## Instruction Sync
|
||||
> This file, **`CLAUDE.md`**, and **`.github/copilot-instructions.md`** are three
|
||||
> views of the same project rules and **must stay in sync**. When you change a shared rule
|
||||
> (architecture, coding standards, configuration, libraries, secrets,
|
||||
> observability, testing), make the equivalent change in **all three files in the
|
||||
> same commit**. This file may add Codex / workflow specifics; the shared
|
||||
> project facts must match `CLAUDE.md` and `copilot-instructions.md`.
|
||||
|
||||
## Project Overview
|
||||
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
||||
- Routes: `/{fn?}/{id?}/{code?}` -> `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` -> `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`).
|
||||
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `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.
|
||||
@@ -377,4 +377,70 @@ public class BankingDualFormatTests
|
||||
Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]);
|
||||
Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema whose string columns carry the same MaxLength as the real
|
||||
/// <c>fds__tt__bankingtransactions</c> table type. Building it here lets the tests
|
||||
/// catch width-overflow bugs that <see cref="BankingService.BuildDefaultSchema"/> (which
|
||||
/// uses unconstrained strings) cannot. Only the columns exercised below are constrained.
|
||||
/// </summary>
|
||||
private static DataTable DbLikeSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("AccountIdentification", typeof(string)).MaxLength = 50;
|
||||
t.Columns.Add("Amount", typeof(decimal));
|
||||
t.Columns.Add("ValueDate", typeof(DateTime));
|
||||
t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here
|
||||
t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 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 });
|
||||
|
||||
case "up":
|
||||
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
|
||||
_logger.LogInformation("Banking statement upload: {FileCount} file(s) user={User}",
|
||||
Request.Form.Files.Count, UserAccountID);
|
||||
var uploadResults = new List<object>();
|
||||
foreach (var fle in Request.Form.Files)
|
||||
{
|
||||
using var stream = fle.OpenReadStream();
|
||||
@@ -34,6 +35,9 @@ public partial class IntranetController
|
||||
|
||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
||||
var (importFrom, importTo) = BankingDateRange(tbl);
|
||||
bool importFailed = false;
|
||||
string importFailure = "";
|
||||
|
||||
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
||||
{
|
||||
@@ -48,16 +52,80 @@ public partial class IntranetController
|
||||
dtwa.CommandAfterError = new SqlCommand(
|
||||
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
|
||||
dtwa.OnError += (_, exc, _) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
|
||||
"rescueTable={RescueTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
dtwa.DoSubmit();
|
||||
if (dtwa.SubmitException != null)
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = dtwa.SubmitException.Message;
|
||||
_logger.LogError(dtwa.SubmitException,
|
||||
"Banking upload submit exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
}
|
||||
|
||||
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":
|
||||
{
|
||||
@@ -126,7 +194,9 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
||||
return res.Result is true
|
||||
? await JSONAsync(new { ok = true })
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
case "ati":
|
||||
@@ -139,7 +209,9 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
||||
return res.Result is true
|
||||
? await JSONAsync(new { ok = true })
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
case "vfi":
|
||||
@@ -165,4 +237,26 @@ public partial class IntranetController
|
||||
|
||||
protected string Form(string key, string 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,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt2.Exception))
|
||||
{
|
||||
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ public partial class IntranetController
|
||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdRem.Id))
|
||||
{
|
||||
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" });
|
||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
case "conf": return await HandleReminderConf(fn, id, code);
|
||||
@@ -59,6 +60,12 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -127,16 +134,35 @@ public partial class IntranetController
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Reminder email send failed — reminderId={ReminderId} email={Email} user={User}",
|
||||
remId, email.Trim(), UserAccountID);
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||
UserAccountID, remId);
|
||||
}
|
||||
}
|
||||
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 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)
|
||||
@@ -178,14 +204,39 @@ public partial class IntranetController
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
await _comService.SendEmailAsync($"rem_{remId}",
|
||||
bool sent = await _comService.SendEmailAsync($"rem_{remId}",
|
||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true);
|
||||
}
|
||||
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 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) =>
|
||||
|
||||
@@ -36,7 +36,15 @@ public partial class IntranetController
|
||||
ri["params"] = dset.Tables("params")
|
||||
.toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'");
|
||||
}
|
||||
catch { ri["params"] = Array.Empty<Dictionary<string, object>>(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Without this, a genuinely broken params filter/query is indistinguishable
|
||||
// from the expected "this report has no params" case in the response.
|
||||
_logger.LogWarning(ex,
|
||||
"Report catalog: failed to load params for object_id={ObjectId} user={User}",
|
||||
ri["object_id"], UserAccountID);
|
||||
ri["params"] = Array.Empty<Dictionary<string, object>>();
|
||||
}
|
||||
}
|
||||
return await JSONAsync(new
|
||||
{
|
||||
|
||||
@@ -49,9 +49,11 @@ public partial class IntranetController
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
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)
|
||||
? 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":
|
||||
@@ -62,10 +64,11 @@ public partial class IntranetController
|
||||
change: false, invId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
case "sedit":
|
||||
@@ -76,10 +79,11 @@ public partial class IntranetController
|
||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
|
||||
}
|
||||
|
||||
case "sdel":
|
||||
@@ -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()!);
|
||||
foreach (var r in req)
|
||||
{
|
||||
try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); }
|
||||
catch { /* no reports table */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// "reports" table absent is expected for some queries; but a real failure while
|
||||
// joining (e.g. malformed filter) looked identical to that with no way to tell them apart.
|
||||
_logger.LogWarning(ex,
|
||||
"AttachReports: failed to join reports for requestId={RequestId} user={User}",
|
||||
r["Id"], UserAccountID);
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
@@ -285,15 +296,34 @@ public partial class IntranetController
|
||||
body, email.Trim(), "", inv);
|
||||
if (sent)
|
||||
{
|
||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||
invId, email.Trim(), UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
}
|
||||
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 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)
|
||||
@@ -309,7 +339,7 @@ public partial class IntranetController
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
|
||||
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
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));
|
||||
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||
await _comService.SendEmailAsync(
|
||||
bool sent = await _comService.SendEmailAsync(
|
||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
||||
if (sent)
|
||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice resend email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||
invId, email.Trim(), UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht erneut an {email.Trim()} versandt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> InvoiceIssueResult(string message, string invoiceId = "")
|
||||
{
|
||||
// Mirrors the SignalR toast in a durable app log: without this, an invoice save/create/send
|
||||
// failure was only visible as a GUI notification nobody was necessarily watching at the time.
|
||||
_logger.LogError("Invoice issue — invoiceId={InvoiceId} user={User} message={Message}",
|
||||
invoiceId, UserAccountID, message);
|
||||
await _events.InvoiceIssueAsync(message, UserAccountID, invoiceId);
|
||||
return StatusCode(500, new { error = message });
|
||||
}
|
||||
|
||||
private static string BuildInvoiceBody(double balance, string paymentTerms) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Web;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -33,6 +34,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -59,7 +61,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders)
|
||||
IReminderService reminders,
|
||||
IEventService events)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -72,6 +75,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
}
|
||||
|
||||
/// <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 FIS_SQLOptions SqlOpt(string fn, string id, string code) =>
|
||||
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code });
|
||||
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code }, _logger);
|
||||
|
||||
// ── Action helpers ────────────────────────────────────────────────────────
|
||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
The items, if completed, should be ticked / checked as done.
|
||||
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the Decisions ind \Docs\Decisions must be followed
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that whenever relevant new decisions should be captured, concept files should be created / updated
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the readme.md files in \Docs\Concept and \Docs\Decisions explain how to create, update, interpret the documents. Create those readme.md files. make sure that any concepts or decisions have a yaml header that contains applyTo key. The agents should scan those yaml headers first (saving tokens) and decide based on that if included/considered
|
||||
[x] Add a first decision, that domain events (success and fails) should be identified and equiped with triggers that trigger notification to the EventService and pass on a context that allows a user understandable message like "Rechnung R2026-0001 wurde and Kunden unter test@test.de per Email versandt".
|
||||
[x] Add a decision that reflects the current concept and implementation of Notification from Service in Backend over SignalR push to any logged in session to display in GUI. (failues with highlighting)
|
||||
[x] Add a decision that the solution must be equiped with logging so that the diagnostics is possible without requiring a debugger or OTel collector.
|
||||
[x] Add OpenTelemetry to the solution. Wire it in extensively without compromising performance.
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the unit tests should be as extensive as possible and cover false positive also. (could be imlcuded already
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
public sealed record GuiNotification(
|
||||
string Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyDictionary<string, object?> Context);
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
[Authorize]
|
||||
public sealed class NotificationHub : Hub
|
||||
{
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
||||
public static readonly Counter<long> Mt940RowsParsed =
|
||||
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
||||
public static readonly Counter<long> BankingEntriesSkipped =
|
||||
Meter.CreateCounter<long>("fuchs.banking.entries.skipped", "{entry}",
|
||||
"Number of bank statement entries/statements dropped during parsing, tagged by reason.");
|
||||
public static readonly Counter<long> BankingFieldsTruncated =
|
||||
Meter.CreateCounter<long>("fuchs.banking.fields.truncated", "{field}",
|
||||
"Number of parsed fields truncated to fit the destination column width.");
|
||||
public static readonly Counter<long> MfrCalls =
|
||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||
public static readonly Counter<long> BlobUploadsSucceeded =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
@@ -52,6 +53,7 @@ public class Program
|
||||
|
||||
// MVC with Razor view support
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Fuchs intranet singleton
|
||||
builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance);
|
||||
@@ -96,6 +98,7 @@ public class Program
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||
@@ -165,6 +168,7 @@ public class Program
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
|
||||
// Intranet routes (root-level — this IS the website)
|
||||
app.MapControllerRoute(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using CAMTParser;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -35,6 +36,7 @@ public class BankingService : IBankingService
|
||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
var diag = new ParseDiagnostics();
|
||||
|
||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
||||
byte[] bytes;
|
||||
@@ -48,46 +50,125 @@ public class BankingService : IBankingService
|
||||
if (CamtParser.LooksLikeZip(bytes))
|
||||
{
|
||||
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."); }
|
||||
}
|
||||
else if (CamtParser.LooksLikeXml(bytes))
|
||||
{
|
||||
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."); }
|
||||
}
|
||||
else
|
||||
{
|
||||
format = "mt940";
|
||||
using var msMt = new MemoryStream(bytes);
|
||||
FillFromMt940(tbl, msMt);
|
||||
FillFromMt940(tbl, msMt, diag);
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
sw.Stop();
|
||||
|
||||
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.rows", tbl.Rows.Count);
|
||||
_logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms",
|
||||
format, tbl.Rows.Count, sw.ElapsedMilliseconds);
|
||||
act?.SetTag("fuchs.banking.statements_skipped_no_account", diag.StatementsSkippedNoAccount);
|
||||
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;
|
||||
}
|
||||
|
||||
// ── MT940 ─────────────────────────────────────────────────────────────────
|
||||
private void FillFromMt940(DataTable tbl, Stream stream)
|
||||
/// <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 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);
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -128,7 +209,13 @@ public class BankingService : IBankingService
|
||||
|
||||
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) ───────────────────────────────────────────────────────
|
||||
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)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag);
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -155,7 +246,10 @@ public class BankingService : IBankingService
|
||||
if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
|
||||
if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
|
||||
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, "BankReference", e.BankReference);
|
||||
SetNfo(nr, "EndToEndReference", e.EndToEndReference);
|
||||
@@ -175,7 +269,13 @@ public class BankingService : IBankingService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,14 +24,16 @@ public class InvoiceService : IInvoiceService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IEventService _events;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<InvoiceService> logger)
|
||||
IEventService events, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -150,6 +153,7 @@ public class InvoiceService : IInvoiceService
|
||||
string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
|
||||
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
|
||||
await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration);
|
||||
await _events.InvoiceFileCreatedAsync(invoice, fileName, userAccountId);
|
||||
return ba;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
||||
"email_signature", "sanitaerfuchs_email_signature.txt");
|
||||
if (File.Exists(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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,14 +24,16 @@ public class ReminderService : IReminderService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IEventService _events;
|
||||
private readonly ILogger<ReminderService> _logger;
|
||||
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<ReminderService> logger)
|
||||
IEventService events, ILogger<ReminderService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -153,6 +156,7 @@ public class ReminderService : IReminderService
|
||||
string fileName = reminder.ReminderRegistration?.getString("DocumentName")
|
||||
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
|
||||
await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration);
|
||||
await _events.ReminderFileCreatedAsync(reminder, fileName, userAccountId);
|
||||
return ba;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.Data.SqlClient
|
||||
@using Newtonsoft.Json
|
||||
@inject IConfiguration Configuration
|
||||
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment HostEnvironment
|
||||
@{
|
||||
bool isAuth = User.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
@@ -16,12 +19,26 @@
|
||||
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
||||
string fullName = ViewData["FullName"] as string ?? "";
|
||||
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>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
<script src="~/web/tools.js" asp-append-version="true"></script>
|
||||
|
||||
@@ -92,6 +109,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="notification_frame"></div>
|
||||
@await RenderSectionAsync("BodyFooter", required: false)
|
||||
</footer>
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"outputFileName": "wwwroot/web/fis.min.js",
|
||||
"inputFiles": [
|
||||
"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_val_de.js",
|
||||
"web/loadcss/loadCSS.js",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@@ -218,14 +219,21 @@ public class FuchsUserIdentity
|
||||
// --------------------------- SQL options -------------------------------------
|
||||
|
||||
/// <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>
|
||||
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) =>
|
||||
{
|
||||
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); }
|
||||
catch { }
|
||||
};
|
||||
|
||||
@@ -29,6 +29,61 @@ main nav ul > li a[role=button] {
|
||||
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 {
|
||||
background-color: #FFF;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
@@ -228,4 +228,45 @@ $fis.ov = function () {
|
||||
});
|
||||
}, 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,3 +1,4 @@
|
||||
$(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.ov();
|
||||
});
|
||||
|
||||
Generated
+315
@@ -8,6 +8,7 @@
|
||||
"name": "fuchs",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"fg-loadcss": "3.1.0",
|
||||
"jquery": "4.0.0",
|
||||
"js-cookie": "3.0.1",
|
||||
@@ -51,6 +52,19 @@
|
||||
"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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -426,6 +440,18 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
|
||||
@@ -1026,6 +1052,15 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
@@ -1036,6 +1071,15 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
@@ -1150,6 +1194,16 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
|
||||
@@ -2457,6 +2511,26 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -2677,6 +2751,33 @@
|
||||
"dev": 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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2789,6 +2890,12 @@
|
||||
"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": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -2966,6 +3073,12 @@
|
||||
"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": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
@@ -3281,6 +3394,27 @@
|
||||
"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": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -3367,12 +3501,31 @@
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
|
||||
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3588,6 +3741,22 @@
|
||||
"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": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
@@ -3641,6 +3810,27 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
|
||||
@@ -3706,6 +3896,18 @@
|
||||
"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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3864,6 +4066,14 @@
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
|
||||
@@ -4296,6 +4506,11 @@
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
@@ -4305,6 +4520,11 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
@@ -4396,6 +4616,15 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
|
||||
@@ -5356,6 +5585,14 @@
|
||||
"dev": 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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -5510,6 +5747,24 @@
|
||||
"dev": 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": {
|
||||
"version": "1.2.3",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -5697,6 +5957,11 @@
|
||||
"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": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
@@ -5946,6 +6211,22 @@
|
||||
"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": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -6006,12 +6287,26 @@
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
|
||||
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -6180,6 +6475,20 @@
|
||||
"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": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
@@ -6217,6 +6526,12 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
{
|
||||
"name": "fuchs",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"fg-loadcss": "3.1.0",
|
||||
"jquery": "4.0.0",
|
||||
"js-cookie": "3.0.1",
|
||||
|
||||
@@ -2230,6 +2230,56 @@ main nav ul > li a[role=button]:hover::after, main nav ul > li a[role=button].fb
|
||||
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 {
|
||||
background-color: #FFF;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+42
-14
@@ -13,15 +13,26 @@ using static OCORE.commons;
|
||||
|
||||
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
|
||||
{
|
||||
private Dictionary<string, object>? _baseInfo;
|
||||
|
||||
public FdsSqlOptions(Dictionary<string, object>? dic = null)
|
||||
public FdsSqlOptions(Dictionary<string, object>? dic = null, ILogger? logger = null)
|
||||
{
|
||||
_baseInfo = dic;
|
||||
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}");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +99,7 @@ public class FdsMfr : IFdsMfr
|
||||
if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - start", filename: "DebugDetail.txt");
|
||||
var dtbl = await getSQLDatatable_async(
|
||||
"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)
|
||||
{
|
||||
var rows = dtbl.DataTable.Rows.Cast<DataRow>()
|
||||
@@ -115,7 +126,7 @@ public class FdsMfr : IFdsMfr
|
||||
SQL_VarChar("@Id", r.id),
|
||||
SQL_VarChar("@filename", r.docName),
|
||||
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
|
||||
options: new FdsSqlOptions());
|
||||
options: new FdsSqlOptions(logger: _logger));
|
||||
Interlocked.Increment(ref downloaded);
|
||||
}
|
||||
}
|
||||
@@ -142,7 +153,7 @@ public class FdsMfr : IFdsMfr
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@reportid", reportid) };
|
||||
var sqldt = Task.Run(async () => await getSQLDatatable_async(
|
||||
"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)
|
||||
{
|
||||
@@ -166,7 +177,7 @@ public class FdsMfr : IFdsMfr
|
||||
SQL_VarChar("@Id", reportid),
|
||||
SQL_VarChar("@filename", fln),
|
||||
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); }
|
||||
}
|
||||
@@ -197,7 +208,7 @@ public class FdsMfr : IFdsMfr
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@type", type), SQL_VarChar("@reportid", reportid) };
|
||||
var sqldt = Task.Run(async () => await getSQLDatatable_async(
|
||||
"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)
|
||||
{
|
||||
@@ -236,14 +247,14 @@ public class FdsMfr : IFdsMfr
|
||||
SQL_Bit("@files", includeFiles), SQL_VarChar("@authuser", authUser)
|
||||
},
|
||||
tablenames: new[] { "admin", "inv", "buchungen", "debitoren" },
|
||||
options: new FdsSqlOptions())).Result;
|
||||
options: new FdsSqlOptions(logger: _logger))).Result;
|
||||
|
||||
var bediFiles = new List<DatevDocument>();
|
||||
|
||||
if (dset.Count >= 4)
|
||||
{
|
||||
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") : "");
|
||||
var fls = new Dictionary<string, byte[]>();
|
||||
|
||||
@@ -251,10 +262,10 @@ public class FdsMfr : IFdsMfr
|
||||
{
|
||||
Formatkategorie = DatevFormatkategorie.Buchungsstapel,
|
||||
Formatversion = DatevFormatversion.Buchungsstapel_9,
|
||||
Beraternummer = (int)admin["beraternummer"],
|
||||
Mandantennummer = (int)admin["mandantennummer"],
|
||||
WJBeginn = (DateTime)admin["WJ-Beginn"],
|
||||
Sachkontenlänge = (int)admin["Sachkontenlänge"],
|
||||
Beraternummer = (int)admin["beraternummer"]!,
|
||||
Mandantennummer = (int)admin["mandantennummer"]!,
|
||||
WJBeginn = (DateTime)admin["WJ-Beginn"]!,
|
||||
Sachkontenlänge = (int)admin["Sachkontenlänge"]!,
|
||||
DatumVon = startdate,
|
||||
DatumBis = enddate,
|
||||
Bezeichnung = "fds_" + mode + tgtdate.ToString("yyMMdd")
|
||||
@@ -284,7 +295,14 @@ public class FdsMfr : IFdsMfr
|
||||
else
|
||||
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))
|
||||
{
|
||||
fls.Add(fln, fl);
|
||||
@@ -308,8 +326,18 @@ public class FdsMfr : IFdsMfr
|
||||
stream!.Position = 0;
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public class FdsMfrClient : IDisposable
|
||||
public Dictionary<string, Dictionary<string, string>> NavProperties { get; } = new();
|
||||
private readonly DataSet _tableSet;
|
||||
|
||||
public DatabaseSchema(EntityTypes et)
|
||||
public DatabaseSchema(EntityTypes et, ILogger? logger = null)
|
||||
{
|
||||
_et = et;
|
||||
var dset = Task.Run(async () => await getSQLDataSet_async(
|
||||
@@ -72,7 +72,7 @@ public class FdsMfrClient : IDisposable
|
||||
FdsShared.FDSConnectionString(),
|
||||
SqlParameterList: new List<SqlParameter> { new("@tgttype", ThisEntityName) },
|
||||
tablenames: new[] { "entity", "complex_types", "navigation_properties", "tables" },
|
||||
options: new FdsSqlOptions())).Result;
|
||||
options: new FdsSqlOptions(logger: logger))).Result;
|
||||
|
||||
IsValid = dset.Count > 0;
|
||||
HasEntity = dset.Tables("entity").Rows.Count == 1;
|
||||
@@ -98,7 +98,7 @@ public class FdsMfrClient : IDisposable
|
||||
string.Join(Environment.NewLine, sqlList),
|
||||
FdsShared.FDSConnectionString(),
|
||||
tablenames: tableNames.ToArray(),
|
||||
options: new FdsSqlOptions())).Result.DataSet;
|
||||
options: new FdsSqlOptions(logger: logger))).Result.DataSet;
|
||||
}
|
||||
|
||||
public DataSet TgtDataset(string setId)
|
||||
@@ -147,7 +147,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
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)
|
||||
{
|
||||
dlg("Schema not found", "", "");
|
||||
@@ -179,7 +179,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
var lastDate = await getSQLValue_async(
|
||||
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(
|
||||
updateNeed == FdsMfr.UpdateNeed.Short && lastDate.Result is DateTime dt
|
||||
? $"$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]()"
|
||||
+ (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);
|
||||
|
||||
if (updateableTables.Count > 0 && updateableTables.DataTable.Columns.Contains("updateneed"))
|
||||
@@ -446,7 +446,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
var updateableRequests = await getSQLDatatable_async(
|
||||
"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);
|
||||
|
||||
if (updateableRequests.Count > 0)
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace fds;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public static class FdsConfig
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace fds;
|
||||
|
||||
public class Archive : IDisposable
|
||||
{
|
||||
public event Action? Saving;
|
||||
public event Action? FileSaved;
|
||||
public event Action? FileStreamCreated;
|
||||
|
||||
@@ -140,8 +139,12 @@ public class Archive : IDisposable
|
||||
_zipIn = null;
|
||||
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;
|
||||
}
|
||||
archiveFile.Refresh();
|
||||
@@ -179,8 +182,11 @@ public class Archive : IDisposable
|
||||
_zipIn = null;
|
||||
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;
|
||||
}
|
||||
return ExitOK;
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>1591</NoWarn>
|
||||
<!-- NU1608: transitive AngleSharp.Css/AngleSharp conflict inherited from OCORE_web -->
|
||||
<NoWarn>1591;NU1608</NoWarn>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>1591</NoWarn>
|
||||
<!-- NU1608: transitive AngleSharp.Css/AngleSharp conflict inherited from OCORE_web -->
|
||||
<NoWarn>1591;NU1608</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<name>Fuchs_DataService</name>
|
||||
</assembly>
|
||||
<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">
|
||||
<summary>Max parallel invoice-file downloads (independent per file).</summary>
|
||||
</member>
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
<Project Path="CAMTParser/CAMTParser.csproj">
|
||||
<BuildType Solution="db-dev.processweb.de|*" Project="Release" />
|
||||
<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 Path="Fuchs.Tests/Fuchs.Tests.csproj">
|
||||
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
|
||||
|
||||
@@ -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
+1
-1
Submodule OCORE_Charting updated: ff19fc3494...696d93abef
+1
-1
Submodule OCORE_web updated: a6593b2a64...678db212ab
+1
-1
Submodule OCORE_web_pdf updated: a94ddf90c3...cd8214ed7b
Reference in New Issue
Block a user