Compare commits
26 Commits
cc2abc91d6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ecf97ed29 | |||
| ae5c90b915 | |||
| 7653b2f0b5 | |||
| bfc695ed6a | |||
| ebdb92713a | |||
| c358fdbdb2 | |||
| 2c17171e77 | |||
| 2a75664625 | |||
| 27becf7c68 | |||
| a00ec1da3b | |||
| 10ecdfa2e4 | |||
| 1376779224 | |||
| 7ee4e5302a | |||
| e04d590c3a | |||
| 8dee630abb | |||
| c81619fa53 | |||
| c8a4d18f1a | |||
| dbe6cd8653 | |||
| 8f8d462045 | |||
| 9c0bf76a05 | |||
| 2d65e34500 | |||
| 1ce497b37e | |||
| aceef9ff74 | |||
| 445fc2b858 | |||
| 9dcbf0d958 | |||
| b17baca835 |
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
> 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 `/`.
|
||||
@@ -30,6 +37,35 @@
|
||||
- Make use of OCORE libraries where possible, especially for common tasks such as logging, configuration management, and data access.
|
||||
- Whenever possible, prefer OCORE_web_pdf / OCORE PDF functions for PDF-related tasks over rewriting.
|
||||
- Do not use OCMS or OCMS_sharp; use only OCORE or OCORE_web.
|
||||
- For builds failing due to SixLabors.ImageSharp requiring a license (v4.0.0+), check copilot-instructions.md for the SixLabors license key/handling info before downgrading ImageSharp.
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
|
||||
## MFR ERP integration
|
||||
- `MFR_RESTClient` talks to the **mfr (Mobile Field Report)** ERP over REST/OData. Its contract (base URLs, auth, OData conventions, pagination, error/retry, deep-create + document-upload) is documented in **`MFR_RESTClient/Docs/mfr_interface_description.md`** — **read it before changing the client**.
|
||||
- The client uses HTTP Basic auth, a configurable timeout, and retries idempotent GETs on transient errors (429/5xx, network/timeout) with backoff. Create clients via `IMfrClientFactory` (don't `new` them). The legacy VB project files have been removed; the active project is `MFR_RESTClient.csproj`.
|
||||
|
||||
## Database
|
||||
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
|
||||
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
|
||||
|
||||
## Bank statement parsing (MT940 + CAMT)
|
||||
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
|
||||
- `BankingService.ParseToDatatable` **auto-detects** the format (XML → CAMT, else MT940) and maps both into the `fds__tt__bankingtransactions` schema. The `bam/up` handler and the frontend file picker accept both (`.sta/.mt940/.txt` and `.xml/.camt`).
|
||||
- `CAMTParser` is namespace-agnostic (matches elements by local name) so it handles every camt schema version. Keep both parsers' column mappings aligned when changing the banking schema.
|
||||
|
||||
## Observability
|
||||
- Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`).
|
||||
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages.
|
||||
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`). DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
||||
|
||||
## Azure Key Vault — Secret Naming
|
||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
applyTo: "Fuchs/**,Fuchs_DataService/**"
|
||||
---
|
||||
|
||||
# Configuration & Secrets Instructions
|
||||
|
||||
## Settings Source
|
||||
- All application settings live in `Fuchs/appsettings.json`. **Never** use `Web.config` or `System.Configuration.ConfigurationManager` for app settings.
|
||||
- App-specific settings are nested under the `"Fuchs"` key, e.g. `_config["Fuchs:SMS_APIKey"]`.
|
||||
- Connection strings live under the standard `"ConnectionStrings"` key and are read via `IConfiguration.GetConnectionString(...)`.
|
||||
- `appsettings.Development.json` (git-ignored) overrides secrets for local development.
|
||||
|
||||
## Startup Order
|
||||
- Call `FuchsOcmsIntranet.Initialize(configuration)` at app start in `Program.cs` **before** DI registration.
|
||||
- `Fuchs_intranet` receives `IConfiguration` via its constructor — inject it, never read config statically.
|
||||
|
||||
## Azure Key Vault — Secret Naming
|
||||
Secret names must satisfy `^[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`.
|
||||
- Underscores within a name segment are encoded as a single `-` in Key Vault and decoded back to `_` when the key is reconstructed.
|
||||
- The app prefix `fuchs` is prepended to every secret name.
|
||||
- Format: `{appname}--{Section}--{key-with-hyphens-for-underscores}`
|
||||
|
||||
### Examples
|
||||
| Key Vault name | `IConfiguration` key |
|
||||
|----------------|----------------------|
|
||||
| `fuchs--ConnectionStrings--ocms-ConnectionString` | `ConnectionStrings:ocms_ConnectionString` |
|
||||
| `fuchs--Fuchs--SMS-APIKey` | `Fuchs:SMS_APIKey` |
|
||||
| `fuchs--Fuchs--Email--Main--password` | `Fuchs:Email:Main:password` |
|
||||
|
||||
## Adding a New Secret
|
||||
1. Replace every `_` in the original config key with `-` for the Key Vault name.
|
||||
2. Add the entry to `ManagedSecretKeys` in `appsettings.json` using the same hyphenated form **without** the `fuchs--` prefix.
|
||||
3. Read it through `IConfiguration` with the underscore form (`Fuchs:SMS_APIKey`).
|
||||
|
||||
## Secret Management Wiring
|
||||
- Secret management is provided by `OCORE_web.Secrets.SecretManagementWebExtensions.AddSecretManagement(...)` (called in `Program.cs`).
|
||||
- Do **not** create a local `SecretManagementExtensions` stub in Fuchs — it collides with the OCORE_web extension and causes ambiguous extension-method resolution.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
applyTo: "Fuchs/Controllers/**,Fuchs/code/**"
|
||||
---
|
||||
|
||||
# IntranetController Instructions
|
||||
|
||||
## Overview
|
||||
The Fuchs intranet **is** the entire website, served from `/`. There is a single MVC controller, `IntranetController`, split into **partial files** by domain. There are no areas.
|
||||
|
||||
## Routing
|
||||
| Route | Action | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/{fn?}/{id?}/{code?}` | `Index` | Returns the SPA shell view `intranet`. `[AllowAnonymous]`. |
|
||||
| `/do/{fn?}/{id?}/{code?}` | `Do` | Central API dispatcher (GET + POST). `[AllowAnonymous]`, gated internally. |
|
||||
|
||||
## Partial File Layout
|
||||
Each partial lives in `Fuchs/Controllers/` and maps to a VB original under `Fuchs/code/`:
|
||||
|
||||
| Partial | Domain | Dispatch entry |
|
||||
|---------|--------|----------------|
|
||||
| `IntranetController.cs` | Core: routing, auth, login/logout, account, MFR | `Do(...)` switch |
|
||||
| `IntranetController.Invoices.cs` / `.Invoices2.cs` | Invoices | `Do_Process_Invoices` |
|
||||
| `IntranetController.Reminder.cs` | Reminders | `Do_Process_Reminder` |
|
||||
| `IntranetController.Requests.cs` | Requests | `Do_Process_Requests` |
|
||||
| `IntranetController.Reports.cs` | Reports | `Do_Process_Reports` |
|
||||
| `IntranetController.Banking.cs` | Banking (MT940) | `Do_Process_Bankings` |
|
||||
|
||||
## Dispatcher Pattern
|
||||
- `Do(...)` normalizes `fn`/`id`/`code` (lowercase `fn`, null-coalesce others) then routes via a `switch` expression to `Do_Process_*` helpers or inline `Handle*` methods.
|
||||
- Each domain handler is itself a `switch` on `id` (the sub-function) returning an `IActionResult`.
|
||||
- Wrap the whole dispatch in a single `try/catch`; log via `_intranet.debug_log(...)` and return `ServerError()` on unhandled exceptions. Do not add per-case try/catch unless a case needs special recovery.
|
||||
|
||||
## Authentication Gate
|
||||
- The unauthenticated allow-list logic in `Do(...)` must keep its braces: unauthenticated users are rejected with `Unauthorized401()` **only** when the function is not in `_allowedNonAuth`, not `login`/`logout`, and not in `_allowedGet` (checked as both `fn` and `fn|id`).
|
||||
- Add new anonymous endpoints by extending `_allowedNonAuth` (full-anonymous) or `_allowedGet` (read-only GET links), never by removing the gate.
|
||||
|
||||
## Conventions
|
||||
- Use the `StdParamlist(...)` helpers to build `SqlParameter` lists; they pre-populate `@authuser` from `UserAccountID`.
|
||||
- Use `SqlOpt(fn, id, code)` to pass `FIS_SQLOptions` to OCORE SQL helpers.
|
||||
- Use `DbSec` (`_intranet.GetDbSecurity(UserAccountID)`) for the `Security:` argument on SQL calls.
|
||||
- Return JSON via the OCORE `JSONAsync(...)` helper, not `Json(...)`.
|
||||
- Use the status helpers `Unauthorized401()`, `BadRequest400()`, `ServerError(...)` rather than raw `StatusCode(...)`.
|
||||
- All controller actions that perform I/O must be `async Task<IActionResult>`.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
applyTo: "**/*.cs"
|
||||
---
|
||||
|
||||
# C# Coding Standards
|
||||
|
||||
## Language & Target
|
||||
- All code must be written in C# targeting **.NET 10**.
|
||||
- The original Fuchs intranet was VB.NET; any remaining VB must be converted to C# during migration. Original VB reference lives at `D:\My Programming\PWProjects\Fuchs\Fuchs\Areas\Intranet`.
|
||||
|
||||
## Style
|
||||
- Follow standard C# naming: **PascalCase** for classes and methods, **camelCase** for locals and parameters, `_camelCase` for private fields.
|
||||
- `ImplicitUsings` and `Nullable` are enabled in the Fuchs project — honor nullable annotations and avoid redundant `using` directives.
|
||||
- Prefer modern, performance-oriented features: `async`/`await` for I/O, LINQ for data manipulation, dependency injection for testability, `switch` expressions, target-typed `new`, and collection initializers.
|
||||
- Only add comments when they match the existing style or explain non-obvious logic. Do not over-comment.
|
||||
|
||||
## File Size
|
||||
- Keep files to a soft limit of **400** lines (hard max **600**).
|
||||
- Proactively refactor larger files into smaller, focused classes/partials — this is why `IntranetController` is split into domain partials.
|
||||
|
||||
## Dependency Injection
|
||||
- Inject dependencies via constructor (`ILogger<T>`, `IConfiguration`, services). Do not use service-locator or static singletons in new class-level code.
|
||||
- Library classes accept optional loggers (`ILogger<T>?`) defaulting to `NullLogger<T>.Instance` — see `logging.instructions.md`.
|
||||
|
||||
## Packages
|
||||
- Do not upgrade `Spire.PDF` beyond `8.10.5`.
|
||||
- For builds failing because `SixLabors.ImageSharp` (v4.0.0+) requires a license, see `imagesharp.instructions.md` before downgrading.
|
||||
- Keep `MailKit`/`MimeKit` versions aligned with OCORE's referenced versions to avoid `NU1605` package-downgrade-as-error.
|
||||
- Only add or update packages when necessary; prefer existing/OCORE libraries.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
applyTo: "Fuchs/**,OCORE/**,OCORE_web/**,OCORE_web_pdf/**"
|
||||
---
|
||||
|
||||
# SixLabors ImageSharp Instructions
|
||||
|
||||
## Licensing (v4.0.0+)
|
||||
- `SixLabors.ImageSharp` v4.0.0+ requires a license; builds fail validation without one.
|
||||
- **Do not downgrade ImageSharp** to avoid the license requirement.
|
||||
- Each project that references ImageSharp needs its own discoverable `sixlabors.lic` file in the project root.
|
||||
- Tracked license files exist for `OCORE`, `OCORE_web`, and `Fuchs`. Use `OCORE_web/OCORE_web/sixlabors.lic` as the canonical Community-license template.
|
||||
- Do **not** create untracked local duplicates of `sixlabors.lic` — an untracked copy blocks `git pull` when upstream adds a tracked one.
|
||||
|
||||
## License File Format
|
||||
A single-line Community license payload:
|
||||
```
|
||||
Id=...;Kind=Community;ExpiryDateUtc=...;Key=...
|
||||
```
|
||||
|
||||
## v4.0.0 API / Namespace Changes
|
||||
When updating or fixing ImageSharp-related code, account for these breaking moves:
|
||||
|
||||
| Removed / old | Use instead |
|
||||
|---------------|-------------|
|
||||
| `using SixLabors.ImageSharp.ColorSpaces;` | Removed — delete if unused |
|
||||
| `using SixLabors.ImageSharp.Web.DependencyInjection;` | `using SixLabors.ImageSharp.Web;` |
|
||||
|
||||
- `AddImageSharp(...)` / `UseImageSharp()` resolve from `SixLabors.ImageSharp.Web` (plus `.Commands` and `.Processors` for related types).
|
||||
- After any ImageSharp version change, rebuild and confirm middleware bootstrap in `OCORE_web/OCORE_web/web/OCORE_appbuilder.cs` still compiles.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
applyTo: "Fuchs/**,Fuchs_DataService/**,OCORE/**,OCORE_web/**,OCORE_web_pdf/**"
|
||||
---
|
||||
|
||||
# OCORE Libraries Instructions
|
||||
|
||||
## Preferred Libraries
|
||||
- Make use of **OCORE** / **OCORE_web** libraries wherever possible for common tasks: logging, configuration, data access, web helpers, and security.
|
||||
- For PDF-related work, prefer **OCORE_web_pdf** / OCORE PDF functions over rewriting from scratch.
|
||||
- **Do not use OCMS or OCMS_sharp.** Use only OCORE or OCORE_web. In particular, never use `OCMS.ocms_debug.debug_log`.
|
||||
|
||||
## Common OCORE Entry Points
|
||||
| Area | Namespace / helper |
|
||||
|------|--------------------|
|
||||
| SQL access | `OCORE.SQL.sql` (`getSQLDatatable_async`, `getSQLValue_async`, `SQL_VarChar`, ...) |
|
||||
| Async MVC/JSON | `OCORE.web.mvc_helper_async` (`JSONAsync`, ...) |
|
||||
| Security | `OCORE.security` (`DatabaseSecurity`, ...) |
|
||||
| Dictionaries/commons | `OCORE.commons`, `OCORE.OCORE_dictionaries` |
|
||||
| Web bootstrapping | `OCORE_web` app-builder / middleware helpers |
|
||||
| Secret management | `OCORE_web.Secrets.SecretManagementWebExtensions` |
|
||||
|
||||
## SQL Helper Conventions
|
||||
- Pass parameters using `SQL_VarChar(...)` and the controller `StdParamlist(...)` helpers (auto-injects `@authuser`).
|
||||
- Always pass `Security: DbSec` and `options: SqlOpt(fn, id, code)` to the OCORE SQL helpers.
|
||||
- Prefer the `_async` variants for all database I/O.
|
||||
|
||||
## Repository Sync
|
||||
- OCORE, OCORE_web, OCORE_web_pdf, and OCORE_Charting are separate git repositories pulled alongside Fuchs.
|
||||
- After pulling OCORE* updates, rebuild and re-run tests, and re-check shared dependency versions (e.g., MailKit/MimeKit) for `NU1605` downgrade conflicts.
|
||||
|
||||
## ImageSharp (used transitively by OCORE/OCORE_web)
|
||||
- See `imagesharp.instructions.md` for license handling and the v4.0.0 namespace changes.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
applyTo: "Fuchs.Tests/**,**/*Tests/**,**/*Tests.cs"
|
||||
---
|
||||
|
||||
# Testing Instructions
|
||||
|
||||
## Framework
|
||||
- Tests use **xUnit** on **.NET 10** (e.g., `Fuchs.Tests`).
|
||||
- Run via Visual Studio Test Explorer or `dotnet test`.
|
||||
|
||||
## Conventions
|
||||
- One test class per unit under test; name it `<TypeUnderTest>Tests`.
|
||||
- Name test methods `Method_Scenario_ExpectedResult` (e.g., `ReadBalance_EmptyString_ThrowsInvalidDataException`).
|
||||
- Use `[Theory]` + `[InlineData]` for parameterized cases; `[Fact]` for single cases.
|
||||
- Arrange / Act / Assert structure; keep tests deterministic and independent (no shared mutable state, no real network/DB calls).
|
||||
|
||||
## What to Cover
|
||||
- Pure parsing/algorithmic helpers (MT940 parsing, date/decimal parsing, HTML cleanup, OData envelope handling, entity helpers) — these are the highest-value, side-effect-free targets.
|
||||
- For library classes that accept `ILogger<T>?`, pass `NullLogger<T>.Instance` (or a test logger) in tests.
|
||||
|
||||
## Workflow
|
||||
- After any code change, build the full solution and run the affected test project before concluding.
|
||||
- Treat a green run (e.g., `Fuchs.Tests` all passing) as the validation gate; never leave the suite red.
|
||||
- When fixing a bug, add or update a test that reproduces it where practical.
|
||||
@@ -7,6 +7,13 @@
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# SSDT / SQL database project caches (regenerated)
|
||||
*.dbmdl
|
||||
*.jfm
|
||||
|
||||
# Generated XML documentation output (regenerated on build)
|
||||
MFR_RESTClient/MFR_RESTClient.xml
|
||||
|
||||
# NuGet
|
||||
packages/
|
||||
*.nupkg
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
[submodule "OCORE"]
|
||||
path = OCORE
|
||||
url = https://git.processweb.de/Stefan/OCORE.git
|
||||
[submodule "OCORE_web"]
|
||||
path = OCORE_web
|
||||
url = https://git.processweb.de/Stefan/OCORE_web.git
|
||||
[submodule "OCORE_web_pdf"]
|
||||
path = OCORE_web_pdf
|
||||
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
|
||||
[submodule "OCORE_Charting"]
|
||||
path = OCORE_Charting
|
||||
url = https://git.processweb.de/Stefan/OCORE_Charting.git
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>CAMTParser</RootNamespace>
|
||||
<AssemblyName>CAMTParser</AssemblyName>
|
||||
<Description>ISO 20022 CAMT (camt.052 / camt.053 / camt.054) bank statement parser — a variant of the MT940Parser producing an equivalent statement/entry model.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace CAMTParser;
|
||||
|
||||
/// <summary>The CAMT message flavour a document was recognised as.</summary>
|
||||
public enum CamtDocumentType
|
||||
{
|
||||
Unknown = 0,
|
||||
/// <summary>camt.052 — Bank to Customer Account Report (intraday).</summary>
|
||||
Camt052,
|
||||
/// <summary>camt.053 — Bank to Customer Statement (end of day).</summary>
|
||||
Camt053,
|
||||
/// <summary>camt.054 — Bank to Customer Debit/Credit Notification.</summary>
|
||||
Camt054
|
||||
}
|
||||
|
||||
/// <summary>Debit/credit indicator, mirroring the MT940 parser's marks.</summary>
|
||||
public enum CamtDebitCreditMark
|
||||
{
|
||||
Credit,
|
||||
Debit,
|
||||
ReverseCredit,
|
||||
ReverseDebit
|
||||
}
|
||||
|
||||
/// <summary>A parsed CAMT account statement/report/notification.</summary>
|
||||
public sealed class CamtStatement
|
||||
{
|
||||
/// <summary>Account IBAN (or other account id when IBAN is absent).</summary>
|
||||
public string AccountIdentification { get; set; } = "";
|
||||
/// <summary>Statement currency (from the account or balances), when available.</summary>
|
||||
public string? Currency { get; set; }
|
||||
public CamtDocumentType DocumentType { get; set; } = CamtDocumentType.Unknown;
|
||||
public List<CamtEntry> Entries { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single booked transaction line. Field names are aligned with the
|
||||
/// banking DataTable columns so the host can map both CAMT and MT940 uniformly.
|
||||
/// </summary>
|
||||
public sealed class CamtEntry
|
||||
{
|
||||
public decimal? Amount { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public CamtDebitCreditMark Mark { get; set; }
|
||||
public DateTime? EntryDate { get; set; }
|
||||
public DateTime? ValueDate { get; set; }
|
||||
|
||||
public string? BankReference { get; set; }
|
||||
public string? EndToEndReference { get; set; }
|
||||
public string? MandateReference { get; set; }
|
||||
public string? CustomerReference { get; set; }
|
||||
public string? CreditorReference { get; set; }
|
||||
|
||||
/// <summary>Counterparty (payer for credits, payee for debits) display name.</summary>
|
||||
public string? CounterpartyName { get; set; }
|
||||
/// <summary>Counterparty IBAN, when present.</summary>
|
||||
public string? CounterpartyIban { get; set; }
|
||||
/// <summary>Counterparty agent BIC, when present.</summary>
|
||||
public string? CounterpartyBic { get; set; }
|
||||
|
||||
/// <summary>Unstructured remittance information (joined RmtInf/Ustrd).</summary>
|
||||
public string? RemittanceUnstructured { get; set; }
|
||||
/// <summary>Structured remittance information (RmtInf/Strd), flattened to text.</summary>
|
||||
public string? RemittanceStructured { get; set; }
|
||||
/// <summary>Additional entry information (AddtlNtryInf).</summary>
|
||||
public string? AdditionalInfo { get; set; }
|
||||
/// <summary>Bank transaction code (Domn/Fmly/SubFmly or Prtry), as text.</summary>
|
||||
public string? BankTransactionCode { get; set; }
|
||||
|
||||
/// <summary>True when only unstructured remittance information is present.</summary>
|
||||
public bool IsUnstructuredData =>
|
||||
!string.IsNullOrEmpty(RemittanceUnstructured) && string.IsNullOrEmpty(RemittanceStructured);
|
||||
|
||||
public string MarkAbbreviation => Mark switch
|
||||
{
|
||||
CamtDebitCreditMark.Credit => "C",
|
||||
CamtDebitCreditMark.Debit => "D",
|
||||
CamtDebitCreditMark.ReverseCredit => "RC",
|
||||
CamtDebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace CAMTParser;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for ISO 20022 CAMT bank statements (camt.052 / camt.053 / camt.054).
|
||||
/// A variant of the MT940 <c>Parser</c> producing an equivalent statement/entry
|
||||
/// model. Element lookups are namespace-agnostic (matched by local name), so the
|
||||
/// same code handles every camt schema version (…001.02, …001.08, etc.).
|
||||
/// </summary>
|
||||
public sealed class CamtParser
|
||||
{
|
||||
/// <summary>Cheap content sniff: is this payload XML (and therefore a CAMT candidate)?</summary>
|
||||
public static bool LooksLikeXml(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
int i = 0;
|
||||
// skip UTF-8 BOM
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) i = 3;
|
||||
for (; i < bytes.Length; i++)
|
||||
{
|
||||
byte b = bytes[i];
|
||||
if (b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or 0xFF or 0xFE) continue;
|
||||
return b == (byte)'<';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Heuristic: does the XML look like a CAMT document?</summary>
|
||||
public static bool LooksLikeCamt(string xml) =>
|
||||
xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
|
||||
xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public List<CamtStatement> Parse(Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
return Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
public List<CamtStatement> Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
|
||||
|
||||
public List<CamtStatement> Parse(string xml)
|
||||
{
|
||||
var result = new List<CamtStatement>();
|
||||
if (string.IsNullOrWhiteSpace(xml)) return result;
|
||||
|
||||
XDocument doc;
|
||||
try { doc = XDocument.Parse(xml); }
|
||||
catch (System.Xml.XmlException ex) { throw new FormatException("Invalid CAMT XML.", ex); }
|
||||
|
||||
var root = doc.Root;
|
||||
if (root is null) return result;
|
||||
|
||||
var docType = DetectType(xml, root);
|
||||
|
||||
// Stmt (053) / Rpt (052) / Ntfctn (054)
|
||||
foreach (var stmtEl in root.DescendantsLocal("Stmt")
|
||||
.Concat(root.DescendantsLocal("Rpt"))
|
||||
.Concat(root.DescendantsLocal("Ntfctn")))
|
||||
{
|
||||
var stmt = new CamtStatement { DocumentType = docType };
|
||||
var acct = stmtEl.ChildLocal("Acct");
|
||||
stmt.AccountIdentification = AccountId(acct);
|
||||
stmt.Currency = acct?.ChildLocal("Ccy")?.Value?.Trim();
|
||||
|
||||
foreach (var ntry in stmtEl.ChildrenLocal("Ntry"))
|
||||
ParseEntry(ntry, stmt);
|
||||
|
||||
result.Add(stmt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CamtDocumentType DetectType(string xml, XElement root)
|
||||
{
|
||||
string probe = xml.Length > 4000 ? xml[..4000] : xml;
|
||||
if (probe.Contains("camt.052", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrAcctRpt").Any()) return CamtDocumentType.Camt052;
|
||||
if (probe.Contains("camt.054", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrDbtCdtNtfctn").Any()) return CamtDocumentType.Camt054;
|
||||
if (probe.Contains("camt.053", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrStmt").Any()) return CamtDocumentType.Camt053;
|
||||
return CamtDocumentType.Unknown;
|
||||
}
|
||||
|
||||
private static void ParseEntry(XElement ntry, CamtStatement stmt)
|
||||
{
|
||||
bool reversal = string.Equals(ntry.ChildLocal("RvslInd")?.Value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
string? ntryCdtDbt = ntry.ChildLocal("CdtDbtInd")?.Value?.Trim();
|
||||
DateTime? entryDate = ParseDate(ntry.ChildLocal("BookgDt"));
|
||||
DateTime? valueDate = ParseDate(ntry.ChildLocal("ValDt"));
|
||||
string? bankRef = ntry.ChildLocal("NtryRef")?.Value?.Trim()
|
||||
?? ntry.ChildLocal("AcctSvcrRef")?.Value?.Trim();
|
||||
string? addtlInfo = ntry.ChildLocal("AddtlNtryInf")?.Value?.Trim();
|
||||
string? bkTxCd = BankTxCode(ntry.ChildLocal("BkTxCd"));
|
||||
var (ntryAmt, ntryCcy) = ParseAmount(ntry.ChildLocal("Amt"));
|
||||
|
||||
var txDetails = ntry.ChildLocal("NtryDtls")?.ChildrenLocal("TxDtls").ToList() ?? new List<XElement>();
|
||||
|
||||
if (txDetails.Count == 0)
|
||||
{
|
||||
// No transaction detail block — emit a single entry from the Ntry level.
|
||||
stmt.Entries.Add(new CamtEntry
|
||||
{
|
||||
Amount = ntryAmt,
|
||||
Currency = ntryCcy ?? stmt.Currency,
|
||||
Mark = MarkOf(ntryCdtDbt, reversal),
|
||||
EntryDate = entryDate,
|
||||
ValueDate = valueDate,
|
||||
BankReference = bankRef,
|
||||
AdditionalInfo = addtlInfo,
|
||||
BankTransactionCode = bkTxCd
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var tx in txDetails)
|
||||
{
|
||||
var (txAmt, txCcy) = ParseAmount(tx.ChildLocal("Amt"));
|
||||
string? cdtDbt = tx.ChildLocal("CdtDbtInd")?.Value?.Trim() ?? ntryCdtDbt;
|
||||
var mark = MarkOf(cdtDbt, reversal);
|
||||
bool isCredit = mark is CamtDebitCreditMark.Credit or CamtDebitCreditMark.ReverseCredit;
|
||||
|
||||
var refs = tx.ChildLocal("Refs");
|
||||
var rmtInf = tx.ChildLocal("RmtInf");
|
||||
var rltdPties = tx.ChildLocal("RltdPties");
|
||||
var rltdAgts = tx.ChildLocal("RltdAgts");
|
||||
|
||||
// Counterparty: payer (Dbtr) for incoming credits, payee (Cdtr) for outgoing debits.
|
||||
string partyTag = isCredit ? "Dbtr" : "Cdtr";
|
||||
string acctTag = isCredit ? "DbtrAcct" : "CdtrAcct";
|
||||
string agtTag = isCredit ? "DbtrAgt" : "CdtrAgt";
|
||||
|
||||
stmt.Entries.Add(new CamtEntry
|
||||
{
|
||||
Amount = txAmt ?? ntryAmt,
|
||||
Currency = txCcy ?? ntryCcy ?? stmt.Currency,
|
||||
Mark = mark,
|
||||
EntryDate = entryDate,
|
||||
ValueDate = valueDate,
|
||||
BankReference = bankRef,
|
||||
EndToEndReference = refs?.ChildLocal("EndToEndId")?.Value?.Trim().NullIfNa(),
|
||||
MandateReference = refs?.ChildLocal("MndtId")?.Value?.Trim(),
|
||||
CustomerReference = refs?.ChildLocal("InstrId")?.Value?.Trim()
|
||||
?? refs?.ChildLocal("AcctSvcrRef")?.Value?.Trim(),
|
||||
CreditorReference = rmtInf?.ChildLocal("Strd")?.ChildLocal("CdtrRefInf")?.ChildLocal("Ref")?.Value?.Trim(),
|
||||
CounterpartyName = rltdPties?.ChildLocal(partyTag)?.ChildLocal("Nm")?.Value?.Trim(),
|
||||
CounterpartyIban = rltdPties?.ChildLocal(acctTag)?.ChildLocal("Id")?.ChildLocal("IBAN")?.Value?.Trim(),
|
||||
CounterpartyBic = rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BICFI")?.Value?.Trim()
|
||||
?? rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BIC")?.Value?.Trim(),
|
||||
RemittanceUnstructured = JoinUstrd(rmtInf),
|
||||
RemittanceStructured = FlattenStrd(rmtInf?.ChildLocal("Strd")),
|
||||
AdditionalInfo = addtlInfo,
|
||||
BankTransactionCode = bkTxCd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──────────────────────────────────────────────────────
|
||||
private static CamtDebitCreditMark MarkOf(string? cdtDbtInd, bool reversal)
|
||||
{
|
||||
bool credit = string.Equals(cdtDbtInd, "CRDT", StringComparison.OrdinalIgnoreCase);
|
||||
if (reversal) return credit ? CamtDebitCreditMark.ReverseCredit : CamtDebitCreditMark.ReverseDebit;
|
||||
return credit ? CamtDebitCreditMark.Credit : CamtDebitCreditMark.Debit;
|
||||
}
|
||||
|
||||
private static (decimal? amount, string? ccy) ParseAmount(XElement? amt)
|
||||
{
|
||||
if (amt is null) return (null, null);
|
||||
string? ccy = amt.Attribute("Ccy")?.Value?.Trim();
|
||||
return decimal.TryParse(amt.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)
|
||||
? (d, ccy) : (null, ccy);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(XElement? dateContainer)
|
||||
{
|
||||
if (dateContainer is null) return null;
|
||||
string? raw = dateContainer.ChildLocal("Dt")?.Value?.Trim()
|
||||
?? dateContainer.ChildLocal("DtTm")?.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
private static string AccountId(XElement? acct)
|
||||
{
|
||||
var id = acct?.ChildLocal("Id");
|
||||
return id?.ChildLocal("IBAN")?.Value?.Trim()
|
||||
?? id?.ChildLocal("Othr")?.ChildLocal("Id")?.Value?.Trim()
|
||||
?? "";
|
||||
}
|
||||
|
||||
private static string? BankTxCode(XElement? bkTxCd)
|
||||
{
|
||||
if (bkTxCd is null) return null;
|
||||
var domn = bkTxCd.ChildLocal("Domn");
|
||||
if (domn is not null)
|
||||
{
|
||||
string? d = domn.ChildLocal("Cd")?.Value?.Trim();
|
||||
string? f = domn.ChildLocal("Fmly")?.ChildLocal("Cd")?.Value?.Trim();
|
||||
string? s = domn.ChildLocal("Fmly")?.ChildLocal("SubFmlyCd")?.Value?.Trim();
|
||||
string joined = string.Join("/", new[] { d, f, s }.Where(x => !string.IsNullOrEmpty(x)));
|
||||
if (!string.IsNullOrEmpty(joined)) return joined;
|
||||
}
|
||||
return bkTxCd.ChildLocal("Prtry")?.ChildLocal("Cd")?.Value?.Trim();
|
||||
}
|
||||
|
||||
private static string? JoinUstrd(XElement? rmtInf)
|
||||
{
|
||||
if (rmtInf is null) return null;
|
||||
var parts = rmtInf.ChildrenLocal("Ustrd").Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static string? FlattenStrd(XElement? strd)
|
||||
{
|
||||
if (strd is null) return null;
|
||||
var parts = strd.Descendants().Where(e => !e.HasElements)
|
||||
.Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(" ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CamtXmlExtensions
|
||||
{
|
||||
public static XElement? ChildLocal(this XElement? e, string localName) =>
|
||||
e?.Elements().FirstOrDefault(x => x.Name.LocalName == localName);
|
||||
|
||||
public static IEnumerable<XElement> ChildrenLocal(this XElement? e, string localName) =>
|
||||
e?.Elements().Where(x => x.Name.LocalName == localName) ?? Enumerable.Empty<XElement>();
|
||||
|
||||
public static IEnumerable<XElement> DescendantsLocal(this XElement e, string localName) =>
|
||||
e.Descendants().Where(x => x.Name.LocalName == localName);
|
||||
|
||||
public static string? NullIfNa(this string? s) =>
|
||||
string.IsNullOrEmpty(s) || s.Equals("NOTPROVIDED", StringComparison.OrdinalIgnoreCase) ? null : s;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
> (architecture, coding standards, configuration, libraries, secrets,
|
||||
> observability, testing), make the equivalent change in **both files in the
|
||||
> same commit**. This file may add Claude Code / workflow specifics; the shared
|
||||
> project facts must match `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`.
|
||||
- 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>`.
|
||||
- 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.
|
||||
|
||||
## 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`, `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`. For each service/handler change, add tests for **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled). Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Documentation map
|
||||
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
||||
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
||||
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
||||
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
||||
Binary file not shown.
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<configSections>
|
||||
<sectionGroup name="applicationSettings" type="System.Configuration.ApplicationSettingsGroup, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<section name="fds.My.MySettings" type="System.Configuration.ClientSettingsSection, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
|
||||
</sectionGroup>
|
||||
</configSections>
|
||||
|
||||
<connectionStrings>
|
||||
<add name="fuchs_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
||||
<add name="fuchs_fds_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_fds;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
||||
</connectionStrings>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
<applicationSettings>
|
||||
<fds.My.MySettings>
|
||||
<setting name="ExecutionFrequency_Minutes" serializeAs="String">
|
||||
<value>15</value>
|
||||
</setting>
|
||||
<setting name="DebugDetails" serializeAs="String">
|
||||
<value>True</value>
|
||||
</setting>
|
||||
<setting name="MFR_UserName" serializeAs="String">
|
||||
<value>system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</value>
|
||||
</setting>
|
||||
<setting name="MFR_Password" serializeAs="String">
|
||||
<value>0oT4G3H2</value>
|
||||
</setting>
|
||||
<setting name="MFR_host" serializeAs="String">
|
||||
<value>portal.mobilefieldreport.com</value>
|
||||
</setting>
|
||||
</fds.My.MySettings>
|
||||
</applicationSettings>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.2" newVersion="7.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Net.Http.Formatting" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-5.2.7.0" newVersion="5.2.7.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Azure.Services.AppAuthentication" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-1.6.2.0" newVersion="1.6.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Clients.ActiveDirectory" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-5.3.0.0" newVersion="5.3.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Numerics.Vectors" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.1.4.0" newVersion="4.1.4.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.JsonWebTokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IO.RecyclableMemoryStream" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.3.2.0" newVersion="2.3.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="BouncyCastle.Crypto" publicKeyToken="0e99375e54769942" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-1.9.0.0" newVersion="1.9.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="MimeKit" publicKeyToken="bede1c8a46c66814" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Json" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.3" newVersion="7.0.0.3" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Bcl.AsyncInterfaces" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Encoding.CodePages" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Web.Infrastructure" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.0.0.0" newVersion="2.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.ComponentModel.Annotations" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -0,0 +1,186 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{7A56E271-A6BE-4C34-A859-DADEBC4C7A54}</ProjectGuid>
|
||||
<OutputType>Exe</OutputType>
|
||||
<StartupObject>Sub Main</StartupObject>
|
||||
<RootNamespace>fds</RootNamespace>
|
||||
<AssemblyName>Fuchs_DataService</AssemblyName>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<MyType>Console</MyType>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<Deterministic>true</Deterministic>
|
||||
<IsWebBootstrapper>false</IsWebBootstrapper>
|
||||
<PublishUrl>publish\</PublishUrl>
|
||||
<Install>true</Install>
|
||||
<InstallFrom>Disk</InstallFrom>
|
||||
<UpdateEnabled>false</UpdateEnabled>
|
||||
<UpdateMode>Foreground</UpdateMode>
|
||||
<UpdateInterval>7</UpdateInterval>
|
||||
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
|
||||
<UpdatePeriodically>false</UpdatePeriodically>
|
||||
<UpdateRequired>false</UpdateRequired>
|
||||
<MapFileExtensions>true</MapFileExtensions>
|
||||
<ApplicationRevision>0</ApplicationRevision>
|
||||
<ApplicationVersion>1.0.0.%2a</ApplicationVersion>
|
||||
<UseApplicationTrust>false</UseApplicationTrust>
|
||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<DefineDebug>true</DefineDebug>
|
||||
<DefineTrace>true</DefineTrace>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<DefineDebug>false</DefineDebug>
|
||||
<DefineTrace>true</DefineTrace>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionExplicit>On</OptionExplicit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionCompare>Binary</OptionCompare>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionStrict>Off</OptionStrict>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionInfer>On</OptionInfer>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.Web.Infrastructure, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Microsoft.Web.Infrastructure.2.0.0\lib\net40\Microsoft.Web.Infrastructure.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SevenZipSharp, Version=1.6.1.23, Culture=neutral, PublicKeyToken=c8ff6ba0184838bb, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Squid-Box.SevenZipSharp.1.6.1.23\lib\net472\SevenZipSharp.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Configuration" />
|
||||
<Reference Include="System.Configuration.Install" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Deployment" />
|
||||
<Reference Include="System.Runtime.InteropServices.RuntimeInformation, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.Web" />
|
||||
<Reference Include="System.Web.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Microsoft.AspNet.Razor.3.2.9\lib\net45\System.Web.Razor.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="Topshelf, Version=4.3.0.0, Culture=neutral, PublicKeyToken=b800c4cfcdeea87b, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Topshelf.4.3.0\lib\net452\Topshelf.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Import Include="Microsoft.VisualBasic" />
|
||||
<Import Include="System" />
|
||||
<Import Include="System.Collections" />
|
||||
<Import Include="System.Collections.Generic" />
|
||||
<Import Include="System.Data" />
|
||||
<Import Include="System.Diagnostics" />
|
||||
<Import Include="System.Linq" />
|
||||
<Import Include="System.Xml.Linq" />
|
||||
<Import Include="System.Threading.Tasks" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="fds_zip.vb" />
|
||||
<Compile Include="fds_debug.vb" />
|
||||
<Compile Include="fds_mfr.vb" />
|
||||
<Compile Include="fds_shared.vb" />
|
||||
<Compile Include="My Project\AssemblyInfo.vb" />
|
||||
<Compile Include="My Project\Application.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Application.myapp</DependentUpon>
|
||||
<DesignTime>True</DesignTime>
|
||||
</Compile>
|
||||
<Compile Include="My Project\Resources.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="My Project\Settings.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
</Compile>
|
||||
<Compile Include="fds_main.vb" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="My Project\Resources.resx">
|
||||
<Generator>VbMyResourcesResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.vb</LastGenOutput>
|
||||
<CustomToolNamespace>My.Resources</CustomToolNamespace>
|
||||
<SubType>Designer</SubType>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="install.bat" />
|
||||
<None Include="My Project\Application.myapp">
|
||||
<Generator>MyApplicationCodeGenerator</Generator>
|
||||
<LastGenOutput>Application.Designer.vb</LastGenOutput>
|
||||
</None>
|
||||
<None Include="My Project\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<CustomToolNamespace>My</CustomToolNamespace>
|
||||
<LastGenOutput>Settings.Designer.vb</LastGenOutput>
|
||||
</None>
|
||||
<None Include="App.config" />
|
||||
<None Include="packages.config" />
|
||||
<Content Include="un-install.bat" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCMS\OCMS.vbproj">
|
||||
<Project>{ac8cba60-d786-48fd-a9f0-8b045a7bd505}</Project>
|
||||
<Name>OCMS</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.vbproj">
|
||||
<Project>{00c70b53-516d-4d56-ad25-6757094b4335}</Project>
|
||||
<Name>MFR_RESTClient</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="7z.dll">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BootstrapperPackage Include=".NETFramework,Version=v4.8">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>Microsoft .NET Framework 4.8 %28x86 and x64%29</ProductName>
|
||||
<Install>true</Install>
|
||||
</BootstrapperPackage>
|
||||
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>.NET Framework 3.5 SP1</ProductName>
|
||||
<Install>false</Install>
|
||||
</BootstrapperPackage>
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.VisualBasic.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' This code was generated by a tool.
|
||||
' Runtime Version:4.0.30319.42000
|
||||
'
|
||||
' Changes to this file may cause incorrect behavior and will be lost if
|
||||
' the code is regenerated.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
Option Strict On
|
||||
Option Explicit On
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MyApplicationData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<MySubMain>false</MySubMain>
|
||||
<SingleInstance>false</SingleInstance>
|
||||
<ShutdownMode>0</ShutdownMode>
|
||||
<EnableVisualStyles>true</EnableVisualStyles>
|
||||
<AuthenticationMode>0</AuthenticationMode>
|
||||
<ApplicationType>2</ApplicationType>
|
||||
<SaveMySettingsOnExit>true</SaveMySettingsOnExit>
|
||||
</MyApplicationData>
|
||||
@@ -0,0 +1,35 @@
|
||||
Imports System
|
||||
Imports System.Reflection
|
||||
Imports System.Runtime.InteropServices
|
||||
|
||||
' General Information about an assembly is controlled through the following
|
||||
' set of attributes. Change these attribute values to modify the information
|
||||
' associated with an assembly.
|
||||
|
||||
' Review the values of the assembly attributes
|
||||
|
||||
<Assembly: AssemblyTitle("Fuchs_DataService")>
|
||||
<Assembly: AssemblyDescription("")>
|
||||
<Assembly: AssemblyCompany("")>
|
||||
<Assembly: AssemblyProduct("Fuchs_DataService")>
|
||||
<Assembly: AssemblyCopyright("Copyright © 2021")>
|
||||
<Assembly: AssemblyTrademark("")>
|
||||
|
||||
<Assembly: ComVisible(False)>
|
||||
|
||||
'The following GUID is for the ID of the typelib if this project is exposed to COM
|
||||
<Assembly: Guid("b4650e09-34ae-4c0f-b973-63439b8a22f0")>
|
||||
|
||||
' Version information for an assembly consists of the following four values:
|
||||
'
|
||||
' Major Version
|
||||
' Minor Version
|
||||
' Build Number
|
||||
' Revision
|
||||
'
|
||||
' You can specify all the values or you can default the Build and Revision Numbers
|
||||
' by using the '*' as shown below:
|
||||
' <Assembly: AssemblyVersion("1.0.*")>
|
||||
|
||||
<Assembly: AssemblyVersion("1.0.0.0")>
|
||||
<Assembly: AssemblyFileVersion("1.0.0.0")>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' This code was generated by a tool.
|
||||
' Runtime Version:4.0.30319.42000
|
||||
'
|
||||
' Changes to this file may cause incorrect behavior and will be lost if
|
||||
' the code is regenerated.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
Option Strict On
|
||||
Option Explicit On
|
||||
|
||||
Imports System
|
||||
|
||||
Namespace My.Resources
|
||||
|
||||
'This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
'class via a tool like ResGen or Visual Studio.
|
||||
'To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
'with the /str option, or rebuild your VS project.
|
||||
'''<summary>
|
||||
''' A strongly-typed resource class, for looking up localized strings, etc.
|
||||
'''</summary>
|
||||
<Global.System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0"), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
||||
Global.Microsoft.VisualBasic.HideModuleNameAttribute()> _
|
||||
Friend Module Resources
|
||||
|
||||
Private resourceMan As Global.System.Resources.ResourceManager
|
||||
|
||||
Private resourceCulture As Global.System.Globalization.CultureInfo
|
||||
|
||||
'''<summary>
|
||||
''' Returns the cached ResourceManager instance used by this class.
|
||||
'''</summary>
|
||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
Friend ReadOnly Property ResourceManager() As Global.System.Resources.ResourceManager
|
||||
Get
|
||||
If Object.ReferenceEquals(resourceMan, Nothing) Then
|
||||
Dim temp As Global.System.Resources.ResourceManager = New Global.System.Resources.ResourceManager("fds.Resources", GetType(Resources).Assembly)
|
||||
resourceMan = temp
|
||||
End If
|
||||
Return resourceMan
|
||||
End Get
|
||||
End Property
|
||||
|
||||
'''<summary>
|
||||
''' Overrides the current thread's CurrentUICulture property for all
|
||||
''' resource lookups using this strongly typed resource class.
|
||||
'''</summary>
|
||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
Friend Property Culture() As Global.System.Globalization.CultureInfo
|
||||
Get
|
||||
Return resourceCulture
|
||||
End Get
|
||||
Set
|
||||
resourceCulture = value
|
||||
End Set
|
||||
End Property
|
||||
End Module
|
||||
End Namespace
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' This code was generated by a tool.
|
||||
' Runtime Version:4.0.30319.42000
|
||||
'
|
||||
' Changes to this file may cause incorrect behavior and will be lost if
|
||||
' the code is regenerated.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
Option Strict On
|
||||
Option Explicit On
|
||||
|
||||
|
||||
Namespace My
|
||||
|
||||
<Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
||||
Global.System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.8.1.0"), _
|
||||
Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
Partial Friend NotInheritable Class MySettings
|
||||
Inherits Global.System.Configuration.ApplicationSettingsBase
|
||||
|
||||
Private Shared defaultInstance As MySettings = CType(Global.System.Configuration.ApplicationSettingsBase.Synchronized(New MySettings()),MySettings)
|
||||
|
||||
#Region "My.Settings Auto-Save Functionality"
|
||||
#If _MyType = "WindowsForms" Then
|
||||
Private Shared addedHandler As Boolean
|
||||
|
||||
Private Shared addedHandlerLockObject As New Object
|
||||
|
||||
<Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
Private Shared Sub AutoSaveSettings(sender As Global.System.Object, e As Global.System.EventArgs)
|
||||
If My.Application.SaveMySettingsOnExit Then
|
||||
My.Settings.Save()
|
||||
End If
|
||||
End Sub
|
||||
#End If
|
||||
#End Region
|
||||
|
||||
Public Shared ReadOnly Property [Default]() As MySettings
|
||||
Get
|
||||
|
||||
#If _MyType = "WindowsForms" Then
|
||||
If Not addedHandler Then
|
||||
SyncLock addedHandlerLockObject
|
||||
If Not addedHandler Then
|
||||
AddHandler My.Application.Shutdown, AddressOf AutoSaveSettings
|
||||
addedHandler = True
|
||||
End If
|
||||
End SyncLock
|
||||
End If
|
||||
#End If
|
||||
Return defaultInstance
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("15")> _
|
||||
Public ReadOnly Property ExecutionFrequency_Minutes() As String
|
||||
Get
|
||||
Return CType(Me("ExecutionFrequency_Minutes"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("True")> _
|
||||
Public ReadOnly Property DebugDetails() As Boolean
|
||||
Get
|
||||
Return CType(Me("DebugDetails"),Boolean)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com")> _
|
||||
Public ReadOnly Property MFR_UserName() As String
|
||||
Get
|
||||
Return CType(Me("MFR_UserName"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("0oT4G3H2")> _
|
||||
Public ReadOnly Property MFR_Password() As String
|
||||
Get
|
||||
Return CType(Me("MFR_Password"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("portal.mobilefieldreport.com")> _
|
||||
Public ReadOnly Property MFR_host() As String
|
||||
Get
|
||||
Return CType(Me("MFR_host"),String)
|
||||
End Get
|
||||
End Property
|
||||
End Class
|
||||
End Namespace
|
||||
|
||||
Namespace My
|
||||
|
||||
<Global.Microsoft.VisualBasic.HideModuleNameAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute()> _
|
||||
Friend Module MySettingsProperty
|
||||
|
||||
<Global.System.ComponentModel.Design.HelpKeywordAttribute("My.Settings")> _
|
||||
Friend ReadOnly Property Settings() As Global.fds.My.MySettings
|
||||
Get
|
||||
Return Global.fds.My.MySettings.Default
|
||||
End Get
|
||||
End Property
|
||||
End Module
|
||||
End Namespace
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="My" GeneratedClassName="MySettings" UseMySettingsClassName="true">
|
||||
<Profiles />
|
||||
<Settings>
|
||||
<Setting Name="ExecutionFrequency_Minutes" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">15</Value>
|
||||
</Setting>
|
||||
<Setting Name="DebugDetails" Type="System.Boolean" Scope="Application">
|
||||
<Value Profile="(Default)">True</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_UserName" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_Password" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">0oT4G3H2</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_host" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">portal.mobilefieldreport.com</Value>
|
||||
</Setting>
|
||||
</Settings>
|
||||
</SettingsFile>
|
||||
@@ -0,0 +1,134 @@
|
||||
Option Explicit On
|
||||
|
||||
|
||||
|
||||
|
||||
Partial Friend Module fds_debug
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Function LogFile(FileName As String) As IO.FileInfo
|
||||
Return New IO.FileInfo(AppBaseDirectory().FullName & FileName)
|
||||
End Function
|
||||
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Function AppBaseDirectory() As IO.DirectoryInfo
|
||||
Dim path As String = AppDomain.CurrentDomain.BaseDirectory + "tmp\"
|
||||
Dim di As New IO.DirectoryInfo(path)
|
||||
If di.Exists = True Then
|
||||
Return di
|
||||
ElseIf My.Computer.FileSystem.DirectoryExists(AppDomain.CurrentDomain.BaseDirectory) = True Then
|
||||
di.Create()
|
||||
Return di
|
||||
Else : Return Nothing
|
||||
End If
|
||||
End Function
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Sub DebugLog_async(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
||||
If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
|
||||
Try
|
||||
Threading.Tasks.Task.Run(Sub() Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=SQLConnectionString, exc:=exc, data:=data, context:=context))
|
||||
Catch ex As Exception
|
||||
Call DebugLog_sync(CodeReference:="fds_debug DebugLog_async", SQLConnectionString:=SQLConnectionString, exc:=ex, data:="", context:=Nothing)
|
||||
End Try
|
||||
End Sub
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Sub DebugLog_sync(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
||||
If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
|
||||
Using con As New SqlClient.SqlConnection(SQLConnectionString)
|
||||
Call DebugLog(CodeReference:=CodeReference, SQLConnection:=con, exc:=exc, data:=data, context:=context)
|
||||
End Using
|
||||
End Sub
|
||||
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Sub DebugLog(CodeReference As String, SQLConnection As SqlClient.SqlConnection, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
|
||||
If CodeReference = "" OrElse IsNothing(SQLConnection) = True Then Exit Sub
|
||||
Dim note As String = Now.ToString("yyyy.MM.dd HH:mm:ss") & " - " & CodeReference
|
||||
Try
|
||||
Try
|
||||
If IsNothing(SQLConnection) = False Then
|
||||
Dim pl As New List(Of SqlClient.SqlParameter) From {
|
||||
New SqlClient.SqlParameter("@CodeReference", CodeReference),
|
||||
New SqlClient.SqlParameter("@ExceptionMessage", If(IsNothing(exc), DBNull.Value, exc.Message)),
|
||||
New SqlClient.SqlParameter("@StackTrace", If(IsNothing(exc), DBNull.Value, exc.StackTrace.ToString)),
|
||||
New SqlClient.SqlParameter("@data", If(data, DBNull.Value))
|
||||
}
|
||||
Try
|
||||
Dim w As Integer = 0
|
||||
If SQLConnection.State = ConnectionState.Broken Then SQLConnection.Close()
|
||||
If SQLConnection.State = ConnectionState.Connecting Then
|
||||
w = 0
|
||||
While SQLConnection.State = ConnectionState.Connecting And w < 10
|
||||
System.Threading.Thread.Sleep(100)
|
||||
w += 1
|
||||
End While
|
||||
ElseIf Not SQLConnection.State = ConnectionState.Open Then
|
||||
SQLConnection.Open()
|
||||
End If
|
||||
w = 0
|
||||
While Not SQLConnection.State = ConnectionState.Open And w < 10
|
||||
System.Threading.Thread.Sleep(100)
|
||||
w += 1
|
||||
End While
|
||||
Dim cmd As New SqlClient.SqlCommand("EXECUTE [dbo].[fds__admin_logdebug] @CodeReference,@ExceptionMessage,@StackTrace,@Data;", SQLConnection)
|
||||
cmd.Parameters.AddRange(pl.ToArray)
|
||||
Call cmd.ExecuteNonQuery()
|
||||
'SQLConnection.Close()
|
||||
cmd.Parameters.Clear()
|
||||
|
||||
Catch sqlex As Exception
|
||||
End Try
|
||||
|
||||
End If
|
||||
Catch dbex As Exception
|
||||
|
||||
End Try
|
||||
|
||||
If IsNothing(exc) = False Then
|
||||
note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
|
||||
End If
|
||||
If data <> "" Then
|
||||
note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
|
||||
End If
|
||||
note &= vbCrLf
|
||||
|
||||
Dim DebugLogfile As IO.FileInfo = LogFile("DebugLog.txt")
|
||||
If DebugLogfile.Directory.Exists = True Then
|
||||
IO.File.AppendAllText(DebugLogfile.FullName, note)
|
||||
End If
|
||||
Catch logex As Exception
|
||||
|
||||
Finally
|
||||
|
||||
Console.Write(note)
|
||||
Debug.Print(note)
|
||||
End Try
|
||||
End Sub
|
||||
|
||||
Public Sub DebugToFile(note As String, Optional filename As String = "DebugLog.txt")
|
||||
Try
|
||||
Dim DebugLogfile As IO.FileInfo = LogFile(filename)
|
||||
If DebugLogfile.Directory.Exists = True Then
|
||||
IO.File.AppendAllText(DebugLogfile.FullName, Now.ToString & ": " & note & vbCrLf)
|
||||
End If
|
||||
Catch ex As Exception
|
||||
End Try
|
||||
End Sub
|
||||
Public Sub DebugToFile(CodeReference As String, exc As Exception, data As String, Optional filename As String = "DebugLog.txt")
|
||||
Dim note As String = CodeReference
|
||||
If IsNothing(exc) = False Then
|
||||
note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
|
||||
End If
|
||||
If data <> "" Then
|
||||
note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
|
||||
End If
|
||||
|
||||
Call DebugToFile(note, filename:=filename)
|
||||
End Sub
|
||||
|
||||
|
||||
|
||||
End Module
|
||||
@@ -0,0 +1,146 @@
|
||||
|
||||
Imports Topshelf
|
||||
Imports json = Newtonsoft.Json.JsonConvert
|
||||
|
||||
|
||||
|
||||
Public Class fds_service
|
||||
Implements Topshelf.ServiceControl
|
||||
|
||||
Dim WithEvents _timer As System.Timers.Timer
|
||||
|
||||
Public Sub New()
|
||||
Me._timer = New System.Timers.Timer(My.Settings.ExecutionFrequency_Minutes * 60 * 1000) With {.AutoReset = True}
|
||||
|
||||
End Sub
|
||||
|
||||
|
||||
Public Function Start(hostControl As HostControl) As Boolean Implements ServiceControl.Start
|
||||
Me._timer.Start()
|
||||
Return True
|
||||
End Function
|
||||
Public Function StartImmediately(hostControl As HostControl) As Boolean
|
||||
Me._timer.Start()
|
||||
System.Threading.Tasks.Task.Run(Sub()
|
||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service - timer started with interval " & _timer.Interval.ToString, filename:="DebugDetail.txt")
|
||||
Call update_mfr() 'start right away and do not wait until first intervall period is over
|
||||
End Sub)
|
||||
'do not wait and immediately return
|
||||
Return True
|
||||
End Function
|
||||
|
||||
Public Function [Stop](hostControl As HostControl) As Boolean Implements ServiceControl.Stop
|
||||
Me._timer.Stop()
|
||||
If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - timer stopped", filename:="DebugDetail.txt"))
|
||||
|
||||
Return True
|
||||
End Function
|
||||
|
||||
|
||||
Public Sub timerElapsed() Handles _timer.Elapsed
|
||||
Call update_mfr()
|
||||
End Sub
|
||||
|
||||
Friend Shared Sub update_mfr()
|
||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary - timer elapsed", filename:="DebugDetail.txt")
|
||||
'call update to data if necessary
|
||||
Try
|
||||
Dim t As Threading.Tasks.Task = Threading.Tasks.Task.Run(Async Function()
|
||||
'Await UpdateIfNecessary_Single_async(et:=MFR_RESTClient.generic._generic.EntityTypes.Report, DebugDetails:=My.Settings.DebugDetails)
|
||||
Await UpdateIfNecessary_async(DebugDetails:=My.Settings.DebugDetails)
|
||||
Await UpdateRequested_async(DebugDetails:=My.Settings.DebugDetails)
|
||||
Await GetInvoiceFiles_async(DebugDetails:=My.Settings.DebugDetails)
|
||||
'Await getDatevZip()
|
||||
End Function)
|
||||
t.Wait()
|
||||
Catch ex As Exception
|
||||
Call DebugLog("fds__data_service update_mfr UpdateIfNecessary", SQLConnection:=Nothing, exc:=ex)
|
||||
If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary", exc:=ex, data:="", filename:="DebugDetail.txt")
|
||||
End Try
|
||||
End Sub
|
||||
|
||||
End Class
|
||||
|
||||
|
||||
|
||||
Public Module fds_main
|
||||
|
||||
Sub Main()
|
||||
Dim clArgs() As String = Environment.GetCommandLineArgs()
|
||||
|
||||
If (New String() {"digital-pc", "digital-dpc"}).Contains(Environment.MachineName.ToLower) = False Then
|
||||
|
||||
HostFactory.Run(Sub(x)
|
||||
x.Service(Of fds_service)(AddressOf ServiceConfiguratorCallback)
|
||||
x.EnablePauseAndContinue()
|
||||
x.StartAutomatically()
|
||||
x.RunAsLocalSystem()
|
||||
x.SetDescription("MFR Data Sync")
|
||||
x.SetDisplayName("MFR Data Sync")
|
||||
x.SetServiceName("MFR Data Sync")
|
||||
End Sub)
|
||||
Else
|
||||
Call fds_service.update_mfr()
|
||||
'Call DEv()
|
||||
End If
|
||||
End Sub
|
||||
|
||||
Private Sub ServiceConfiguratorCallback(s As ServiceConfigurators.ServiceConfigurator(Of fds_service))
|
||||
s.ConstructUsing(Function(name) New fds_service())
|
||||
s.WhenStarted(Function(tc, Host)
|
||||
Return tc.Start(Host)
|
||||
End Function)
|
||||
s.WhenStopped(Function(tc, Host)
|
||||
Return tc.Stop(Host)
|
||||
End Function)
|
||||
s.BeforeStoppingService(Sub(HostStopContext)
|
||||
If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - beforestop", filename:="DebugDetail.txt"))
|
||||
End Sub)
|
||||
s.WhenPaused(Function(tc, Host)
|
||||
Return tc.Stop(Host)
|
||||
End Function)
|
||||
s.WhenContinued(Function(tc, Host)
|
||||
Return tc.StartImmediately(Host)
|
||||
End Function)
|
||||
End Sub
|
||||
|
||||
|
||||
|
||||
Public Sub DEv()
|
||||
Using MFR As New fds_MFR_Client()
|
||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/$metadata"))
|
||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Companies?$top=5&$expand=Contacts,Tags,ServiceObjects,MainContact"))
|
||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/ServiceObjects?$expand=WarehouseManager,CustomValueSteps,Company,Product,Tags,ChildServiceObject,Contacts,Items"))
|
||||
'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Contacts/$count"))
|
||||
'Diagnostics.Debug.Print(MFR.getEntities())
|
||||
Dim fle As Byte()
|
||||
Try
|
||||
fle = MFR.GetFile("https://portal.mobilefieldreport.com/mfr/Report/19584712737/Content/")
|
||||
System.IO.File.WriteAllBytes("C:\Users\sailo\Desktop\Test.pdf", fle)
|
||||
Catch ex As Exception
|
||||
|
||||
End Try
|
||||
End Using
|
||||
|
||||
End Sub
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
End Module
|
||||
|
||||
|
||||
|
||||
Partial Friend Module fds_debug
|
||||
|
||||
|
||||
Public Sub DebugLog(CodeReference As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing, Optional execute_async As Boolean = True)
|
||||
If execute_async = True Then
|
||||
Call DebugLog_async(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
|
||||
Else
|
||||
Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
|
||||
End If
|
||||
End Sub
|
||||
|
||||
End Module
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
|
||||
|
||||
Friend Module fds_shared
|
||||
|
||||
Friend Function SQLConnectionString() As String
|
||||
Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString
|
||||
End Function
|
||||
Friend Function FDSConnectionString() As String
|
||||
Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_fds_ConnectionString").ConnectionString
|
||||
End Function
|
||||
Friend Function SqlCon() As SqlClient.SqlConnection
|
||||
Return New SqlClient.SqlConnection(Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString)
|
||||
End Function
|
||||
|
||||
|
||||
|
||||
Public Function RandomString(rs_length As Byte) As String
|
||||
Dim r As New Random()
|
||||
Dim s As String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
Dim sb As New Text.StringBuilder
|
||||
For i As Byte = 1 To rs_length
|
||||
Dim idx As Integer = r.Next(0, s.Length)
|
||||
sb.Append(s.Substring(idx, 1))
|
||||
Next
|
||||
Return sb.ToString()
|
||||
End Function
|
||||
|
||||
|
||||
'''' <summary>
|
||||
'''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
||||
'''' </summary>
|
||||
'''' <param name="source">
|
||||
'''' The input <see cref="DataRow" />.
|
||||
'''' </param>
|
||||
'''' <param name="delimiter">
|
||||
'''' The delimiter placed between field values. the default value is a comma.
|
||||
'''' </param>
|
||||
'''' <returns>
|
||||
'''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
||||
'''' </returns>
|
||||
'<Runtime.CompilerServices.Extension>
|
||||
'Public Function ToCsv(source As DataRow,
|
||||
' Optional delimiter As String = ",") As String
|
||||
' Return String.Join(delimiter, source.ItemArray)
|
||||
'End Function
|
||||
|
||||
''' <summary>
|
||||
''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
||||
''' </summary>
|
||||
''' <param name="source">
|
||||
''' The input <see cref="DataRow" />.
|
||||
''' </param>
|
||||
''' <param name="quoteStrings">
|
||||
''' <b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.
|
||||
''' </param>
|
||||
''' <param name="delimiter">
|
||||
''' The delimiter placed between field values. the default value is a comma.
|
||||
''' </param>
|
||||
''' <returns>
|
||||
''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
||||
''' </returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
Public Function ToCsv(source As DataRow,
|
||||
quoteStrings As Boolean,
|
||||
cultureinfo As Globalization.CultureInfo,
|
||||
Optional delimiter As String = ",") As String
|
||||
Dim fieldValues = source.ItemArray
|
||||
|
||||
|
||||
Dim rx As New Text.RegularExpressions.Regex("(\"")")
|
||||
'Wrap any String values in double-quotes and also escape any double-quotes in the String with another double-quote.
|
||||
'replace array by converted array
|
||||
fieldValues = fieldValues.Select(Function(o)
|
||||
If IsNothing(o) OrElse IsDBNull(o) Then
|
||||
Return ""
|
||||
ElseIf o.GetType = GetType(String) Then
|
||||
If quoteStrings = True Then
|
||||
Return Microsoft.VisualBasic.ChrW(34) & rx.Replace(o.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34)
|
||||
Else
|
||||
Return o.ToString
|
||||
End If
|
||||
Else
|
||||
Select Case o.GetType
|
||||
Case GetType(Decimal)
|
||||
Return DirectCast(o, Decimal).ToString(cultureinfo)
|
||||
Case GetType(Single)
|
||||
Return DirectCast(o, Single).ToString(cultureinfo)
|
||||
Case GetType(Double)
|
||||
Return DirectCast(o, Double).ToString(cultureinfo)
|
||||
Case GetType(Boolean)
|
||||
Return DirectCast(o, Boolean).ToString(cultureinfo)
|
||||
Case GetType(System.DateTime)
|
||||
Return DirectCast(o, DateTime).ToUniversalTime.ToString("U")
|
||||
Case Else
|
||||
Return o.ToString()
|
||||
End Select
|
||||
End If
|
||||
End Function).ToArray()
|
||||
|
||||
|
||||
Return String.Join(delimiter, fieldValues)
|
||||
End Function
|
||||
|
||||
|
||||
|
||||
''' <summary>
|
||||
''' Returns a delimited <see cref="String" /> containing the field values from the rows a <see cref="DataTable" />.
|
||||
''' </summary>
|
||||
''' <param name="source">The input <see cref="DataTable" />.</param>
|
||||
''' <param name="includeHeaders"><b>True</b> to include a row of column headers; otherwise, <b>False</b></param>
|
||||
''' <param name="quoteStrings"><b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.</param>
|
||||
''' <param name="rowDelimiter">The delimiter placed between rows. the default value is a line break comprising a carriage return and a line feed.</param>
|
||||
''' <param name="fieldDelimiter">The delimiter placed between field values. the default value is a comma.</param>
|
||||
''' <param name="cultureinfo">The culture that is used to convert float-point numbers like <see cref="Decimal" /> or <see cref="Double"/> to string. <br/>This falls back to InvariantCulture, if not provided.</param>
|
||||
''' <param name="quoteHeader"><b>True</b> to wrap <see cref="String"/> column header names in double-quotes; otherwise, <b>False</b>.<br />
|
||||
''' If no value is provided, the settings falls back to <b>quoteStrings</b> parameter.</param>
|
||||
''' <returns>A <see cref="String"/> containing the field values from the rows of the table separated by the specified delimiters.</returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
Public Function ToCsv(source As DataTable,
|
||||
includeHeaders As Boolean,
|
||||
quoteStrings As Boolean,
|
||||
Optional rowDelimiter As String = ControlChars.CrLf,
|
||||
Optional fieldDelimiter As String = ",",
|
||||
Optional cultureinfo As Globalization.CultureInfo = Nothing,
|
||||
Optional quoteHeader As Boolean? = Nothing) As String
|
||||
If quoteHeader.HasValue = False Then quoteHeader = quoteStrings
|
||||
cultureinfo = If(cultureinfo, Globalization.CultureInfo.InvariantCulture) 'fallback if not provided
|
||||
Dim rows = source.Rows.
|
||||
Cast(Of DataRow)().
|
||||
Select(Function(row) row.ToCsv(quoteStrings:=quoteStrings, cultureinfo:=cultureinfo, delimiter:=fieldDelimiter))
|
||||
|
||||
If includeHeaders = True Then
|
||||
Dim rx As New Text.RegularExpressions.Regex("(\"")")
|
||||
Dim headers = String.Join(fieldDelimiter,
|
||||
source.Columns.
|
||||
Cast(Of DataColumn)().
|
||||
Select(Function(column) If(quoteHeader.Value,
|
||||
Microsoft.VisualBasic.ChrW(34) & rx.Replace(column.ColumnName.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34),
|
||||
column.ColumnName)))
|
||||
|
||||
rows = {headers}.Concat(rows)
|
||||
End If
|
||||
|
||||
Return String.Join(rowDelimiter, rows)
|
||||
End Function
|
||||
|
||||
''' <summary>
|
||||
''' Returns a text-file containing the string, created by streamwriter.
|
||||
''' </summary>
|
||||
''' <param name="input">The input <see cref="String"/>.</param>
|
||||
''' <param name="encoding">The encoding used with streamwriter for the textfile. This falls back to <see cref="System.Text.Encoding.utf8"/>, if not provided.</param>
|
||||
''' <returns>A file as byte-array.</returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
Public Function ToByteArray(input As String, Optional encoding As System.Text.Encoding = Nothing) As Byte()
|
||||
Dim content As Byte() = Nothing
|
||||
Using ms As New IO.MemoryStream
|
||||
Using sw As New IO.StreamWriter(ms, encoding:=If(encoding, System.Text.Encoding.UTF8))
|
||||
sw.Write(input)
|
||||
sw.Flush()
|
||||
ms.Position = 0
|
||||
content = ms.ToArray()
|
||||
End Using
|
||||
End Using
|
||||
Return content
|
||||
End Function
|
||||
|
||||
|
||||
Public Function WriteStreamToDisk(ByVal StreamToWrite As IO.Stream, ByVal FilePath As String) As Boolean
|
||||
'Dim tmpFilePath As String = Left(FilePath, Len(FilePath) - 4) & ".tmp"
|
||||
Dim cnt = 0
|
||||
restart:
|
||||
Try
|
||||
If My.Computer.FileSystem.FileExists(FilePath) Then My.Computer.FileSystem.DeleteFile(FilePath)
|
||||
Using FleStream As System.IO.FileStream = New System.IO.FileStream(FilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.Delete)
|
||||
ReadWriteStream(StreamToWrite, FleStream, True)
|
||||
End Using
|
||||
Catch ex As Exception
|
||||
System.Diagnostics.Debug.WriteLine($"{"WriteStreamToDisk - " & ex.Message}")
|
||||
cnt += 1
|
||||
If cnt = 6 Then
|
||||
Return False
|
||||
Exit Function
|
||||
Else
|
||||
Threading.Thread.Sleep(500)
|
||||
GoTo restart
|
||||
End If
|
||||
End Try
|
||||
Return True
|
||||
End Function
|
||||
|
||||
Public Function ReadWriteStream(ByVal readStream As IO.Stream, ByVal writeStream As IO.Stream, ByVal closeWriteStream As Boolean) As Boolean
|
||||
Try
|
||||
Dim Length As Integer = 256
|
||||
Dim buffer(Length - 1) As Byte
|
||||
readStream.Seek(0, System.IO.SeekOrigin.Begin)
|
||||
Dim bytesRead As Integer = readStream.Read(buffer, 0, Length)
|
||||
'write the required bytes
|
||||
While (bytesRead > 0)
|
||||
writeStream.Write(buffer, 0, bytesRead)
|
||||
bytesRead = readStream.Read(buffer, 0, Length)
|
||||
End While
|
||||
readStream.Close()
|
||||
If closeWriteStream = True Then writeStream.Close()
|
||||
Return True
|
||||
Catch ex As Exception
|
||||
System.Diagnostics.Debug.WriteLine($"{"ReadWriteStream - " & ex.Message}")
|
||||
Call OCMS.debug_log("files_folders ReadWriteStream", ex)
|
||||
Return False
|
||||
End Try
|
||||
End Function
|
||||
|
||||
<System.Diagnostics.DebuggerStepThrough()>
|
||||
<Runtime.CompilerServices.Extension()>
|
||||
Public Function NameBase(ByVal FI As System.IO.FileInfo) As String
|
||||
Return FI.Name.Substring(startIndex:=0, length:=FI.Name.Length - FI.Extension.Length)
|
||||
End Function
|
||||
|
||||
|
||||
<System.Diagnostics.DebuggerStepThrough()>
|
||||
<Runtime.CompilerServices.Extension()>
|
||||
Public Function MimeType(ByVal FI As System.IO.FileInfo) As String
|
||||
Return System.Web.MimeMapping.GetMimeMapping(FI.Name)
|
||||
End Function
|
||||
End Module
|
||||
@@ -0,0 +1,500 @@
|
||||
Imports SevenZip 'Squid-Box.SevenZipSharp
|
||||
Imports System.IO
|
||||
|
||||
Namespace Global.fds
|
||||
Public Class Archive
|
||||
Implements IDisposable
|
||||
|
||||
'Private Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long
|
||||
'Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
|
||||
|
||||
'Public Enum Timeunit As Long
|
||||
' Milliseconds = 0
|
||||
' Seconds = 1000
|
||||
' Minutes = 60000
|
||||
'End Enum
|
||||
'Public Sub Wait(ByVal No As Integer, ByVal unit As Timeunit)
|
||||
' Dim tme As Long = CLng(No * unit)
|
||||
' Sleep(tme)
|
||||
'End Sub
|
||||
|
||||
Public Event Saving()
|
||||
Public Event FileSaved()
|
||||
Public Event FileStreamCreated()
|
||||
|
||||
Private _ArchiveFile As FileInfo
|
||||
Private _ArchivePassword As String
|
||||
Private _ArchiveFormat As OutArchiveFormat
|
||||
Public TempPath As String = System.AppDomain.CurrentDomain.BaseDirectory
|
||||
Public Property ArchiveFileStream As IO.Stream
|
||||
|
||||
Private ZipOut As SevenZipExtractor
|
||||
Private ZipIn As SevenZipCompressor
|
||||
Public ZipAppend As Boolean = True
|
||||
|
||||
Public ExitOK As Boolean = False
|
||||
Public ZipInOK As Boolean = False
|
||||
|
||||
Public Sub New(ByVal ArchiveFile As FileInfo, Optional ByVal ArchivePassword As String = "", Optional ByVal INIT As Boolean = True, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip)
|
||||
Me._ArchiveFormat = Type
|
||||
Me._ArchiveFile = New FileInfo(ArchiveFile.FullName.Replace(ArchiveFile.Extension, If(Type = OutArchiveFormat.SevenZip, ".7z", ArchiveFile.Extension)))
|
||||
Me._ArchivePassword = ArchivePassword
|
||||
If INIT = True Then Call InitZipIn(Type)
|
||||
End Sub
|
||||
|
||||
Private Sub InitZipIn(ByVal Type As OutArchiveFormat)
|
||||
Dim assemblydirectory As IO.DirectoryInfo
|
||||
If Zipping.SevenZipPath = "" Then
|
||||
Try
|
||||
assemblydirectory = New IO.DirectoryInfo(New Uri(System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase)).LocalPath)
|
||||
Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
|
||||
Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
|
||||
Finally
|
||||
If Zipping.SevenZipPath = "" Then
|
||||
assemblydirectory = New IO.DirectoryInfo(System.AppDomain.CurrentDomain.BaseDirectory)
|
||||
Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
|
||||
Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
|
||||
End If
|
||||
End Try
|
||||
If Zipping.SevenZipPath = "" Then
|
||||
OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", error:="SevenZipPath not found")
|
||||
End If
|
||||
End If
|
||||
SevenZipCompressor.SetLibraryPath(SevenZipPath)
|
||||
|
||||
Me.ZipIn = New SevenZipCompressor
|
||||
With Me.ZipIn
|
||||
If Type = OutArchiveFormat.SevenZip AndAlso Me._ArchiveFile.Extension.ToLower.Contains("7z") = True Then
|
||||
.ArchiveFormat = OutArchiveFormat.SevenZip
|
||||
Else
|
||||
.ArchiveFormat = Type
|
||||
End If
|
||||
|
||||
.CompressionLevel = SevenZip.CompressionLevel.Ultra
|
||||
Select Case .ArchiveFormat
|
||||
Case OutArchiveFormat.SevenZip
|
||||
.CompressionMethod = SevenZip.CompressionMethod.Lzma2
|
||||
Case OutArchiveFormat.Zip, OutArchiveFormat.GZip
|
||||
.CompressionMethod = CompressionMethod.Deflate
|
||||
Case Else
|
||||
.CompressionMethod = CompressionMethod.Default
|
||||
End Select
|
||||
|
||||
If ZipAppend = True Then
|
||||
.CompressionMode = SevenZip.CompressionMode.Append
|
||||
Else
|
||||
.CompressionMode = SevenZip.CompressionMode.Create
|
||||
End If
|
||||
.DirectoryStructure = False
|
||||
End With
|
||||
Me.ZipInOK = True
|
||||
End Sub
|
||||
|
||||
Public Sub Extract(ByVal DataArchiveFilePath As FileInfo, ByVal TgtDirectory As DirectoryInfo, Optional ByVal Type As OutArchiveFormat = Nothing)
|
||||
If DataArchiveFilePath.Exists Then
|
||||
If IsNothing(Type) = True AndAlso DataArchiveFilePath.Extension.ToLower.Contains("7z") = True Then
|
||||
Type = OutArchiveFormat.SevenZip
|
||||
ElseIf IsNothing(Type) = True Then
|
||||
Type = OutArchiveFormat.Zip
|
||||
End If
|
||||
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Type)
|
||||
|
||||
If Me._ArchivePassword = "" Then
|
||||
Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName)
|
||||
Else
|
||||
Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName, Me._ArchivePassword)
|
||||
End If
|
||||
|
||||
Try
|
||||
If Me.ZipOut.ArchiveFileData(0).Encrypted = False And Not Me._ArchivePassword = "" Then
|
||||
Me._ArchivePassword = ""
|
||||
End If
|
||||
Catch ex As Exception
|
||||
System.Diagnostics.Debug.WriteLine($"{"zip Extract - " & ex.Message}")
|
||||
OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", ex:=ex, data:=New With {.DataArchiveFilePath = DataArchiveFilePath.FullName, .TgtDirectory = TgtDirectory.FullName})
|
||||
Exit Sub
|
||||
End Try
|
||||
|
||||
If Not ZipOut Is Nothing Then
|
||||
Me.ZipOut.ExtractArchive(TgtDirectory.FullName)
|
||||
Me.ZipOut.Dispose()
|
||||
End If
|
||||
End If
|
||||
End Sub
|
||||
|
||||
Public Function FileInfo_to_Filepaths_Converter() As Converter(Of FileInfo, String)
|
||||
Return New Converter(Of FileInfo, String)(Function(filepath As FileInfo) filepath.FullName())
|
||||
End Function
|
||||
Public Function Filepaths_to_FileInfo_Converter() As Converter(Of String, FileInfo)
|
||||
Return New Converter(Of String, FileInfo)(Function(filepath As String) New FileInfo(filepath))
|
||||
End Function
|
||||
|
||||
Public Function Compress(ByVal FilePaths As List(Of String), Optional ByVal ArchiveFilePath As String = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
|
||||
Return Compress(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()), ArchiveFile:=If(IsNothing(ArchiveFilePath), Nothing, New FileInfo(ArchiveFilePath)), ArchivePass:=ArchivePass, Type:=Type)
|
||||
End Function
|
||||
Public Function Compress(ByVal Files As List(Of FileInfo), Optional ByVal ArchiveFile As FileInfo = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
|
||||
If Files.Count = 0 Then Return True
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Type)
|
||||
|
||||
If IsNothing(ArchiveFile) = True Then ArchiveFile = Me._ArchiveFile
|
||||
If If(IsNothing(ArchivePass), "", ArchivePass) = "" Then ArchivePass = Me._ArchivePassword
|
||||
|
||||
If ArchiveFile.Exists() AndAlso Me.ZipAppend = True Then
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
||||
Else
|
||||
If ArchiveFile.Exists = True Then ArchiveFile.Delete()
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
||||
End If
|
||||
|
||||
Try
|
||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
||||
Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
||||
If ArchivePass = "" Then
|
||||
Me.ZipIn.CompressFiles(ArchiveFile.FullName, FilePaths)
|
||||
Else
|
||||
Me.ZipIn.EncryptHeaders = True
|
||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
||||
Me.ZipIn.CompressFilesEncrypted(ArchiveFile.FullName, ArchivePass, FilePaths)
|
||||
End If
|
||||
RaiseEvent FileSaved()
|
||||
|
||||
'Debug.Print("Saved: " & Now().ToString)
|
||||
Me.ExitOK = True
|
||||
'Disposing...
|
||||
Me.ZipIn = Nothing
|
||||
Me.ZipInOK = False
|
||||
Catch ex As Exception
|
||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
||||
Me.ExitOK = False
|
||||
End Try
|
||||
Return Me.ExitOK AndAlso ArchiveFile.Exists
|
||||
End Function
|
||||
Public Function Compress(ByVal FilePath As String) As Boolean
|
||||
Dim FL As New List(Of String) From {
|
||||
FilePath
|
||||
}
|
||||
Call Compress(FL)
|
||||
Return True
|
||||
End Function
|
||||
Public Function CompressToStream(ByVal FilePath As String) As Boolean
|
||||
Dim FL As New List(Of String) From {
|
||||
FilePath
|
||||
}
|
||||
Call CompressToStream(FL)
|
||||
Return True
|
||||
End Function
|
||||
|
||||
Public Function CompressToStream(ByVal FilePaths As List(Of String)) As Boolean
|
||||
Return CompressToStream(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()))
|
||||
End Function
|
||||
Public Function CompressToStream(ByVal Files As List(Of FileInfo)) As Boolean
|
||||
If Files.Count = 0 Then Return True
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
||||
|
||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
||||
Me._ArchiveFileStream = New MemoryStream
|
||||
Else
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
||||
End If
|
||||
|
||||
Try
|
||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
||||
Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
||||
If Me._ArchivePassword = "" Then
|
||||
Me.ZipIn.CompressFiles(Me._ArchiveFileStream, FilePaths)
|
||||
Else
|
||||
Me.ZipIn.EncryptHeaders = True
|
||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
||||
Me.ZipIn.CompressFilesEncrypted(Me._ArchiveFileStream, Me._ArchivePassword, FilePaths)
|
||||
End If
|
||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
||||
RaiseEvent FileStreamCreated()
|
||||
|
||||
'Debug.Print("Saved: " & Now().ToString)
|
||||
Me.ExitOK = True
|
||||
'Disposing...
|
||||
Me.ZipIn = Nothing
|
||||
Me.ZipInOK = False
|
||||
Catch ex As Exception
|
||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
||||
Me.ExitOK = False
|
||||
End Try
|
||||
Return Me.ExitOK
|
||||
End Function
|
||||
|
||||
Public Function CompressToStream(ByVal Files As List(Of FileInfo), ByRef TargetStream As IO.Stream) As Boolean
|
||||
If Files.Count = 0 Then Return True
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
||||
|
||||
If IsNothing(TargetStream) = True Then
|
||||
TargetStream = New MemoryStream
|
||||
End If
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
||||
Dim FilePaths As String() = New String() {}
|
||||
Try
|
||||
Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
|
||||
FilePaths = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
|
||||
If Me._ArchivePassword = "" Then
|
||||
Me.ZipIn.CompressFiles(TargetStream, FilePaths)
|
||||
Else
|
||||
Me.ZipIn.EncryptHeaders = True
|
||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
||||
Me.ZipIn.CompressFilesEncrypted(TargetStream, Me._ArchivePassword, FilePaths)
|
||||
End If
|
||||
TargetStream.Seek(0, SeekOrigin.Begin)
|
||||
|
||||
'Debug.Print("Saved: " & Now().ToString)
|
||||
Me.ExitOK = True
|
||||
'Disposing...
|
||||
Me.ZipIn = Nothing
|
||||
Me.ZipInOK = False
|
||||
Catch ex As Exception
|
||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
||||
OCMS.debug_log("IntranetController zip", ex, data:=New With {.filepaths = FilePaths})
|
||||
Me.ExitOK = False
|
||||
End Try
|
||||
Return Me.ExitOK
|
||||
End Function
|
||||
|
||||
|
||||
Public Function CompressToStream(ByVal Files As Dictionary(Of String, IO.Stream)) As Boolean
|
||||
If Files.Count = 0 Then Return True
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
||||
|
||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
||||
Me._ArchiveFileStream = New MemoryStream
|
||||
Else
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
||||
End If
|
||||
|
||||
Try
|
||||
If Me._ArchivePassword = "" Then
|
||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream)
|
||||
Else
|
||||
Me.ZipIn.EncryptHeaders = True
|
||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream, password:=Me._ArchivePassword)
|
||||
End If
|
||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
||||
RaiseEvent FileStreamCreated()
|
||||
|
||||
'Debug.Print("Saved: " & Now().ToString)
|
||||
Me.ExitOK = True
|
||||
'Disposing...
|
||||
Me.ZipIn = Nothing
|
||||
Me.ZipInOK = False
|
||||
Catch ex As Exception
|
||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
||||
Me.ExitOK = False
|
||||
End Try
|
||||
Return Me.ExitOK
|
||||
End Function
|
||||
Public Function CompressToStream(ByVal Files As Dictionary(Of String, Byte()), Optional targetstream As IO.Stream = Nothing) As Boolean
|
||||
If Files.Count = 0 Then Return True
|
||||
If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
|
||||
|
||||
If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Create
|
||||
Me._ArchiveFileStream = New MemoryStream
|
||||
Else
|
||||
Me.ZipIn.CompressionMode = CompressionMode.Append
|
||||
End If
|
||||
|
||||
Try
|
||||
Dim FilesStreams As New Dictionary(Of String, IO.Stream)
|
||||
For Each fy As String In Files.Keys
|
||||
FilesStreams.Add(fy, New IO.MemoryStream(Files(fy)))
|
||||
Next
|
||||
|
||||
If Me._ArchivePassword = "" Then
|
||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream))
|
||||
Else
|
||||
Me.ZipIn.EncryptHeaders = True
|
||||
Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
|
||||
Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream), password:=Me._ArchivePassword)
|
||||
End If
|
||||
Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
|
||||
RaiseEvent FileStreamCreated()
|
||||
|
||||
'Debug.Print("Saved: " & Now().ToString)
|
||||
Me.ExitOK = True
|
||||
'Disposing...
|
||||
Me.ZipIn = Nothing
|
||||
Me.ZipInOK = False
|
||||
Catch ex As Exception
|
||||
'Debug.Print("NOT Saved: " & Now().ToString)
|
||||
Me.ExitOK = False
|
||||
End Try
|
||||
Return Me.ExitOK
|
||||
End Function
|
||||
|
||||
|
||||
Public Function WriteArchiveStreamToDisk(Optional ByVal ArchiveFile As IO.FileInfo = Nothing) As Boolean
|
||||
Try
|
||||
If Me._ArchiveFile.Exists() Then Me._ArchiveFile.Delete()
|
||||
Catch ex As Exception
|
||||
End Try
|
||||
If IsNothing(ArchiveFile) = False Then 'Wenn ein DateiPfad hier übergeben wurde...
|
||||
WriteStreamToDisk(Me._ArchiveFileStream, ArchiveFile.FullName)
|
||||
Else ' sonst wird der interne genommen
|
||||
WriteStreamToDisk(Me._ArchiveFileStream, Me._ArchiveFile.FullName)
|
||||
End If
|
||||
Return Me._ArchiveFile.Exists
|
||||
End Function
|
||||
|
||||
|
||||
|
||||
'Private ArchiveMail As EMail = Nothing
|
||||
'Public Function SendZip(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
|
||||
' Try
|
||||
' If ArchiveMail Is Nothing Then Exit Function
|
||||
' If Not Subject = Nothing Then
|
||||
' ArchiveMail.Subject = Subject
|
||||
' End If
|
||||
' If Not BodyText = Nothing Then
|
||||
' ArchiveMail.Body = BodyText
|
||||
' End If
|
||||
' 'Send with archive as attachment
|
||||
' Return ArchiveMail.Send(_ArchivePath.Path)
|
||||
' Catch ex As Exception
|
||||
' Return False
|
||||
' End Try
|
||||
'End Function
|
||||
'Public Function SendZipStream(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
|
||||
' Try
|
||||
' If ArchiveMail Is Nothing Then Exit Function
|
||||
' If Not Subject = Nothing Then
|
||||
' ArchiveMail.Subject = Subject
|
||||
' End If
|
||||
' If Not BodyText = Nothing Then
|
||||
' ArchiveMail.Body = BodyText
|
||||
' End If
|
||||
' 'Send with archive as attachment
|
||||
' Return ArchiveMail.Send(_ArchivePath.Name, _ArchiveFileStream)
|
||||
' Catch ex As Exception
|
||||
' Return False
|
||||
' End Try
|
||||
'End Function
|
||||
'Public Sub SetMailSettings(ByVal SMTP As MailServer_Settings, ByVal MAIL As Mail_Settings)
|
||||
' ArchiveMail = New EMail(SMTP, MAIL)
|
||||
'End Sub
|
||||
|
||||
|
||||
#Region "IDisposable Support"
|
||||
Private disposedValue As Boolean ' To detect redundant calls
|
||||
|
||||
' IDisposable
|
||||
Protected Overridable Sub Dispose(disposing As Boolean)
|
||||
If Not disposedValue Then
|
||||
If disposing Then
|
||||
' TODO: dispose managed state (managed objects).
|
||||
Try
|
||||
If IsNothing(Me._ArchiveFileStream) = False Then Me._ArchiveFileStream.Dispose()
|
||||
If IsNothing(Me.ZipOut) = False Then Me.ZipOut.Dispose()
|
||||
Me.ZipIn = Nothing
|
||||
Catch ex As Exception
|
||||
|
||||
End Try
|
||||
End If
|
||||
|
||||
' TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
|
||||
' TODO: set large fields to null.
|
||||
End If
|
||||
disposedValue = True
|
||||
End Sub
|
||||
|
||||
' TODO: override Finalize() only if Dispose(disposing As Boolean) above has code to free unmanaged resources.
|
||||
'Protected Overrides Sub Finalize()
|
||||
' ' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
|
||||
' Dispose(False)
|
||||
' MyBase.Finalize()
|
||||
'End Sub
|
||||
|
||||
' This code added by Visual Basic to correctly implement the disposable pattern.
|
||||
Public Sub Dispose() Implements IDisposable.Dispose
|
||||
' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
|
||||
Dispose(True)
|
||||
' TODO: uncomment the following line if Finalize() is overridden above.
|
||||
' GC.SuppressFinalize(Me)
|
||||
End Sub
|
||||
#End Region
|
||||
|
||||
|
||||
End Class
|
||||
|
||||
|
||||
Public Module Zipping
|
||||
|
||||
Public SevenZipPath As String = ""
|
||||
|
||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal ArchiveFile As FileInfo)
|
||||
If FileToZip.Exists AndAlso IsNothing(ArchiveFile) = False AndAlso ArchiveFile.Exists Then
|
||||
Dim Zip As New Archive(ArchiveFile) With {
|
||||
.ZipAppend = True
|
||||
}
|
||||
Dim FL As New List(Of String) From {
|
||||
FileToZip.FullName
|
||||
}
|
||||
Zip.Compress(FL)
|
||||
End If
|
||||
End Sub
|
||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectory As DirectoryInfo, ByVal ArchiveName As String)
|
||||
Dim ArchiveFile As New FileInfo(TgtArchiveDirectory.FullName & If(Strings.Right(TgtArchiveDirectory.FullName, 1) = "\", "", "\") & ArchiveName)
|
||||
If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
|
||||
End Sub
|
||||
Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectoryPath As String, ByVal ArchiveName As String)
|
||||
Dim ArchiveFile As New FileInfo(TgtArchiveDirectoryPath & If(Strings.Right(TgtArchiveDirectoryPath, 1) = "\", "", "\") & ArchiveName)
|
||||
If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
|
||||
End Sub
|
||||
Public Function FastZip(ByVal DirectoryToZip As DirectoryInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
||||
If DirectoryToZip.Exists Then
|
||||
Dim ZipFile As New FileInfo(DirectoryToZip.Name + ".7z"), cnt As Integer = 0
|
||||
If Append = False Then
|
||||
Do Until ZipFile.Exists = False
|
||||
cnt += 1
|
||||
ZipFile = New FileInfo(DirectoryToZip.Name & "_" & CStr(cnt) & ".7z")
|
||||
Loop
|
||||
End If
|
||||
Dim Zip As New Archive(ZipFile) With {
|
||||
.ZipAppend = Append
|
||||
}
|
||||
Zip.Compress(New List(Of FileInfo)(DirectoryToZip.GetFiles))
|
||||
Return If(ZipFile.Exists, ZipFile, Nothing)
|
||||
Else
|
||||
Return Nothing
|
||||
End If
|
||||
End Function
|
||||
Public Function FastZip(ByVal FileToZip As IO.FileInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
||||
If FileToZip.Exists Then
|
||||
Dim ZipFile As New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") + ".7z"), cnt As Integer = 0
|
||||
If Append = False Then
|
||||
Do Until ZipFile.Exists = False
|
||||
cnt += 1
|
||||
ZipFile = New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") & "_" & CStr(cnt) & ".7z")
|
||||
Loop
|
||||
End If
|
||||
Dim Zip As New Archive(ZipFile) With {
|
||||
.ZipAppend = Append
|
||||
}
|
||||
Dim FL As New List(Of FileInfo) From {
|
||||
FileToZip
|
||||
}
|
||||
Zip.Compress(FL)
|
||||
Return If(ZipFile.Exists, ZipFile, Nothing)
|
||||
Else
|
||||
Return Nothing
|
||||
End If
|
||||
End Function
|
||||
Public Function FastZip(ByVal TgtDirectory As String, ByVal Filename As String, Optional ByVal Append As Boolean = True) As IO.FileInfo
|
||||
Return FastZip(New FileInfo(TgtDirectory & If(Strings.Right(TgtDirectory, 1) = "\", "", "\") & Filename), Append)
|
||||
End Function
|
||||
|
||||
|
||||
|
||||
|
||||
End Module
|
||||
|
||||
End Namespace
|
||||
@@ -0,0 +1 @@
|
||||
Fuchs_Dataservice.exe install --autostart
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.AspNet.Razor" version="3.2.9" targetFramework="net48" />
|
||||
<package id="Microsoft.Web.Infrastructure" version="2.0.0" targetFramework="net48" />
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
<package id="Squid-Box.SevenZipSharp" version="1.6.1.23" targetFramework="net48" />
|
||||
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net48" />
|
||||
<package id="Topshelf" version="4.3.0" targetFramework="net48" />
|
||||
</packages>
|
||||
@@ -0,0 +1 @@
|
||||
Fuchs_Dataservice.exe uninstall
|
||||
+19
-14
@@ -1,17 +1,20 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using programmersdigest.MT940Parser;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Banking helper robustness tests.
|
||||
/// Banking service robustness tests.
|
||||
/// </summary>
|
||||
public class BankingDebitCreditMarkTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
[Theory]
|
||||
[InlineData(DebitCreditMark.Credit, "C")]
|
||||
[InlineData(DebitCreditMark.Debit, "D")]
|
||||
@@ -19,18 +22,20 @@ public class BankingDebitCreditMarkTests
|
||||
[InlineData(DebitCreditMark.ReverseDebit, "RD")]
|
||||
public void DebitCreditMarkAbb_ReturnsExpected(DebitCreditMark mark, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Banking.DebitCreditMarkAbb(mark));
|
||||
Assert.Equal(expected, Svc.DebitCreditMarkAbb(mark));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebitCreditMarkAbb_UndefinedValue_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", Banking.DebitCreditMarkAbb((DebitCreditMark)999));
|
||||
Assert.Equal("", Svc.DebitCreditMarkAbb((DebitCreditMark)999));
|
||||
}
|
||||
}
|
||||
|
||||
public class BankingParseToDatatableTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
private static readonly string MinimalMT940 =
|
||||
"\r\n:20:STARTUMSE\r\n" +
|
||||
":25:DE12345678901234567890\r\n" +
|
||||
@@ -48,7 +53,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_ReturnsOneRow()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(1, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasAccountColumn()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("AccountIdentification"));
|
||||
Assert.Equal("DE12345678901234567890", table.Rows[0]["AccountIdentification"]);
|
||||
}
|
||||
@@ -65,7 +70,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasAmountColumn()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("Amount"));
|
||||
Assert.Equal(500m, table.Rows[0]["Amount"]);
|
||||
}
|
||||
@@ -74,7 +79,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasDebitCreditMark()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal("C", table.Rows[0]["DebitCreditMark"]);
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_EmptyStream_ReturnsEmptyTable()
|
||||
{
|
||||
using var stream = ToStream("");
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(0, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_EmptyStream_HasDefaultSchema()
|
||||
{
|
||||
using var stream = ToStream("");
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("AccountIdentification"));
|
||||
Assert.True(table.Columns.Contains("Amount"));
|
||||
Assert.True(table.Columns.Contains("DebitCreditMark"));
|
||||
@@ -104,7 +109,7 @@ public class BankingParseToDatatableTests
|
||||
schema.Columns.Add("Amount", typeof(decimal));
|
||||
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream, schemaDatatable: schema);
|
||||
var table = Svc.ParseToDatatable(stream, schemaDatatable: schema);
|
||||
Assert.Equal(2, table.Columns.Count);
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ public class BankingParseToDatatableTests
|
||||
{
|
||||
var multi = MinimalMT940 + "\n" + MinimalMT940;
|
||||
using var stream = ToStream(multi);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(2, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_MalformedContent_DoesNotThrow()
|
||||
{
|
||||
using var stream = ToStream("This is not MT940 data at all");
|
||||
var ex = Record.Exception(() => Banking.ParseToDatatable(stream));
|
||||
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using CAMTParser;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the ISO 20022 CAMT parser and for the BankingService dual-format
|
||||
/// (CAMT + MT940) routing. Covers both succeeding and failing/edge inputs.
|
||||
/// </summary>
|
||||
public class CamtParserTests
|
||||
{
|
||||
// camt.053 with one incoming (CRDT) and one outgoing (DBIT) entry.
|
||||
private const string Camt053 = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<GrpHdr><MsgId>MSG1</MsgId><CreDtTm>2023-01-31T08:00:00</CreDtTm></GrpHdr>
|
||||
<Stmt>
|
||||
<Id>STMT-1</Id>
|
||||
<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>
|
||||
<ValDt><Dt>2023-01-16</Dt></ValDt>
|
||||
<NtryRef>REF-CR-1</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<Refs><EndToEndId>E2E-CR-1</EndToEndId></Refs>
|
||||
<RltdPties>
|
||||
<Dbtr><Nm>Max Mustermann</Nm></Dbtr>
|
||||
<DbtrAcct><Id><IBAN>DE99888877776666555544</IBAN></Id></DbtrAcct>
|
||||
</RltdPties>
|
||||
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">89.90</Amt>
|
||||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||
<BookgDt><Dt>2023-01-16</Dt></BookgDt>
|
||||
<ValDt><Dt>2023-01-16</Dt></ValDt>
|
||||
<NtryRef>REF-DB-1</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<Refs><MndtId>MND-1</MndtId></Refs>
|
||||
<RltdPties>
|
||||
<Cdtr><Nm>Stadtwerke</Nm></Cdtr>
|
||||
<CdtrAcct><Id><IBAN>DE11223344556677889900</IBAN></Id></CdtrAcct>
|
||||
</RltdPties>
|
||||
<RmtInf><Ustrd>Strom Januar</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
// ── Detection ──────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void LooksLikeXml_XmlContent_True()
|
||||
=> Assert.True(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(Camt053)));
|
||||
|
||||
[Fact]
|
||||
public void LooksLikeXml_Mt940Content_False()
|
||||
=> Assert.False(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(":20:STARTUMSE\r\n:25:DE123\r\n")));
|
||||
|
||||
[Fact]
|
||||
public void LooksLikeXml_Empty_False()
|
||||
=> Assert.False(CamtParser.LooksLikeXml(Array.Empty<byte>()));
|
||||
|
||||
// ── Successful parse ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Parse_Camt053_ReturnsOneStatementWithAccountAndTwoEntries()
|
||||
{
|
||||
var statements = new CamtParser().Parse(Camt053);
|
||||
var stmt = Assert.Single(statements);
|
||||
Assert.Equal(CamtDocumentType.Camt053, stmt.DocumentType);
|
||||
Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
|
||||
Assert.Equal(2, stmt.Entries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Camt053_CreditEntry_MappedCorrectly()
|
||||
{
|
||||
var stmt = new CamtParser().Parse(Camt053).Single();
|
||||
var credit = stmt.Entries[0];
|
||||
Assert.Equal(500.00m, credit.Amount);
|
||||
Assert.Equal("EUR", credit.Currency);
|
||||
Assert.Equal(CamtDebitCreditMark.Credit, credit.Mark);
|
||||
Assert.Equal("C", credit.MarkAbbreviation);
|
||||
Assert.Equal(new DateTime(2023, 1, 15), credit.EntryDate);
|
||||
Assert.Equal(new DateTime(2023, 1, 16), credit.ValueDate);
|
||||
Assert.Equal("Max Mustermann", credit.CounterpartyName); // payer for a credit
|
||||
Assert.Equal("DE99888877776666555544", credit.CounterpartyIban);
|
||||
Assert.Equal("E2E-CR-1", credit.EndToEndReference);
|
||||
Assert.Equal("Rechnung 4711", credit.RemittanceUnstructured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Camt053_DebitEntry_MappedCorrectly()
|
||||
{
|
||||
var stmt = new CamtParser().Parse(Camt053).Single();
|
||||
var debit = stmt.Entries[1];
|
||||
Assert.Equal(89.90m, debit.Amount);
|
||||
Assert.Equal(CamtDebitCreditMark.Debit, debit.Mark);
|
||||
Assert.Equal("D", debit.MarkAbbreviation);
|
||||
Assert.Equal("Stadtwerke", debit.CounterpartyName); // payee for a debit
|
||||
Assert.Equal("MND-1", debit.MandateReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NamespaceVersionAgnostic_StillParses()
|
||||
{
|
||||
// Different schema version namespace (…001.08) must parse identically.
|
||||
string v8 = Camt053.Replace("camt.053.001.02", "camt.053.001.08");
|
||||
var stmt = new CamtParser().Parse(v8).Single();
|
||||
Assert.Equal(2, stmt.Entries.Count);
|
||||
Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReversalEntry_MarkedAsReverseCredit()
|
||||
{
|
||||
string xml = """
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt><Stmt>
|
||||
<Acct><Id><IBAN>DE00</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><RvslInd>true</RvslInd>
|
||||
<BookgDt><Dt>2023-02-01</Dt></BookgDt>
|
||||
</Ntry>
|
||||
</Stmt></BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
var entry = new CamtParser().Parse(xml).Single().Entries.Single();
|
||||
Assert.Equal(CamtDebitCreditMark.ReverseCredit, entry.Mark);
|
||||
Assert.Equal("RC", entry.MarkAbbreviation);
|
||||
}
|
||||
|
||||
// ── Failing / edge inputs ───────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Parse_EmptyString_ReturnsEmptyList()
|
||||
=> Assert.Empty(new CamtParser().Parse(""));
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidXml_ThrowsFormatException()
|
||||
=> Assert.Throws<FormatException>(() => new CamtParser().Parse("<broken"));
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonCamtXml_ReturnsNoStatements()
|
||||
=> Assert.Empty(new CamtParser().Parse("<root><foo>bar</foo></root>"));
|
||||
}
|
||||
|
||||
/// <summary>BankingService routing: CAMT and MT940 both land in the same DataTable.</summary>
|
||||
public class BankingDualFormatTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
private static Stream ToStream(string s) => new MemoryStream(Encoding.UTF8.GetBytes(s));
|
||||
|
||||
private const string Camt053 = """
|
||||
<?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>Max Mustermann</Nm></Dbtr></RltdPties>
|
||||
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt></BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
private const string MinimalMT940 =
|
||||
"\r\n:20:STARTUMSE\r\n:25:DE12345678901234567890\r\n:28C:00001/001\r\n" +
|
||||
":60F:C230101EUR1000,00\r\n:61:2301010101CR500,00NTRFREF123\r\n:86:Gutschrift\r\n" +
|
||||
":62F:C230101EUR1500,00\r\n-\r\n";
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Camt_RoutesToCamtAndMapsColumns()
|
||||
{
|
||||
using var s = ToStream(Camt053);
|
||||
var t = Svc.ParseToDatatable(s);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||
Assert.Equal(500.00m, t.Rows[0]["Amount"]);
|
||||
Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
|
||||
Assert.Equal("Max Mustermann", t.Rows[0]["NameOfPayer"]);
|
||||
Assert.Equal("Rechnung 4711", t.Rows[0]["SepaRemittanceInformation"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Mt940_StillRoutesToMt940()
|
||||
{
|
||||
using var s = ToStream(MinimalMT940);
|
||||
var t = Svc.ParseToDatatable(s);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||
Assert.Equal(500m, t.Rows[0]["Amount"]);
|
||||
Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Camt_RespectsCustomSchema()
|
||||
{
|
||||
var schema = new DataTable();
|
||||
schema.Columns.Add("AccountIdentification", typeof(string));
|
||||
schema.Columns.Add("Amount", typeof(decimal));
|
||||
using var s = ToStream(Camt053);
|
||||
var t = Svc.ParseToDatatable(s, schemaDatatable: schema);
|
||||
Assert.Equal(2, t.Columns.Count);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the refactored data holders (FdsInvoiceData / FdsReminderData).
|
||||
/// These are now pure data objects — persistence/PDF moved to the DI services.
|
||||
/// </summary>
|
||||
public class FdsDataTests
|
||||
{
|
||||
[Fact]
|
||||
public void FdsInvoiceData_Empty_DefaultsToDraftWithNoRegistration()
|
||||
{
|
||||
var inv = new FdsInvoiceData();
|
||||
Assert.True(inv.IsDraft);
|
||||
Assert.Null(inv.InvoiceRegistration);
|
||||
Assert.Equal("", inv.Id);
|
||||
Assert.Equal("R", inv.InvoiceType); // fallback when no registration
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsInvoiceData_ParsesFormSections()
|
||||
{
|
||||
var jo = JObject.Parse(
|
||||
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"42\" }, " +
|
||||
" \"new\": { \"title\": \"Test\", \"total_gross\": \"119.00\" }, " +
|
||||
" \"sms\": { \"tscn\": \"10\" }, " +
|
||||
" \"req\": [] }");
|
||||
var inv = new FdsInvoiceData(jo);
|
||||
|
||||
Assert.NotNull(inv.Admin);
|
||||
Assert.NotNull(inv.NewValues);
|
||||
Assert.NotNull(inv.Sms);
|
||||
Assert.Equal("R", inv.Admin!.getString("type"));
|
||||
Assert.Equal("Test", inv.NewValues!.getString("title"));
|
||||
Assert.True(inv.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsInvoiceData_BuildInvoiceParams_PicksHighestVatAndMapsFields()
|
||||
{
|
||||
// VAT comes from the sms.vat map (rate → amount); highest rate (19) selected.
|
||||
var jo = JObject.Parse(
|
||||
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"7\", \"p13b\": false }, " +
|
||||
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"paymentterm\": \"10d\" }, " +
|
||||
" \"sms\": { \"vat\": { \"7,0%\": 7.0, \"19,0%\": 19.0 } } }");
|
||||
var inv = new FdsInvoiceData(jo);
|
||||
|
||||
var pl = inv.BuildInvoiceParams(change: false, invId: "");
|
||||
var vat = pl.Find(p => p.ParameterName == "@InvoiceVAT_1");
|
||||
var title = pl.Find(p => p.ParameterName == "@InvoiceTitle");
|
||||
|
||||
Assert.NotNull(vat);
|
||||
Assert.Equal("19", vat!.Value);
|
||||
Assert.Equal("Bad", title!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsReminderData_Empty_DefaultsToDraft()
|
||||
{
|
||||
var rem = new FdsReminderData();
|
||||
Assert.True(rem.IsDraft);
|
||||
Assert.Null(rem.ReminderRegistration);
|
||||
Assert.Equal("", rem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsReminderData_ParsesFormSections()
|
||||
{
|
||||
var jo = JObject.Parse(
|
||||
"{ \"new\": { \"amount\": \"50\", \"invoiceemail\": \"a@b.de\" }, " +
|
||||
" \"rem\": { \"type\": \"f\", \"invid\": \"123\" } }");
|
||||
var rem = new FdsReminderData(jo);
|
||||
|
||||
Assert.NotNull(rem.NewValues);
|
||||
Assert.NotNull(rem.Rem);
|
||||
Assert.Equal("50", rem.NewValues!.getString("amount"));
|
||||
Assert.Equal("f", rem.Rem!.getString("type"));
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for the non-DB parts of the restored report engine and the
|
||||
/// giro-code QR generator. The SQL-driven report rendering itself requires a
|
||||
/// live report catalog and is validated separately against the database.
|
||||
/// </summary>
|
||||
public class FuchsReportRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetPaycode_ProducesNonEmptyBitmap()
|
||||
{
|
||||
using var bmp = FuchsPdf.GetPaycode(
|
||||
iban: "DE52301502000002091478", bic: "WELADED1KSD",
|
||||
name: "Sebastian Fuchs Bad und Heizung", amount: 123.45m, purpose: "Rechnung 4711");
|
||||
Assert.NotNull(bmp);
|
||||
Assert.True(bmp.Width > 0);
|
||||
Assert.True(bmp.Height > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtmlPage_Web_FallbackShell_EmbedsContentTitleAndReload()
|
||||
{
|
||||
var page = new FuchsHtmlPage("My Report", templatePath: "", queryDuration: 0)
|
||||
{
|
||||
ReloadSeconds = 60
|
||||
};
|
||||
page.Add("<div id=\"frm\">hello</div>");
|
||||
|
||||
string html = page.ToHtml(FdsDestination.web);
|
||||
|
||||
Assert.Contains("hello", html);
|
||||
Assert.Contains("My Report", html); // title injected
|
||||
Assert.Contains("http-equiv=\"refresh\"", html); // reload meta injected
|
||||
Assert.Contains("content=\"60\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtmlPage_Content_OmitsReloadAndTitleChrome()
|
||||
{
|
||||
var page = new FuchsHtmlPage("Ignored", templatePath: "");
|
||||
page.Add("<span>fragment</span>");
|
||||
|
||||
string html = page.ToHtml(FdsDestination.content);
|
||||
|
||||
Assert.Contains("fragment", html);
|
||||
Assert.DoesNotContain("http-equiv=\"refresh\"", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the posted <c>admin</c> flags map to the <c>@InvoiceOptions</c>
|
||||
/// CSV the backend persists — the channel both §13b and the set-pricing
|
||||
/// <c>setmode</c> token ride. The PDF reads the mode back via
|
||||
/// <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/>.
|
||||
/// </summary>
|
||||
public class InvoiceOptionsTests
|
||||
{
|
||||
private static string InvoiceOptionsFor(object? admin)
|
||||
{
|
||||
var root = new JObject();
|
||||
if (admin != null) root["admin"] = JObject.FromObject(admin);
|
||||
var data = new FdsInvoiceData(root);
|
||||
var p = data.BuildInvoiceParams(change: false, invId: "")
|
||||
.First(x => x.ParameterName == "@InvoiceOptions");
|
||||
return p.Value is DBNull or null ? "" : p.Value!.ToString()!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoFlags_EmptyOptions()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||
|
||||
[Fact]
|
||||
public void DefaultSetPrice_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||
|
||||
[Theory]
|
||||
[InlineData("itemprices", "setmode:itemprices")]
|
||||
[InlineData("setonly", "setmode:setonly")]
|
||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
||||
public void SetMode_EmitsToken(string mode, string expected)
|
||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
||||
|
||||
[Fact]
|
||||
public void UnknownSetMode_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||
|
||||
[Fact]
|
||||
public void P13bOnly_EmitsLegacyToken()
|
||||
=> Assert.Equal("§13b", InvoiceOptionsFor(new { type = "r", p13b = true }));
|
||||
|
||||
[Fact]
|
||||
public void P13bAndSetMode_EmitsBoth()
|
||||
=> Assert.Equal("§13b,setmode:setonly", InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" }));
|
||||
|
||||
// ── VAT: highest rate + amount taken from the sms.vat map ─────────────────
|
||||
private static GenericObjectDictionary SmsWithVat(params (string rate, double amount)[] vat)
|
||||
{
|
||||
var map = new Dictionary<string, double>();
|
||||
foreach (var (r, a) in vat) map[r] = a;
|
||||
return new GenericObjectDictionary(new Dictionary<string, object> { ["vat"] = JObject.FromObject(map) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_PicksHighestRate_GermanFormatted()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("19,0%", 38.0), ("7,0%", 7.0)));
|
||||
Assert.Equal("19", rate); // integer, invariant
|
||||
Assert.Equal("38", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_NonIntegerRate_Preserved()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("10,5%", 5.25)));
|
||||
Assert.Equal("10.5", rate); // invariant decimal point — safe for numeric(5,2)
|
||||
Assert.Equal("5.25", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_EmptyOrMissing_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(null));
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(new GenericObjectDictionary(new Dictionary<string, object>())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_FlowsIntoParams()
|
||||
{
|
||||
var root = new JObject
|
||||
{
|
||||
["admin"] = JObject.FromObject(new { type = "r" }),
|
||||
["sms"] = JObject.FromObject(new Dictionary<string, object>
|
||||
{
|
||||
["vat"] = new Dictionary<string, double> { ["19,0%"] = 38.0 }
|
||||
})
|
||||
};
|
||||
var pars = new FdsInvoiceData(root).BuildInvoiceParams(false, "");
|
||||
Assert.Equal("19", pars.First(p => p.ParameterName == "@InvoiceVAT_1").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||
{
|
||||
// The token this side emits must parse back to the same mode on the PDF side.
|
||||
Assert.Equal(SetDisplayMode.ItemPrices,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||
Assert.Equal(SetDisplayMode.SetOnly,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the set-pricing display modes — the agreed interplay
|
||||
/// between the front-end (which picks the mode and groups items) and the
|
||||
/// back-end PDF renderer (which honours ShowPrice / IsSetHeader). Set items use
|
||||
/// type="set"; members carry setId == the set header's id.
|
||||
/// </summary>
|
||||
public class InvoiceSetPricingTests
|
||||
{
|
||||
private static Dictionary<string, object?> SetHeader(string id, string title, string? total = null) => new()
|
||||
{
|
||||
["type"] = "set", ["id"] = id, ["title"] = title, ["qty"] = "1",
|
||||
["total_net"] = total ?? "0", ["price_net"] = total ?? "0"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Member(string setId, string title, string price, string total) => new()
|
||||
{
|
||||
["setId"] = setId, ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Standalone(string title, string price, string total) => new()
|
||||
{
|
||||
["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
// A set (sum 1000) with two members, plus one standalone item (50).
|
||||
private static List<Dictionary<string, object?>> Sample() => new()
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
Member("10", "Waschbecken", "600.00", "600.00"),
|
||||
Member("10", "Armatur", "400.00", "400.00"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsSets_DetectsHeaderOrMember()
|
||||
{
|
||||
Assert.True(InvoiceSetPricing.ContainsSets(Sample()));
|
||||
Assert.False(InvoiceSetPricing.ContainsSets(new List<Dictionary<string, object?>> { Standalone("X", "1", "1") }));
|
||||
}
|
||||
|
||||
// ── SetPrice (default): set priced, members blank ─────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetPrice_SetLinePricedMembersBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
|
||||
|
||||
Assert.Equal(4, lines.Count); // header + 2 members + standalone
|
||||
var header = lines[0];
|
||||
Assert.True(header.IsSetHeader);
|
||||
Assert.True(header.ShowPrice);
|
||||
Assert.Equal(1000.00m, header.TotalNet);
|
||||
|
||||
Assert.False(lines[1].ShowPrice); // Waschbecken — no price
|
||||
Assert.False(lines[2].ShowPrice); // Armatur — no price
|
||||
Assert.Equal("Waschbecken", lines[1].Title);
|
||||
|
||||
Assert.True(lines[3].ShowPrice); // standalone keeps price
|
||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||
}
|
||||
|
||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
||||
[Fact]
|
||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
||||
|
||||
Assert.Equal(4, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
||||
Assert.True(lines[3].ShowPrice); // standalone
|
||||
}
|
||||
|
||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetOnly);
|
||||
|
||||
Assert.Equal(2, lines.Count); // set header + standalone only
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.True(lines[0].ShowPrice);
|
||||
Assert.Equal(1000.00m, lines[0].TotalNet);
|
||||
Assert.False(lines[1].IsSetHeader);
|
||||
Assert.Equal("Anfahrt", lines[1].Title);
|
||||
}
|
||||
|
||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"), // total 0
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
||||
}
|
||||
|
||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_NoSets_PassThroughAllPriced()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
Standalone("A", "10.00", "10.00"),
|
||||
Standalone("B", "20.00", "20.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.All(lines, l => Assert.True(l.ShowPrice));
|
||||
Assert.All(lines, l => Assert.False(l.IsSetHeader));
|
||||
}
|
||||
|
||||
// ── Text/Title lines never show a price (any mode) ────────────────────────
|
||||
private static Dictionary<string, object?> TextLine(string title) => new()
|
||||
{
|
||||
["type"] = "title", ["title"] = title, ["qty"] = "", ["price_net"] = "", ["total_net"] = ""
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_Standalone_NoPrice()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
TextLine("Leistungszeitraum 2026"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.False(lines[0].ShowPrice); // heading — blank price
|
||||
Assert.True(lines[1].ShowPrice); // real item — priced
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||
Member("10", "Waschbecken", "600.00", "600.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
|
||||
var note = lines.First(l => l.Title == "Hinweis");
|
||||
Assert.False(note.ShowPrice); // text member stays blank
|
||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
[Fact]
|
||||
public void SetPrice_EqualsSumOfMembers()
|
||||
{
|
||||
var setLine = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice).First(l => l.IsSetHeader);
|
||||
decimal memberSum = Sample()
|
||||
.Where(i => i.TryGetValue("setId", out var s) && s?.ToString() == "10")
|
||||
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(memberSum, setLine.TotalNet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the outbound communication service. Cover both intentionally
|
||||
/// succeeding (API 200) and intentionally failing (invalid email, disabled,
|
||||
/// API 500) paths, plus the attachment payload contract and metric emission.
|
||||
///
|
||||
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
|
||||
/// which is wrapped in try/catch and therefore harmless in a unit test.
|
||||
/// </summary>
|
||||
public class ProcessWebComServiceTests
|
||||
{
|
||||
// ── Test doubles ────────────────────────────────────────────────────────
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body = "OK")
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
BaseUrl = "https://mailer.test",
|
||||
AccountId = "acct",
|
||||
Token = "tok"
|
||||
});
|
||||
return new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance,
|
||||
intranet: null!,
|
||||
settings,
|
||||
new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
// ── Success path ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Failure paths ──────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_ReturnsFalse()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
[InlineData("missing@")]
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount); // never reached the API
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, enabled: false);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Attachment payload contract ────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
byte[] pdf = { 1, 2, 3, 4 };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendSmsAsync("", "Code 1234");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Metric emission (performance indicator) ────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_IncrementsEmailsSentCounter()
|
||||
{
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.sent")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
|
||||
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
|
||||
}
|
||||
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
}
|
||||
@@ -15,12 +15,15 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Bankings(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Bankings action={Action} user={User}", id, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "auth":
|
||||
return await JSONAsync(new { manage = 1 });
|
||||
|
||||
case "up":
|
||||
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
|
||||
Request.Form.Files.Count, UserAccountID);
|
||||
foreach (var fle in Request.Form.Files)
|
||||
{
|
||||
using var stream = fle.OpenReadStream();
|
||||
@@ -29,7 +32,7 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).DataTable;
|
||||
|
||||
var tbl = Banking.ParseToDatatable(stream, schemaDt);
|
||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
||||
|
||||
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
||||
@@ -50,6 +53,8 @@ public partial class IntranetController
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
_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();
|
||||
}
|
||||
return Ok();
|
||||
@@ -76,11 +81,13 @@ public partial class IntranetController
|
||||
string mode = Form("mode").ToLower();
|
||||
if (mode == "s" && Form("tgt").Contains(':'))
|
||||
{
|
||||
// Search mode: @tgtdate is unused by the proc but required by its signature.
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@mode", Form("mode")),
|
||||
SQL_VarChar("@search", Form("tgt")));
|
||||
SQL_Date("@tgtdate", DBNull.Value),
|
||||
SQL_VarChar("@mode", Form("mode").ne("m")),
|
||||
SQL_VarChar("@search", Form("tgt")));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getBankingtransactions_list2] @mode, @search, @authuser;",
|
||||
"EXECUTE [dbo].[fds__getBankingtransfers_list2] @tgtdate, @mode, @search, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "bank" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
@@ -17,34 +17,51 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Invoices(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Invoices called: fn={Fn} id={Id} code={Code} user={User}", fn, id, code, UserAccountID);
|
||||
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "auth":
|
||||
_logger.LogDebug("Invoice auth check for user {User}", UserAccountID);
|
||||
return await JSONAsync(new { manage = 1 });
|
||||
|
||||
case "setpyd":
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
return await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoicePayed] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))
|
||||
? Ok() : StatusCode(500);
|
||||
if (!HasForm("id")) { _logger.LogWarning("setpyd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
{
|
||||
var invoiceId = Form("id");
|
||||
_logger.LogInformation("setpyd: marking invoice {InvoiceId} as paid, user={User}", invoiceId, UserAccountID);
|
||||
var ok = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoicePayed] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
||||
return ok ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
case "setupd":
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
return await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceUNPayed] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))
|
||||
? Ok() : StatusCode(500);
|
||||
if (!HasForm("id")) { _logger.LogWarning("setupd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
{
|
||||
var invoiceId = Form("id");
|
||||
_logger.LogInformation("setupd: marking invoice {InvoiceId} as unpaid, user={User}", invoiceId, UserAccountID);
|
||||
var ok = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceUNPayed] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
|
||||
return ok ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
case "setvat":
|
||||
if (!float.TryParse(Form("val").Replace("%", "").Replace(",", ".").Trim(),
|
||||
NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out float vatVal))
|
||||
return BadRequest400();
|
||||
{
|
||||
_logger.LogWarning("setvat: invalid VAT value '{Val}', user={User}", Form("val"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
{
|
||||
_logger.LogInformation("setvat: setting VAT {Vat} on report {ReportId}, user={User}", vatVal, Form("id"), UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_BigInt("@id", Form("id")),
|
||||
SQL_VarChar("@entitytype", "report"),
|
||||
@@ -53,42 +70,90 @@ public partial class IntranetController
|
||||
string sqlEx = ""; int? sqlCode = null;
|
||||
setSQLValue("EXECUTE [dbo].[fds__setReportVAT] @id, @entitytype, @vat, @authuser;",
|
||||
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
|
||||
if (!string.IsNullOrEmpty(sqlEx))
|
||||
_logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID);
|
||||
return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx });
|
||||
}
|
||||
|
||||
case "sis":
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("sis: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
{
|
||||
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@auto", false));
|
||||
var invoiceId = Form("id");
|
||||
_logger.LogInformation("sis: marking invoice {InvoiceId} as sent, user={User}", invoiceId, UserAccountID);
|
||||
var pl = StdParamlist(SQL_VarChar("@Id", invoiceId), SQL_Bit("@auto", false));
|
||||
var dt2 = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt2.Exception))
|
||||
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
|
||||
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
case "pget": return await HandleInvoicePget(fn, id, code);
|
||||
case "get": return await HandleInvoiceGet(fn, id, code);
|
||||
case "icget": return await HandleInvoiceIcGet(fn, id, code);
|
||||
case "pget":
|
||||
_logger.LogDebug("pget: invoice PDF get, user={User}", UserAccountID);
|
||||
return await HandleInvoicePget(fn, id, code);
|
||||
|
||||
case "get":
|
||||
_logger.LogDebug("get: invoice get, user={User}", UserAccountID);
|
||||
return await HandleInvoiceGet(fn, id, code);
|
||||
|
||||
case "icget":
|
||||
_logger.LogDebug("icget: invoice IC get, user={User}", UserAccountID);
|
||||
return await HandleInvoiceIcGet(fn, id, code);
|
||||
|
||||
case "storno":
|
||||
case "credit": return await HandleInvoiceStornoCredit(fn, id, code);
|
||||
case "invl": return await HandleInvoiceList(fn, id, code);
|
||||
case "rqi": return await HandleInvoiceRequestItems(fn, id, code);
|
||||
case "pyi": return await HandleInvoicePayments(fn, id, code);
|
||||
case "datev": return await HandleDatev(fn, id, code);
|
||||
case "rdoc": return await HandleReportDoc(fn, id, code, Form("id"));
|
||||
case "rdocn": return await HandleReportDocByName(fn, id, code);
|
||||
case "datevzip": return await HandleDatevZip(fn, id, code);
|
||||
case "getrem": return await HandleGetReminder(fn, id, code);
|
||||
case "credit":
|
||||
_logger.LogInformation("{Action}: invoice storno/credit, user={User}", id, UserAccountID);
|
||||
return await HandleInvoiceStornoCredit(fn, id, code);
|
||||
|
||||
case "invl":
|
||||
_logger.LogDebug("invl: invoice list, user={User}", UserAccountID);
|
||||
return await HandleInvoiceList(fn, id, code);
|
||||
|
||||
case "rqi":
|
||||
_logger.LogDebug("rqi: invoice request items, user={User}", UserAccountID);
|
||||
return await HandleInvoiceRequestItems(fn, id, code);
|
||||
|
||||
case "pyi":
|
||||
_logger.LogDebug("pyi: invoice payments, user={User}", UserAccountID);
|
||||
return await HandleInvoicePayments(fn, id, code);
|
||||
|
||||
case "datev":
|
||||
_logger.LogDebug("datev: DATEV export, user={User}", UserAccountID);
|
||||
return await HandleDatev(fn, id, code);
|
||||
|
||||
case "rdoc":
|
||||
_logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID);
|
||||
return await HandleReportDoc(fn, id, code, Form("id"));
|
||||
|
||||
case "rdocn":
|
||||
_logger.LogDebug("rdocn: report document get by name, user={User}", UserAccountID);
|
||||
return await HandleReportDocByName(fn, id, code);
|
||||
|
||||
case "datevzip":
|
||||
_logger.LogDebug("datevzip: DATEV ZIP export, user={User}", UserAccountID);
|
||||
return await HandleDatevZip(fn, id, code);
|
||||
|
||||
case "getrem":
|
||||
_logger.LogDebug("getrem: get reminder for invoice, user={User}", UserAccountID);
|
||||
return await HandleGetReminder(fn, id, code);
|
||||
|
||||
case "mfrrel":
|
||||
if (!HasForm("id") || !long.TryParse(Form("id"), out long relId)) return BadRequest400();
|
||||
using (var mfr = new fds.FdsMfrClient())
|
||||
if (!HasForm("id") || !long.TryParse(Form("id"), out long relId))
|
||||
{
|
||||
_logger.LogWarning("mfrrel: missing or invalid form field 'id', user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
_logger.LogInformation("mfrrel: resetting MFR relation for invoice {InvoiceId}, user={User}", relId, UserAccountID);
|
||||
using (var mfr = _mfrFactory.Create())
|
||||
await mfr.Update__entitytable(EntityTypes.Invoice,
|
||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||
return Ok();
|
||||
|
||||
default: return Ok();
|
||||
default:
|
||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!long.TryParse(Form("id"), out long tgtid)) return BadRequest400();
|
||||
using (var mfr = new fds.FdsMfrClient())
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
|
||||
|
||||
using (var mfr = _mfrFactory.Create())
|
||||
{
|
||||
_logger.LogDebug("HandleInvoicePget resetting invoice entity tgtid={TgtId}", tgtid);
|
||||
await mfr.Update__entitytable(EntityTypes.Invoice,
|
||||
fds.FdsMfr.UpdateNeed.Reset, new[] { tgtid });
|
||||
}
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fds__getInvoiceTreeIds](@srqid);",
|
||||
@@ -30,6 +35,7 @@ public partial class IntranetController
|
||||
StdParamlist(SQL_BigInt("@srqid", tgtid)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
_logger.LogDebug("HandleInvoicePget tree query returned {Count} rows for tgtid={TgtId}", dt.Count, tgtid);
|
||||
if (dt.Count > 0)
|
||||
{
|
||||
var invIds = new List<long>();
|
||||
@@ -43,11 +49,14 @@ public partial class IntranetController
|
||||
case "servicerequest": if (iid > 0 && !srqIds.Contains(iid)) srqIds.Add(iid); break;
|
||||
}
|
||||
}
|
||||
using var mfr2 = new fds.FdsMfrClient();
|
||||
_logger.LogDebug("HandleInvoicePget resetting {InvCount} invoices and {SrqCount} service requests", invIds.Count, srqIds.Count);
|
||||
using var mfr2 = _mfrFactory.Create();
|
||||
foreach (var iid in invIds)
|
||||
await mfr2.Update__entitytable(EntityTypes.Invoice, fds.FdsMfr.UpdateNeed.Reset, new[] { iid });
|
||||
foreach (var iid in srqIds)
|
||||
await mfr2.Update__entitytable(EntityTypes.ServiceRequest, fds.FdsMfr.UpdateNeed.Reset, new[] { iid });
|
||||
_logger.LogInformation("HandleInvoicePget reset complete for tgtid={TgtId} invoices={InvCount} serviceRequests={SrqCount} user={User}",
|
||||
tgtid, invIds.Count, srqIds.Count, UserAccountID);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
@@ -56,35 +65,48 @@ public partial class IntranetController
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoiceGet: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||
var sqldset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||
tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
var ldic = BuildInvoiceRequestList(sqldset);
|
||||
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
||||
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
|
||||
if (invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b"))
|
||||
bool has13b = invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b");
|
||||
if (has13b)
|
||||
adminDic["p13b"] = true;
|
||||
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
|
||||
invoiceId, ldic.Count, has13b, UserAccountID);
|
||||
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
||||
}
|
||||
catch { return StatusCode(500); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", Form("id"), UserAccountID);
|
||||
return StatusCode(500);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleInvoiceIcGet(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoiceIcGet: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
_logger.LogDebug("HandleInvoiceIcGet (storno/recreate prep) invoiceId={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||
var sqldset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__prepStorno_recreate] @InvId, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@InvId", invoiceId)),
|
||||
tablenames: new[] { "admin", "requests", "items", "steps", "companies", "locations" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
var ldic = BuildRequestItemList(sqldset);
|
||||
_logger.LogDebug("HandleInvoiceIcGet invoiceId={InvoiceId} requestCount={ReqCount} user={User}",
|
||||
invoiceId, ldic.Count, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = sqldset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
@@ -96,34 +118,47 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleInvoiceStornoCredit(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id", "mode")) return BadRequest400();
|
||||
string sqlcmd = Form("mode") switch
|
||||
if (!HasForm("id", "mode")) { _logger.LogWarning("HandleInvoiceStornoCredit: missing required form fields user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
string mode = Form("mode");
|
||||
_logger.LogDebug("HandleInvoiceStornoCredit invoiceId={InvoiceId} mode={Mode} user={User}", invoiceId, mode, UserAccountID);
|
||||
string sqlcmd = mode switch
|
||||
{
|
||||
"credit" => "EXECUTE [dbo].[fds__createCredit_simple] @Id, @authuser;",
|
||||
"simple" => "EXECUTE [dbo].[fds__createStorno_simple] @Id, @authuser;",
|
||||
"copy" => "EXECUTE [dbo].[fds__createStorno_copy] @Id, @authuser;",
|
||||
_ => ""
|
||||
};
|
||||
if (string.IsNullOrEmpty(sqlcmd)) return StatusCode(500, new { error = "function not allowed" });
|
||||
if (string.IsNullOrEmpty(sqlcmd))
|
||||
{
|
||||
_logger.LogWarning("HandleInvoiceStornoCredit: unknown mode={Mode} invoiceId={InvoiceId} user={User}", mode, invoiceId, UserAccountID);
|
||||
return StatusCode(500, new { error = "function not allowed" });
|
||||
}
|
||||
_logger.LogInformation("HandleInvoiceStornoCredit executing mode={Mode} invoiceId={InvoiceId} user={User}", mode, invoiceId, UserAccountID);
|
||||
var sqldset = await getSQLDataSet_async(sqlcmd,
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@Id", invoiceId)),
|
||||
tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
var reqList = BuildInvoiceRequestList(sqldset);
|
||||
_logger.LogDebug("HandleInvoiceStornoCredit complete mode={Mode} invoiceId={InvoiceId} requestCount={ReqCount} user={User}",
|
||||
mode, invoiceId, reqList.Count, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = sqldset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
inv = sqldset.Table("inv").FirstRow.toObjectDictionary(),
|
||||
req = BuildInvoiceRequestList(sqldset)
|
||||
req = reqList
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleInvoiceList(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("mode")) return BadRequest400();
|
||||
if (!HasForm("mode")) { _logger.LogWarning("HandleInvoiceList: missing 'mode' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string mode = Form("mode").ToLower();
|
||||
_logger.LogDebug("HandleInvoiceList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
|
||||
if (mode == "s" && Form("tgt").Contains(':'))
|
||||
{
|
||||
_logger.LogDebug("HandleInvoiceList using search path mode={Mode} search={Search} user={User}", mode, Form("tgt"), UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_Date("@tgtdate", DBNull.Value),
|
||||
SQL_VarChar("@mode", Form("mode").ne("m")),
|
||||
@@ -134,6 +169,8 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "invoices" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleInvoiceList search returned {Count} invoices user={User}",
|
||||
dset.Tables("invoices").Rows.Count, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
@@ -142,17 +179,25 @@ public partial class IntranetController
|
||||
}
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||
return BadRequest400();
|
||||
{
|
||||
_logger.LogWarning("HandleInvoiceList: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
{
|
||||
string includes = Form("includes").ne(Form("all") == "true" ? "all" : "");
|
||||
_logger.LogDebug("HandleInvoiceList date-based tgtdate={TgtDate} mode={Mode} includes={Includes} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), mode, includes, UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_Date("@tgtdate", tgtdate),
|
||||
SQL_VarChar("@mode", Form("mode").ne("m")),
|
||||
SQL_VarChar("@includes", Form("includes").ne(Form("all") == "true" ? "all" : "")));
|
||||
SQL_VarChar("@includes", includes));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoices_list_vario] @tgtdate, @mode, @includes, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "invoices" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleInvoiceList date-based returned {Count} invoices user={User}",
|
||||
dset.Tables("invoices").Rows.Count, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
@@ -163,11 +208,13 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleInvoiceRequestItems(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoiceRequestItems: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
_logger.LogDebug("HandleInvoiceRequestItems invoiceId={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||
var sqldt = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvRequestItems] @invoiceid, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@invoiceid", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@invoiceid", invoiceId)),
|
||||
tablenames: new[] { "requests", "items" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
var ldic = new List<Dictionary<string, object?>>();
|
||||
@@ -180,18 +227,25 @@ public partial class IntranetController
|
||||
.Select(r => r.toObjectDictionary()).ToList();
|
||||
ldic.Add(sdic!);
|
||||
}
|
||||
_logger.LogDebug("HandleInvoiceRequestItems invoiceId={InvoiceId} requestCount={ReqCount} user={User}",
|
||||
invoiceId, ldic.Count, UserAccountID);
|
||||
return await JSONAsync(new { requests = ldic });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleInvoicePayments(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePayments: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
_logger.LogDebug("HandleInvoicePayments invoiceId={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||
var sqldt = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvPayments] @invoiceid, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@invoiceid", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@invoiceid", invoiceId)),
|
||||
tablenames: new[] { "items" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
int paymentCount = sqldt.Tables("items").Rows.Count;
|
||||
_logger.LogDebug("HandleInvoicePayments invoiceId={InvoiceId} paymentCount={PaymentCount} user={User}",
|
||||
invoiceId, paymentCount, UserAccountID);
|
||||
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
@@ -199,13 +253,20 @@ public partial class IntranetController
|
||||
{
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||
{
|
||||
_logger.LogWarning("HandleDatev: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
string mode = Form("mode").ne("m");
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", Form("mode").ne("m"))),
|
||||
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
|
||||
tablenames: new[] { "files", "invoices", "debits" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
files = dset.Tables("files").toArrayofObjectDictionaries(),
|
||||
@@ -215,9 +276,16 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleReportDoc(string fn, string id, string code, string reportid)
|
||||
{
|
||||
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
|
||||
byte[]? content = null;
|
||||
var file = _mfr.GetReportDoc(ref content, reportid);
|
||||
if (file == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
if (file == null)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
}
|
||||
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
|
||||
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
|
||||
@@ -225,46 +293,76 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("name")) return BadRequest400();
|
||||
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string nme = Form("name").LeftToFirst("(").Trim();
|
||||
if (string.IsNullOrEmpty(nme)) return StatusCode(404);
|
||||
_logger.LogDebug("HandleReportDocByName name='{Name}' user={User}", nme, UserAccountID);
|
||||
if (string.IsNullOrEmpty(nme))
|
||||
{
|
||||
_logger.LogWarning("HandleReportDocByName: empty name after trim user={User}", UserAccountID);
|
||||
return StatusCode(404);
|
||||
}
|
||||
var so = await getSQLValue_async(
|
||||
"SELECT [dbo].[fds__fn_InvoiceIdByName](@nme);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@nme", nme)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
string reportid = so.Result?.ToString() ?? "";
|
||||
return string.IsNullOrEmpty(reportid)
|
||||
? StatusCode(404)
|
||||
: await HandleReportDoc(fn, id, code, reportid);
|
||||
if (string.IsNullOrEmpty(reportid))
|
||||
{
|
||||
_logger.LogWarning("HandleReportDocByName: no invoice found for name='{Name}' user={User}", nme, UserAccountID);
|
||||
return StatusCode(404);
|
||||
}
|
||||
_logger.LogDebug("HandleReportDocByName resolved name='{Name}' to reportid={ReportId} user={User}", nme, reportid, UserAccountID);
|
||||
return await HandleReportDoc(fn, id, code, reportid);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleDatevZip(string fn, string id, string code)
|
||||
{
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||
{
|
||||
_logger.LogWarning("HandleDatevZip: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
string mode = Form("mode").ne("m");
|
||||
bool includeFiles = Form("files", "1") != "0";
|
||||
_logger.LogDebug("HandleDatevZip tgtdate={TgtDate} mode={Mode} includeFiles={IncludeFiles} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), mode, includeFiles, UserAccountID);
|
||||
Stream? ms = new MemoryStream();
|
||||
var file = _mfr.GetDatevZip(ref ms, tgtdate,
|
||||
mode: Form("mode").ne("m"),
|
||||
mode: mode,
|
||||
authUser: UserAccountID,
|
||||
includeFiles: Form("files", "1") != "0");
|
||||
if (file == null) return BadRequest400();
|
||||
includeFiles: includeFiles);
|
||||
if (file == null)
|
||||
{
|
||||
_logger.LogWarning("HandleDatevZip: zip generation returned null for tgtdate={TgtDate} mode={Mode} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
_logger.LogInformation("HandleDatevZip sending file='{FileName}' tgtdate={TgtDate} user={User}",
|
||||
file.Name, tgtdate.ToString("yy-MM-dd"), UserAccountID);
|
||||
ms!.Position = 0;
|
||||
return await FileStreamResultAsync(ms, file.MimeType(), file.Name);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleGetReminder(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleGetReminder: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
string invoiceId = Form("id");
|
||||
string includeDrafts = Form("drafts");
|
||||
_logger.LogDebug("HandleGetReminder invoiceId={InvoiceId} includeDrafts={IncludeDrafts} user={User}",
|
||||
invoiceId, includeDrafts, UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@InvId", Form("id")),
|
||||
SQL_Bit("@include_drafts", Form("drafts")));
|
||||
SQL_VarChar("@InvId", invoiceId),
|
||||
SQL_Bit("@include_drafts", includeDrafts));
|
||||
var sqldt = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoiceReminder] @InvId, @include_drafts, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "reminder" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
int reminderCount = sqldt.Table("reminder").DataTable.Rows.Count;
|
||||
_logger.LogDebug("HandleGetReminder invoiceId={InvoiceId} reminderCount={ReminderCount} user={User}",
|
||||
invoiceId, reminderCount, UserAccountID);
|
||||
return await JSONAsync(sqldt.Table("reminder").DataTable.toArrayofObjectDictionaries());
|
||||
}
|
||||
|
||||
@@ -315,9 +413,9 @@ public partial class IntranetController
|
||||
return ldic;
|
||||
}
|
||||
|
||||
private static async Task<string[]> BuildPdfImageArray(byte[] content)
|
||||
private async Task<string[]> BuildPdfImageArray(byte[] content)
|
||||
{
|
||||
var imgcol = await FuchsPdf.BytesToImageCollection(content);
|
||||
var imgcol = await _pdf.BytesToImageCollectionAsync(content);
|
||||
return imgcol.ImgB64Array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
@@ -16,6 +17,7 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Reminder(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Reminder action={Action} user={User}", id, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "get":
|
||||
@@ -37,11 +39,11 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("remc")) return BadRequest400();
|
||||
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
|
||||
var fdRem = new FdsReminderData(ctd);
|
||||
fdRem.RegisterReminder(this, change: false, remId: "");
|
||||
var fdRem = await _reminders.RegisterReminderAsync(
|
||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdRem.Id))
|
||||
{
|
||||
var imgcol = await FuchsPdf.DocToImageCollection(fdRem.ReminderPDF(this));
|
||||
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" });
|
||||
@@ -63,12 +65,11 @@ public partial class IntranetController
|
||||
case "rdoc":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
byte[]? fc = null;
|
||||
var file = FdsReminderData.GetStoredFile(ref fc, Form("id"), this);
|
||||
if (file == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(fc!, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc!) });
|
||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
||||
}
|
||||
|
||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||
@@ -106,8 +107,8 @@ public partial class IntranetController
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
string remId = frdic["Id"]?.ToString() ?? "";
|
||||
var fdRem = new FdsReminderData(remId, this);
|
||||
byte[] filebyte = await fdRem.StoreReminderDocumentFile(this);
|
||||
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
|
||||
byte[] filebyte = await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec);
|
||||
string email = frdic.nz("SendToEmail", "");
|
||||
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
||||
{
|
||||
@@ -117,11 +118,13 @@ public partial class IntranetController
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
|
||||
bool sent = await FuchsFdsEmail.SendEmail(
|
||||
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
|
||||
remId, email.Trim(), UserAccountID);
|
||||
bool sent = await _comService.SendEmailAsync(
|
||||
$"inv_{remId}",
|
||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
||||
email.Trim(), "", remdoc, _intranet);
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
||||
@@ -139,17 +142,17 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
var fdRem = new FdsReminderData(Form("id"), this);
|
||||
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (string.IsNullOrEmpty(fdRem.Id)) return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" });
|
||||
string filename = fdRem.ReminderRegistration.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
byte[] ct = Form("create", "0") != "1"
|
||||
? (await fdRem.GetReminderFile(this)) is { Length: > 0 } f1 ? f1 : await fdRem.StoreReminderDocumentFile(this)
|
||||
: FuchsPdf.DocToPdfBytes(fdRem.ReminderPDF(this));
|
||||
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
|
||||
}
|
||||
var imgcol = await FuchsPdf.DocToImageCollection(fdRem.ReminderPDF(this));
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
@@ -175,10 +178,10 @@ public partial class IntranetController
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
await FuchsFdsEmail.SendEmail($"rem_{remId}",
|
||||
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, _intranet);
|
||||
email.Trim(), "", remdoc);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Reports(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Reports action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "auth":
|
||||
@@ -46,7 +47,7 @@ public partial class IntranetController
|
||||
}
|
||||
|
||||
default:
|
||||
return await FuchsReports.ProcessFdsRequest(this, id.ToLower(), code);
|
||||
return await _reports.ProcessRequestAsync(id.ToLower(), code, UserAccountID, DbSec, RequestParamsDict());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MFR_RESTClient.generic;
|
||||
@@ -18,6 +19,7 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Requests(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Requests action={Action} user={User}", id, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "auth":
|
||||
@@ -44,8 +46,9 @@ public partial class IntranetController
|
||||
case "save":
|
||||
{
|
||||
if (!HasForm("invc")) return BadRequest400();
|
||||
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
|
||||
fdInv.RegisterInvoice(this, change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"));
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
|
||||
return !string.IsNullOrEmpty(fdInv.Id)
|
||||
? await JSONAsync(new { id = fdInv.Id })
|
||||
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" });
|
||||
@@ -54,11 +57,12 @@ public partial class IntranetController
|
||||
case "sprep":
|
||||
{
|
||||
if (!HasForm("invc")) return BadRequest400();
|
||||
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
|
||||
fdInv.RegisterInvoice(this, change: false, invId: "");
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: false, invId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
|
||||
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" });
|
||||
@@ -67,11 +71,12 @@ public partial class IntranetController
|
||||
case "sedit":
|
||||
{
|
||||
if (!HasForm("id", "invc")) return BadRequest400();
|
||||
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
|
||||
fdInv.RegisterInvoice(this, change: true, invId: Form("id"));
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
|
||||
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" });
|
||||
@@ -170,7 +175,7 @@ public partial class IntranetController
|
||||
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
|
||||
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
|
||||
};
|
||||
using var mfr = new fds.FdsMfrClient();
|
||||
using var mfr = _mfrFactory.Create();
|
||||
await mfr.Update__entitytable(EntityTypes.ServiceRequest,
|
||||
fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic);
|
||||
return Ok();
|
||||
@@ -257,8 +262,8 @@ public partial class IntranetController
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
string invId = frdic["Id"]?.ToString() ?? "";
|
||||
var fdInv = new FdsInvoiceData(invId, this);
|
||||
byte[] filebyte = await fdInv.StoreInvoiceDocumentFile(this);
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
|
||||
byte[] filebyte = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
|
||||
var dtset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
@@ -273,9 +278,11 @@ 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");
|
||||
string body = BuildInvoiceBody(bal, terms);
|
||||
bool sent = await FuchsFdsEmail.SendEmail(
|
||||
_logger.LogInformation("Request sconf: emailing finalized invoice {InvId} to {Email} user={User}",
|
||||
invId, email.Trim(), UserAccountID);
|
||||
bool sent = await _comService.SendEmailAsync(
|
||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||
body, email.Trim(), "", inv, _intranet);
|
||||
body, email.Trim(), "", inv);
|
||||
if (sent)
|
||||
{
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
||||
@@ -292,19 +299,19 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
var fdInv = new FdsInvoiceData(Form("id"), this);
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (string.IsNullOrEmpty(fdInv.Id)) return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" });
|
||||
string filename = fdInv.InvoiceRegistration.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
|
||||
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await fdInv.GetInvoiceFile(this) is { Length: > 0 } f1 ? f1 : await fdInv.StoreInvoiceDocumentFile(this)
|
||||
: FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this));
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
|
||||
}
|
||||
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
@@ -321,18 +328,17 @@ public partial class IntranetController
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
string invId = frdic["Id"]?.ToString() ?? "";
|
||||
var fdInv = new FdsInvoiceData(invId, this);
|
||||
byte[] filebyte = FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this));
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
|
||||
byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
|
||||
string email = frdic.nz("SendToEmail", "");
|
||||
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
||||
{
|
||||
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
||||
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||
await FuchsFdsEmail.SendEmail(
|
||||
await _comService.SendEmailAsync(
|
||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte },
|
||||
_intranet);
|
||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Web;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -24,6 +25,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
internal readonly Fuchs_intranet _intranet;
|
||||
internal readonly fds.IFdsMfr _mfr;
|
||||
private readonly ILogger<IntranetController> _logger;
|
||||
private readonly IComService _comService;
|
||||
private readonly IBankingService _banking;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IMfrClientFactory _mfrFactory;
|
||||
private readonly IWidgetService _widgets;
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -39,11 +48,40 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
public string UserAccountID => UserIdent.UserAccountId;
|
||||
public string AuthAccount => UserIdent.Email;
|
||||
|
||||
public IntranetController(Fuchs_intranet intranet, fds.IFdsMfr mfr, ILogger<IntranetController> logger)
|
||||
public IntranetController(
|
||||
Fuchs_intranet intranet,
|
||||
fds.IFdsMfr mfr,
|
||||
ILogger<IntranetController> logger,
|
||||
IComService comService,
|
||||
IBankingService banking,
|
||||
IPdfService pdf,
|
||||
IMfrClientFactory mfrFactory,
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
_logger = logger;
|
||||
_comService = comService;
|
||||
_banking = banking;
|
||||
_pdf = pdf;
|
||||
_mfrFactory = mfrFactory;
|
||||
_widgets = widgets;
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
}
|
||||
|
||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||
internal Dictionary<string, string> RequestParamsDict()
|
||||
{
|
||||
var prms = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in Request.Query) prms[kv.Key] = kv.Value.ToString();
|
||||
if (Request.HasFormContentType)
|
||||
foreach (var kv in Request.Form) prms[kv.Key] = kv.Value.ToString();
|
||||
return prms;
|
||||
}
|
||||
|
||||
// ── Standard param list (pre-populates @authuser) ────────────────────────
|
||||
@@ -73,23 +111,29 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
|
||||
// ── Index (GET /) ─────────────────────────────────────────────────────────
|
||||
[AllowAnonymous]
|
||||
public IActionResult Index(string? fn, string? id, string? code) =>
|
||||
public IActionResult Index([FromRoute] string? fn, [FromRoute] string? id, [FromRoute] string? code) =>
|
||||
View("intranet");
|
||||
|
||||
// ── Do (POST+GET /do/{fn}/{id}/{code}) ─────────────────────────────────
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Do(string? fn, string? id, string? code)
|
||||
public async Task<IActionResult> Do([FromRoute] string? fn, [FromRoute] string? id, [FromRoute] string? code)
|
||||
{
|
||||
fn = (fn ?? "").ToLower();
|
||||
id ??= "";
|
||||
code ??= "";
|
||||
bool isGet = HttpContext.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogDebug("Do dispatching {Fn}/{Id}/{Code} [{Method}] user={User}",
|
||||
fn, id, code, HttpContext.Request.Method, UserAccountID);
|
||||
|
||||
if (!UserIdent.IsAuthenticated && !(new string[] { "login","logout" }).Contains(fn.ToLower()) && !_allowedNonAuth.Contains(fn.ToLower()))
|
||||
{
|
||||
if (!_allowedGet.Contains(fn.ToLower()) && !_allowedGet.Contains($"{fn.ToLower()}|{id.ToLower()}"))
|
||||
_logger.LogInformation($"rejected function on do {fn}");
|
||||
{
|
||||
_logger.LogWarning("Rejected unauthenticated request for fn={Fn} id={Id} ip={IP}",
|
||||
fn, id, HttpContext.Connection.RemoteIpAddress);
|
||||
return Unauthorized401();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
@@ -97,7 +141,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IActionResult? result = fn.ToLower() switch
|
||||
{
|
||||
"ping" => Ok(),
|
||||
"wdg" => await FuchsWidgets.IntranetWdg(this, id),
|
||||
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
|
||||
"todos" => new PhysicalFileResult(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
|
||||
"text/html"),
|
||||
@@ -116,12 +160,16 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"logout" => await HandleLogout(),
|
||||
_ => null
|
||||
};
|
||||
if (result == null)
|
||||
_logger.LogWarning("No handler matched fn={Fn}", fn);
|
||||
else
|
||||
_logger.LogDebug("Do completed fn={Fn}/{Id} result={ResultType}", fn, id, result.GetType().Name);
|
||||
return result ?? Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_intranet.debug_log("IntranetController.Do", ex, UserAccountID,
|
||||
data: new { fn, id, code });
|
||||
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||
fn, id, code, UserAccountID);
|
||||
return ServerError();
|
||||
}
|
||||
}
|
||||
@@ -129,8 +177,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
// ── Auth helper ───────────────────────────────────────────────────────────
|
||||
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
|
||||
{
|
||||
if (!Request.Form.ContainsKey("module")) return BadRequest400();
|
||||
if (!Request.Form.ContainsKey("module"))
|
||||
{
|
||||
_logger.LogWarning("HandleAuth called without 'module' form field by user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
string module = Request.Form["module"].ToString();
|
||||
_logger.LogDebug("HandleAuth module={Module} array={Array} user={User}",
|
||||
module, Request.Form["array"].ToString(), UserAccountID);
|
||||
if (Request.Form["array"] == "1")
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
@@ -138,6 +192,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@module", module)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleAuth returned {Count} module auth entries for user={User}",
|
||||
dt.DataTable.Rows.Count, UserAccountID);
|
||||
return await JSONAsync(dt.DataTable.ToDictionary(KeyColumn: "module", ValueColumn: "auth"));
|
||||
}
|
||||
else
|
||||
@@ -147,6 +203,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_intranet.Intranet__SQLConnectionString, -1,
|
||||
StdParamlist(SQL_VarChar("@module", module)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleAuth module={Module} auth={Auth} user={User}",
|
||||
module, val.Result, UserAccountID);
|
||||
return await JSONAsync(new { auth = val.Result });
|
||||
}
|
||||
}
|
||||
@@ -157,13 +215,13 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
{
|
||||
string email = Request.Form["userinfo"].ToString();
|
||||
string password = Request.Form["userpass"].ToString();
|
||||
_logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}",
|
||||
email, HttpContext.Connection.RemoteIpAddress);
|
||||
var row = await _intranet.AuthenticateAsync(email, password);
|
||||
if (row == null)
|
||||
{
|
||||
_logger.LogWarning("Login failed for '{Email}' from {IP}",
|
||||
_logger.LogWarning("Login failed for email={Email} ip={IP}",
|
||||
email, HttpContext.Connection.RemoteIpAddress);
|
||||
_intranet.debug_log("HandleLogin: failed",
|
||||
data: new { email, ip = HttpContext.Connection.RemoteIpAddress?.ToString() });
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
@@ -174,6 +232,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme);
|
||||
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
||||
await HttpContext.SignInAsync(Fuchs_intranet.AuthScheme, principal);
|
||||
_logger.LogInformation("Login succeeded for userId={UserId} email={Email} authorization={Auth} ip={IP}",
|
||||
userId, userEmail, auth, HttpContext.Connection.RemoteIpAddress);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
login = userEmail,
|
||||
@@ -187,7 +247,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
|
||||
private async Task<IActionResult> HandleLogout()
|
||||
{
|
||||
_logger.LogInformation("Logout user={User} ip={IP}",
|
||||
UserAccountID, HttpContext.Connection.RemoteIpAddress);
|
||||
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
|
||||
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -196,19 +259,27 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
{
|
||||
string? lastname = Request.Form["lastname"];
|
||||
string? email = Request.Form["email"];
|
||||
if (string.IsNullOrEmpty(lastname) || string.IsNullOrEmpty(email)) return BadRequest400();
|
||||
_logger.LogDebug("HandleSendPasswordCode called for email={Email}", email);
|
||||
if (string.IsNullOrEmpty(lastname) || string.IsNullOrEmpty(email))
|
||||
{
|
||||
_logger.LogWarning("HandleSendPasswordCode missing lastname or email");
|
||||
return BadRequest400();
|
||||
}
|
||||
|
||||
var row = await _intranet.GetUserAccountByEmailAsync(email);
|
||||
if (row != null && row.nz("email").Length > 5 &&
|
||||
row.nz("name").ToLower().Trim() == lastname.ToLower().Trim() &&
|
||||
row.nz("mobile").Length > 5 && !Request.Host.Host.ToLower().Contains("localhost"))
|
||||
{
|
||||
OCORE.sms.SMS77.Settings.APIKey = _intranet.Intranet__SMS_API_key;
|
||||
using var sms = new OCORE.sms.SMS77.SMS("ProcessWeb");
|
||||
_logger.LogInformation("Sending password code SMS to mobile for email={Email}", email);
|
||||
string totp = OCORE.security.TFA.generateTotp_12h(_intranet.Intranet__TOTPsharedsecret_base);
|
||||
if (long.TryParse(row.nz("mobile").Replace("+", "00").Replace(" ", ""), out long smsNum))
|
||||
sms.SendSMS_sync(smsNum,
|
||||
"Zur Bestätigung des Passwortversands auf sanitarfuchs.de, verwenden Sie bitte folgenden Code:" + totp);
|
||||
await _comService.SendSmsAsync(row.nz("mobile"),
|
||||
"Zur Bestätigung des Passwortversands auf sanitarfuchs.de, verwenden Sie bitte folgenden Code:" + totp);
|
||||
_logger.LogDebug("Password code SMS sent for email={Email}", email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("HandleSendPasswordCode: no SMS sent for email={Email} (user not found, name mismatch, no mobile, or localhost)", email);
|
||||
}
|
||||
return Ok(); // always OK to prevent enumeration
|
||||
}
|
||||
@@ -218,36 +289,57 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
string? lastname = Request.Form["lastname"];
|
||||
string? email = Request.Form["email"];
|
||||
string? totpCode = Request.Form["code"];
|
||||
_logger.LogDebug("HandleSendPassword called for email={Email}", email);
|
||||
if (string.IsNullOrEmpty(lastname) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(totpCode))
|
||||
return BadRequest400();
|
||||
|
||||
if (OCORE.security.TFA.validateTotp_12h(_intranet.Intranet__TOTPsharedsecret_base, totpCode).isVerifiedInTime)
|
||||
{
|
||||
_logger.LogWarning("HandleSendPassword missing required fields (lastname, email or code)");
|
||||
return BadRequest400();
|
||||
}
|
||||
|
||||
var totpResult = OCORE.security.TFA.validateTotp_12h(_intranet.Intranet__TOTPsharedsecret_base, totpCode);
|
||||
if (totpResult.isVerifiedInTime)
|
||||
{
|
||||
_logger.LogDebug("HandleSendPassword TOTP verified for email={Email}", email);
|
||||
var row = await _intranet.GetUserAccountByEmailAsync(email, includePassword: true);
|
||||
if (row != null && row.nz("email").Length > 5)
|
||||
{
|
||||
await FuchsFdsEmail.SendEmail("pw_" + row.nz("email"),
|
||||
_logger.LogInformation("Sending password email to email={Email}", email);
|
||||
await _comService.SendEmailAsync("pw_" + row.nz("email"),
|
||||
"sanitaerfuchs.de Intranet Passwort",
|
||||
$"<p>Guten Tag {row.nz("firstname")} {row.nz("name")},<br/>Ihr Passwort: {HttpUtility.HtmlEncode(row.nz("password"))}</p>",
|
||||
row.nz("email"), $"{row.nz("firstname")} {row.nz("name")}", null, _intranet);
|
||||
row.nz("email"), $"{row.nz("firstname")} {row.nz("name")}", null);
|
||||
_logger.LogDebug("Password email sent for email={Email}", email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("HandleSendPassword: user not found for email={Email}", email);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("HandleSendPassword: TOTP verification failed for email={Email}", email);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleAccount(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("HandleAccount action={Action} user={User}", id, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "sms":
|
||||
var row = await _intranet.GetUserAccountByEmailAsync(UserIdent.Email, includePassword: true);
|
||||
if (row != null && row.nz("mobile").Length > 5 && !Request.Host.Host.Contains("localhost"))
|
||||
{
|
||||
OCORE.sms.SMS77.Settings.APIKey = _intranet.Intranet__SMS_API_key;
|
||||
using var sms2 = new OCORE.sms.SMS77.SMS("ProcessWeb");
|
||||
_logger.LogInformation("Sending change-password confirmation SMS to user={User}", UserAccountID);
|
||||
string totp2 = OCORE.security.TFA.generateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR");
|
||||
if (long.TryParse(row.nz("mobile").Replace("+", "00").Replace(" ", ""), out long mob2))
|
||||
sms2.SendSMS_sync(mob2, "Zur Bestätigung der Passwortänderung auf sanitarfuchs.de: " + totp2);
|
||||
await _comService.SendSmsAsync(row.nz("mobile"),
|
||||
"Zur Bestätigung der Passwortänderung auf sanitarfuchs.de: " + totp2);
|
||||
_logger.LogDebug("Change-password SMS sent for user={User}", UserAccountID);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("HandleAccount sms: no SMS sent for user={User} (no mobile or localhost)", UserAccountID);
|
||||
}
|
||||
return Ok();
|
||||
|
||||
@@ -256,52 +348,110 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
string? npwc = Request.Form["npwc"];
|
||||
string? totpCode = Request.Form["code"];
|
||||
if (string.IsNullOrEmpty(npw) || string.IsNullOrEmpty(npwc) || string.IsNullOrEmpty(totpCode))
|
||||
{
|
||||
_logger.LogWarning("HandleAccount changepassword: missing required fields for user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
if (npw != npwc)
|
||||
{
|
||||
_logger.LogWarning("HandleAccount changepassword: password confirmation mismatch for user={User}", UserAccountID);
|
||||
return StatusCode(409, new { error = "match" });
|
||||
}
|
||||
if (!npw.ValidatePassword(minLength: 6, numSpecial: 0))
|
||||
{
|
||||
_logger.LogWarning("HandleAccount changepassword: new password does not meet requirements for user={User}", UserAccountID);
|
||||
return StatusCode(409, new { error = "requirements" });
|
||||
}
|
||||
if (!OCORE.security.TFA.validateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR", totpCode).isVerifiedInTime)
|
||||
{
|
||||
_logger.LogWarning("HandleAccount changepassword: TOTP verification failed for user={User}", UserAccountID);
|
||||
return StatusCode(409, new { error = "sms" });
|
||||
}
|
||||
// Verify the supplied current password actually belongs to this user before changing it
|
||||
string oldPw = Request.Form["opw"].ToString();
|
||||
var authDt = await getSQLDatatable_async(
|
||||
"SELECT TOP(1) * FROM [dbo].[fis_admin_authenticate_byID](@useraccount_id, @password);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@useraccount_id", UserAccountID),
|
||||
SQL_VarChar("@password", oldPw)
|
||||
},
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
var authRow = authDt.FirstRow.toObjectDictionary();
|
||||
if (!string.IsNullOrEmpty(UserAccountID) && authRow.nz("useraccount_id") != UserAccountID)
|
||||
{
|
||||
_logger.LogWarning("HandleAccount changepassword: current password verification failed for user={User}", UserAccountID);
|
||||
return StatusCode(409, new { error = "valid" });
|
||||
}
|
||||
_logger.LogInformation("Changing password for user={User}", UserAccountID);
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@useraccount_id", UserAccountID),
|
||||
SQL_VarChar("@oldpassword", Request.Form["opw"].ToString()),
|
||||
SQL_VarChar("@oldpassword", oldPw),
|
||||
SQL_VarChar("@newpassword", npw)
|
||||
},
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("Password changed successfully for user={User}", UserAccountID);
|
||||
return Ok();
|
||||
}
|
||||
_logger.LogWarning("HandleAccount unknown action={Action} user={User}", id, UserAccountID);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleMfr(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("HandleMfr id={Id} code={Code} user={User} auth={Auth}",
|
||||
id, code, UserAccountID, UserIdent.Authorization);
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
// Empty id → return the OData EDMX schema ($metadata), matching legacy fds.getSchema()
|
||||
using var mfrSchema = _mfrFactory.Create();
|
||||
string schema = await mfrSchema.ReadAnything(
|
||||
mfrSchema.ClientConfig.BaseUrl + "$metadata", throwErrorIfNotOk: false);
|
||||
return Content(schema, "text/xml", System.Text.Encoding.UTF8);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(UserAccountID) && UserIdent.Authorization > 3)
|
||||
{
|
||||
string path = id + (!string.IsNullOrEmpty(code) ? "/" + code : HttpUtility.UrlDecode(Request.QueryString.Value ?? ""));
|
||||
using var mfrRead = new fds.FdsMfrClient();
|
||||
_logger.LogDebug("HandleMfr reading OData path={Path} user={User}", path, UserAccountID);
|
||||
using var mfrRead = _mfrFactory.Create();
|
||||
var result = await mfrRead.ReadOData(path, throwErrorIfNotOk: false);
|
||||
_logger.LogDebug("HandleMfr OData read complete for path={Path} user={User}", path, UserAccountID);
|
||||
return Content(JsonConvert.SerializeObject(result), "application/json");
|
||||
}
|
||||
_logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}",
|
||||
UserAccountID, UserIdent.Authorization);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleMfrUpdate(string fn, string id, string code)
|
||||
{
|
||||
var et = EntityHelper.EntityValue(Request.Form["type"].ToString());
|
||||
string typeParam = Request.Form["type"].ToString();
|
||||
string needParam = Request.Form["need"].ToString();
|
||||
var et = EntityHelper.EntityValue(typeParam);
|
||||
_logger.LogDebug("HandleMfrUpdate type={Type} need={Need} user={User}", typeParam, needParam, UserAccountID);
|
||||
if (et != EntityTypes.none && string.IsNullOrEmpty(Request.Form["need"]))
|
||||
{
|
||||
using var mfrSingle = new fds.FdsMfrClient();
|
||||
_logger.LogInformation("MfrUpdate entity={EntityType} need=Short user={User}", et, UserAccountID);
|
||||
using var mfrSingle = _mfrFactory.Create();
|
||||
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
|
||||
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
|
||||
return Ok();
|
||||
}
|
||||
if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"]))
|
||||
{
|
||||
var need = fds.FdsMfr.UpdateNeedValue(Request.Form["need"].ToString());
|
||||
using var mfr = new fds.FdsMfrClient();
|
||||
var need = fds.FdsMfr.UpdateNeedValue(needParam);
|
||||
_logger.LogInformation("MfrUpdate entity={EntityType} need={Need} user={User}", et, need, UserAccountID);
|
||||
using var mfr = _mfrFactory.Create();
|
||||
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
|
||||
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
|
||||
return Ok();
|
||||
}
|
||||
_logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
|
||||
+52
-11
@@ -13,12 +13,14 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
|---|---|---|
|
||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||
| **OCORE_web** | Class Library (shared) | Web utilities: MVC helpers, middleware, auth, captcha |
|
||||
| **OCORE_web_pdf** | Class Library (shared) | PDF generation (MigraDoc/PDFsharp, HTML→PDF) |
|
||||
| **OCORE_Charting** | Class Library (shared) | Data visualization / charting (ported System.Windows.Forms.DataVisualization) |
|
||||
| **MT940Parser** | Class Library (shared) | SWIFT MT940/MT942 bank statement parser |
|
||||
| **MT940Parser** | Class Library (external) | SWIFT MT940/MT942 bank statement parser |
|
||||
| **CAMTParser** | Class Library (in-repo) | ISO 20022 CAMT (camt.052/053/054) bank statement parser |
|
||||
|
||||
**All projects target `net10.0`.**
|
||||
|
||||
@@ -163,8 +165,12 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
||||
### 4.2 Singleton Configuration Object
|
||||
`Fuchs_intranet` is a manually-managed singleton (via `FuchsOcmsIntranet`) initialized at startup with `IConfiguration`. It holds connection strings, app settings, auth helpers, and DB connection factory methods.
|
||||
|
||||
### 4.3 Static Business Logic Classes
|
||||
Most business logic lives in **static classes** (`FuchsPdf`, `FuchsWidgets`, `FuchsReports`, `FuchsFdsEmail`, `Banking`) that receive the controller instance or `Fuchs_intranet` as a parameter. This is a legacy pattern from the VB.NET conversion.
|
||||
### 4.3 Service Layer (Dependency Injection)
|
||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||
|
||||
### 4.4 SQL-First Data Access
|
||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||
@@ -177,9 +183,17 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
|
||||
|
||||
---
|
||||
|
||||
## 5. DI Service Extraction Candidates
|
||||
## 5. Service Layer (implemented)
|
||||
|
||||
The following static classes and tightly-coupled code sections are strong candidates for refactoring into proper DI-registered services. This improves testability, decouples dependencies, and aligns with ASP.NET Core best practices.
|
||||
> **Status: DONE.** The services below are implemented and DI-registered in
|
||||
> `Program.cs`. The original extraction rationale is retained for reference /
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
|
||||
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
|
||||
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
|
||||
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
|
||||
> (backed by the ported `FuchsVisualization` engine), `FdsMfrClient` →
|
||||
> `IMfrClientFactory`.
|
||||
|
||||
---
|
||||
|
||||
@@ -391,8 +405,35 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
|
||||
## 7. Additional Observations
|
||||
|
||||
1. **`System.Configuration.ConfigurationManager` usage** in `FuchsFdsEmail.cs` directly violates the project's coding standards (`appsettings.json` only).
|
||||
2. **No dependency injection in `FdsInvoiceData`/`FdsReminderData`** — these classes receive the entire controller, creating circular-style dependencies.
|
||||
3. **`FdsMfrClient` is `new`-ed directly** in controller partials (e.g., `IntranetController.Invoices2.cs`) instead of being injected.
|
||||
4. **`OCORE_Charting`** is in the solution but not directly referenced by any project — verify if it's still needed.
|
||||
5. **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
1. ✅ **Resolved** — email/SMS moved off `ConfigurationManager` into `IComService` (ProcessWeb Mailer API).
|
||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
|
||||
---
|
||||
|
||||
## 8. Observability (OpenTelemetry)
|
||||
|
||||
- Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs`: one `ActivitySource` and one `Meter` (`Fuchs.Intranet`).
|
||||
- **Metrics** — counters (`fuchs.invoices.rendered`, `fuchs.reminders.rendered`, `fuchs.reports.rendered`, `fuchs.emails.sent`/`.failed`, `fuchs.sms.sent`, `fuchs.banking.mt940.rows`, `fuchs.mfr.calls`) and duration histograms (`fuchs.pdf.render.duration`, `fuchs.report.render.duration`, `fuchs.email.send.duration`).
|
||||
- **Tracing** — ASP.NET Core, HttpClient and SqlClient instrumentation plus the app `ActivitySource`; services start spans for their key operations.
|
||||
- Configured in `Program.cs`. Always collected in-process; **OTLP export is opt-in** via `Fuchs:Telemetry:OtlpEndpoint` (and can be disabled with `Fuchs:Telemetry:Enabled=false`), so a missing collector never affects the app.
|
||||
- All services + handlers log entry/result/timing/errors via `ILogger<T>` with structured placeholders.
|
||||
|
||||
## 9. Bank Statement Parsing (MT940 + CAMT)
|
||||
|
||||
- `BankingService` (`IBankingService`) accepts **both** MT940 (SWIFT text, via the external `MT940Parser`) and **CAMT** (ISO 20022 camt.052/053/054 XML, via the in-repo `CAMTParser`).
|
||||
- `ParseToDatatable` **auto-detects** the format from content (XML → CAMT, else MT940) and maps either into the `fds__tt__bankingtransactions` schema; the `bam/up` handler and the frontend upload accept both.
|
||||
- `CAMTParser` matches elements by **local name** (namespace-agnostic) so it works across every camt schema version. When the banking schema changes, keep the MT940 and CAMT column mappings in `BankingService` aligned.
|
||||
|
||||
## 10. MFR ERP Integration
|
||||
|
||||
- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its integration contract — base URLs, auth, OData conventions, pagination, error/retry semantics, deep-create and document-upload endpoints — is documented in **`MFR_RESTClient/Docs/mfr_interface_description.md`**. Consult that file before changing the client.
|
||||
- The client uses HTTP Basic auth, a configurable timeout/user-agent, and **retries idempotent GETs** on transient failures (HTTP 429/5xx, network/timeout) with exponential backoff + jitter (honouring `Retry-After`). `ReadODataAllPages` follows `@odata.nextLink` pagination.
|
||||
- `Fuchs_DataService` (Topshelf worker) polls MFR on a timer and syncs entities + invoice files; the web app creates clients via `IMfrClientFactory`.
|
||||
|
||||
## 11. Database
|
||||
|
||||
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
|
||||
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Evaluation — Backend-cached invoice editing over SignalR
|
||||
|
||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||
The backend becomes the single source of truth for the in-progress invoice.
|
||||
|
||||
This note evaluates that against the current design and recommends a path.
|
||||
|
||||
---
|
||||
|
||||
## 1. How invoice editing works today
|
||||
|
||||
- **Stateless.** The browser holds the editor state. It posts the whole `invc`
|
||||
JSON to `req/sprep|sedit|save`; the server computes/persists and returns a PDF
|
||||
preview (image collection). No per-user editing state lives on the server.
|
||||
- Totals/§13b/§35a and now **set pricing** are computed from posted data; the
|
||||
invoice **total comes from the registration balance**, not summed lines.
|
||||
- Auth is cookie-based (`OCORECookieAuthenticationEvents`), SQL-first data access,
|
||||
controller is stateless and DI-scoped.
|
||||
|
||||
**Implication:** the server is horizontally scalable and crash-tolerant for
|
||||
editing — there is nothing to lose if a node restarts mid-edit.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the proposal would buy
|
||||
|
||||
| Benefit | Real for Fuchs? |
|
||||
|---|---|
|
||||
| Server-authoritative calculation (one place for pricing/VAT/set rules) | **Partly already true** — the PDF/totals are server-computed; the editor only previews. The duplicated logic is the *live* line math in JS. |
|
||||
| Real-time multi-user co-editing | **Low value** — invoices are edited by one back-office user at a time; concurrent editing of the same draft is rare. |
|
||||
| Live validation / instant recompute without full round-trips | **Some value** — smoother UX than re-posting the whole `invc` for each tweak. |
|
||||
| Reduced payload (deltas vs whole invoice) | Marginal — invoices are small. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Costs and risks
|
||||
|
||||
- **Server-side edit state.** Per-user/per-draft cache with lifetime management
|
||||
(idle expiry, explicit discard, max size), or the server leaks memory. Needs a
|
||||
distributed cache if scaled out (Redis), or **sticky sessions** for SignalR.
|
||||
- **Concurrency/locking.** Two tabs or two users on the same draft → need
|
||||
optimistic concurrency / locking semantics that don't exist today.
|
||||
- **Connection lifecycle.** Reconnect, replay, and "lost update" handling;
|
||||
offline/flaky networks; auth on the socket (cookie works, but token refresh and
|
||||
disconnect-on-logout must be handled).
|
||||
- **Scaling.** SignalR with multiple instances needs a backplane (Redis/Azure
|
||||
SignalR). Today the app has none.
|
||||
- **Big rewrite of the editor.** The 1,200-line `fis.inv_shared.js` becomes an
|
||||
event-driven client of server state — a substantial, risky rewrite of working
|
||||
code, with new failure modes (desync between optimistic UI and server truth).
|
||||
- **Testing surface** grows (connection states, races) far beyond the current
|
||||
request/response model.
|
||||
|
||||
For a single-tenant back-office app with one editor at a time, this is a lot of
|
||||
**accidental complexity** for modest UX gains.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
**Do not do a full SignalR rewrite now.** It optimises a problem (real-time
|
||||
collaboration, server-held edit state) the business doesn't strongly have, while
|
||||
adding stateful-server, scaling, and reconnection complexity to an app that is
|
||||
currently simple and robust.
|
||||
|
||||
Prefer an **incremental, lower-risk path** that captures most of the value:
|
||||
|
||||
1. **Single source of pricing truth (highest value, do first).**
|
||||
Add a stateless endpoint `inv/calc` that takes the `invc` JSON and returns the
|
||||
computed lines + totals + set-mode resolution using the **same backend code**
|
||||
(`InvoiceSetPricing`, VAT, §13b/§35a). The editor calls it on change (debounced)
|
||||
to recompute, instead of duplicating the math in JS. This removes the
|
||||
front/back duplication — the actual pain — **without** sockets or server state.
|
||||
It also makes the interplay fully unit-testable (the endpoint is pure).
|
||||
|
||||
2. **Keep preview as-is** (post → PDF image) but allow it to reuse `inv/calc`.
|
||||
|
||||
3. **If/when live UX is still wanted**, add SignalR **only as a transport** on top
|
||||
of the stateless calc (push recompute results), keeping persistence stateless.
|
||||
Defer server-held draft state until genuine multi-user co-editing is required.
|
||||
|
||||
4. **If server-held drafts are truly needed**, scope a pilot: one entity (invoice
|
||||
draft), `IDistributedCache`-backed, explicit acquire/release lock, idle TTL,
|
||||
and a reconnect/replay protocol — behind a feature flag, measured against the
|
||||
current flow before rollout.
|
||||
|
||||
### Why this fits the codebase
|
||||
It aligns with the just-completed DI service layer: the calc lives in
|
||||
`IInvoiceService`/`InvoiceSetPricing` (already tested), reused by both the preview
|
||||
and a future socket. We get "changes computed by the backend, reflected in the
|
||||
frontend" — the stated goal — **without** turning a stateless, scalable web app
|
||||
into a stateful real-time system prematurely.
|
||||
|
||||
**Suggested next step:** implement `inv/calc` (item 1) and migrate the editor's
|
||||
line math to it; revisit SignalR only if real-time/co-editing becomes a real
|
||||
requirement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
|
||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
|
||||
| Mode | Set line | Member items | Use as |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
|
||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
|
||||
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
|
||||
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
||||
set header line is rendered emphasised. Invoices without sets pass through
|
||||
unchanged.
|
||||
|
||||
## Front-end contract (implemented in the invoice editor)
|
||||
|
||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
|
||||
editor only posted the legacy on-screen `itm`/`co` objects, which
|
||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||
|
||||
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with
|
||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
|
||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||
`BuildInvoiceParams` reads different ones. At post time `$inv.invcPayload(d)` maps the
|
||||
working model onto the exact field names the back-end reads (non-destructively):
|
||||
`sms.ttn → new.total_net`, `sms.ttb → new.total_gross`, each `sms.vat` rate →
|
||||
`new.vat_<rate>_net`, `new.invoicetitle → new.title`, `new.loc → new.provisionlocation`,
|
||||
`admin.paymentterms → new.paymentterm`, `admin.CustomerId → admin.customerid`. Both
|
||||
`req/save` and `req/sprep|sedit` post through it, so titles/balances/VAT now reach the
|
||||
backend correctly.
|
||||
|
||||
VAT (rate **and** amount) is taken by the backend directly from the posted `sms.vat`
|
||||
map via `FdsInvoiceData.HighestVat` (highest rate wins) — `invcPayload` therefore emits
|
||||
no per-rate `vat_*` keys.
|
||||
|
||||
**Back-end fixes applied alongside the wiring:**
|
||||
- Heading/free-text lines (`type` `text`/`title`) now render a **blank** price/total
|
||||
(`InvoiceSetPricing.IsNoPriceLine`), instead of `0,00 €`.
|
||||
- VAT rate detection no longer reads line items (the old `items is List<object>` test
|
||||
failed on Newtonsoft `JArray` and pinned `@InvoiceVAT_1` to `19`); it now comes from
|
||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
|
||||
### Why the switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. Persisting the
|
||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
||||
only required if a finalised invoice must be **re-generated** from stored items in
|
||||
a different mode later — not done here.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Fuchs Intranet — Benutzerhandbuch
|
||||
|
||||
> Praxisleitfaden für Mitarbeiter:innen der Firma Sebastian Fuchs Bad und Heizung.
|
||||
> Beschreibt die täglichen Abläufe im Intranet. Technische Details siehe
|
||||
> `ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anmeldung & Konto
|
||||
|
||||
### Anmelden
|
||||
1. Intranet im Browser öffnen.
|
||||
2. E-Mail-Adresse und Passwort eingeben → **Anmelden**.
|
||||
3. Die Sitzung bleibt 8 Stunden aktiv (gleitend).
|
||||
|
||||
### Passwort vergessen
|
||||
1. „Passwort vergessen" wählen, **Nachname** und **E-Mail** eingeben.
|
||||
2. Es wird ein Bestätigungscode per **SMS** an die hinterlegte Mobilnummer gesendet.
|
||||
3. Code eingeben → das Passwort wird per E-Mail zugesandt.
|
||||
*(Aus Sicherheitsgründen erfolgt keine Rückmeldung, ob die Adresse existiert.)*
|
||||
|
||||
### Passwort ändern
|
||||
1. Konto → **Passwort ändern**.
|
||||
2. Bestätigungscode per SMS anfordern und eingeben.
|
||||
3. Altes Passwort + neues Passwort (mind. 6 Zeichen) zweimal eingeben.
|
||||
Das alte Passwort und die Code-Bestätigung werden geprüft.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard (Widgets)
|
||||
- Nach der Anmeldung erscheinen die persönlichen **Widgets** (Kennzahlen, Tabellen, Hinweise).
|
||||
- Widgets werden aus der Datenbank gespeist; welche angezeigt werden, hängt an Ihrem Benutzerkonto.
|
||||
|
||||
---
|
||||
|
||||
## 3. Serviceaufträge (Requests)
|
||||
Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatisch synchronisiert.
|
||||
|
||||
**Typische Schritte:**
|
||||
1. **Liste** öffnen (nach Datum oder Suche).
|
||||
2. Auftrag öffnen → Details, Positionen und ggf. bereits vorhandene Rechnungen ansehen.
|
||||
3. **Rechnung vorbereiten** (`iget`/`sprep`): Positionen werden in eine Rechnung übernommen und als Vorschau (PDF) angezeigt.
|
||||
4. Bei Bedarf einzelne Aufträge **aus-/einblenden**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rechnungen (Invoices)
|
||||
|
||||
### Erstellen / Bearbeiten
|
||||
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
||||
|
||||
### Finalisieren & Versenden
|
||||
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
|
||||
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.
|
||||
3. **Erneut senden** ist jederzeit möglich.
|
||||
|
||||
> **Hinweis Versand:** Der E-Mail-/SMS-Versand läuft über die ProcessWeb-Mailer-API
|
||||
> und ist über `Fuchs:Mailer:Enabled` geschaltet. Ist er deaktiviert, wird der
|
||||
> Versand nur protokolliert (kein echter Versand).
|
||||
|
||||
### Weitere Aktionen
|
||||
- **Bezahlt / Unbezahlt** markieren.
|
||||
- **Storno** oder **Gutschrift** erzeugen (einfach, als Kopie oder als Gutschrift).
|
||||
- **Zahlungen** und **Positionen** zu einer Rechnung einsehen.
|
||||
- **Rechnungs-PDF** anzeigen/herunterladen (gespeichertes Dokument oder neu erzeugt).
|
||||
|
||||
### Was steht auf der Rechnung
|
||||
Das PDF enthält Positionen, Netto/MwSt./Brutto, den **GiroCode (QR zur Überweisung)**
|
||||
sowie die gesetzlich relevanten Hinweise (u. a. §13b bzw. §35a-Lohnkostenhinweis,
|
||||
§14-Aufbewahrung, §48-Freistellungsbescheinigung, Steuernummer/USt-ID).
|
||||
|
||||
---
|
||||
|
||||
## 5. Zahlungserinnerungen (Reminders)
|
||||
1. Zu einer offenen Rechnung eine **Erinnerung vorbereiten** (Typ/Stufe wählen) → Vorschau.
|
||||
2. **Finalisieren**: PDF wird erzeugt; bei hinterlegter E-Mail erfolgt der Versand
|
||||
(Erinnerungs-PDF, ggf. inkl. Rechnungskopie, als Anhang).
|
||||
3. **Erneut senden** und **Erinnerungen nachschlagen** sind möglich.
|
||||
|
||||
---
|
||||
|
||||
## 6. Banking (Kontoumsätze)
|
||||
|
||||
### Umsätze importieren
|
||||
1. Banking → **Kontobericht hochladen**.
|
||||
2. Datei(en) wählen. Es werden **beide Formate** akzeptiert:
|
||||
- **MT940** (SWIFT-Text: `.sta`, `.mt940`, `.txt`)
|
||||
- **CAMT** (ISO 20022 XML: `.xml`, `.camt` — camt.052/053/054)
|
||||
3. Das System erkennt das Format automatisch, liest die Buchungen ein und führt sie
|
||||
in die Kontoumsätze über.
|
||||
|
||||
### Umsätze zuordnen
|
||||
- **Fragliche Überweisungen** anzeigen und prüfen.
|
||||
- Eine Transaktion einer **Rechnung zuordnen** (`ati`) oder als **erledigt** markieren (`smd`).
|
||||
- Per Rechnungsnummer nach passenden Rechnungen suchen (`vfi`).
|
||||
|
||||
---
|
||||
|
||||
## 7. DATEV-Export
|
||||
- **DATEV-Übersicht** für einen Stichtag/Zeitraum anzeigen (Dateien, Rechnungen).
|
||||
- **DATEV-ZIP** herunterladen — optional inkl. der Beleg-PDFs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Berichte (Reports)
|
||||
1. **Katalog** öffnen — Liste aller verfügbaren Berichte mit Parametern.
|
||||
2. Bericht auswählen, Parameter setzen, ausführen:
|
||||
- als **HTML-Seite** (mit optionaler automatischer Aktualisierung),
|
||||
- als **HTML-Inhalt** (eingebettet),
|
||||
- oder als **Diagramm (PNG)**.
|
||||
|
||||
---
|
||||
|
||||
## 9. MFR-Synchronisation (Hintergrund)
|
||||
- Stammdaten (Firmen, Kontakte, Serviceaufträge, Rechnungen, Belege …) werden vom
|
||||
**Fuchs_DataService** regelmäßig aus dem MFR-ERP abgeglichen.
|
||||
- Administrator:innen können eine **Aktualisierung anstoßen** (`mfr_update`) bzw. die
|
||||
Verknüpfung einzelner Datensätze zurücksetzen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Hilfe & Support
|
||||
- Bei Versandproblemen: prüfen, ob der Mailer aktiviert ist und ob eine gültige
|
||||
E-Mail-/Mobilnummer hinterlegt ist.
|
||||
- Technische Fehler werden protokolliert (Logging/OpenTelemetry); bitte mit
|
||||
Uhrzeit und betroffener Aktion an die IT melden:
|
||||
[info@processweb.de](mailto:info@processweb.de).
|
||||
+20
-9
@@ -7,16 +7,21 @@
|
||||
<Configurations>db-dev.processweb.de;Debug;Release;server02.processweb.de</Configurations>
|
||||
<NoWarn>CA1416</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Expose internal members (e.g. FdsInvoiceData.BuildInvoiceParams) to the test project -->
|
||||
<InternalsVisibleTo Include="Fuchs.Tests" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE_web\OCORE_web.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE\OCORE\OCORE.csproj" />
|
||||
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
|
||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
@@ -27,8 +32,14 @@
|
||||
<!-- Compatible packages (kept) -->
|
||||
<PackageReference Include="BouncyCastle" Version="1.8.9" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
@@ -37,13 +48,13 @@
|
||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="MimeKit" Version="4.16.0" />
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.6" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="App_Data\cache\" />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Fuchs.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Central definition of the application's OpenTelemetry instrumentation:
|
||||
/// a single <see cref="ActivitySource"/> for tracing and a single
|
||||
/// <see cref="Meter"/> with the business / performance instruments.
|
||||
///
|
||||
/// These use the in-box <c>System.Diagnostics</c> APIs, which the OpenTelemetry
|
||||
/// SDK (wired up in <c>Program.cs</c>) collects and exports. Code can therefore
|
||||
/// emit spans and metrics without taking a direct dependency on the OTel SDK.
|
||||
/// </summary>
|
||||
public static class FuchsTelemetry
|
||||
{
|
||||
public const string ServiceName = "Fuchs.Intranet";
|
||||
public static readonly string ServiceVersion =
|
||||
typeof(FuchsTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
|
||||
/// <summary>Tracing source — register this name with the tracer provider.</summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion);
|
||||
|
||||
/// <summary>Metrics meter — register this name with the meter provider.</summary>
|
||||
public static readonly Meter Meter = new(ServiceName, ServiceVersion);
|
||||
|
||||
// ── Business counters ────────────────────────────────────────────────────
|
||||
public static readonly Counter<long> InvoicesRendered =
|
||||
Meter.CreateCounter<long>("fuchs.invoices.rendered", "{invoice}", "Number of invoice PDFs rendered.");
|
||||
public static readonly Counter<long> RemindersRendered =
|
||||
Meter.CreateCounter<long>("fuchs.reminders.rendered", "{reminder}", "Number of reminder PDFs rendered.");
|
||||
public static readonly Counter<long> ReportsRendered =
|
||||
Meter.CreateCounter<long>("fuchs.reports.rendered", "{report}", "Number of reports rendered (by function).");
|
||||
public static readonly Counter<long> EmailsSent =
|
||||
Meter.CreateCounter<long>("fuchs.emails.sent", "{email}", "Number of emails accepted by the mailer API.");
|
||||
public static readonly Counter<long> EmailsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.emails.failed", "{email}", "Number of emails that failed to send.");
|
||||
public static readonly Counter<long> SmsSent =
|
||||
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> MfrCalls =
|
||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.pdf.render.duration", "ms", "PDF render duration.");
|
||||
public static readonly Histogram<double> ReportRenderDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration.");
|
||||
public static readonly Histogram<double> EmailSendDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
|
||||
|
||||
/// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary>
|
||||
public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
|
||||
ActivitySource.StartActivity(name, kind);
|
||||
}
|
||||
+50
-4
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Fuchs.Observability;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.web;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Fuchs;
|
||||
|
||||
@@ -36,7 +40,7 @@ public class Program
|
||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||
|
||||
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings
|
||||
fds.FdsConfig.Initialize();
|
||||
fds.FdsConfig.Initialize(builder.Configuration);
|
||||
|
||||
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
||||
@@ -71,9 +75,51 @@ public class Program
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Email service
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
builder.Services.AddScoped<IEmailService, FuchsEmailService>();
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API)
|
||||
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
|
||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
|
||||
builder.Services.AddSingleton<IPdfService, FuchsPdfService>(); // stateless renderer (sets license)
|
||||
builder.Services.AddSingleton<IMfrClientFactory, MfrClientFactory>();
|
||||
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
|
||||
// ── OpenTelemetry: tracing + metrics ─────────────────────────────────
|
||||
// Instrumentation is always collected; OTLP export is enabled only when
|
||||
// an endpoint is configured (Fuchs:Telemetry:OtlpEndpoint), so a missing
|
||||
// collector never impacts the app. Disable entirely with Enabled=false.
|
||||
bool telemetryEnabled = builder.Configuration.GetValue("Fuchs:Telemetry:Enabled", true);
|
||||
string? otlpEndpoint = builder.Configuration["Fuchs:Telemetry:OtlpEndpoint"];
|
||||
if (telemetryEnabled)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
serviceName: FuchsTelemetry.ServiceName,
|
||||
serviceVersion: FuchsTelemetry.ServiceVersion))
|
||||
.WithTracing(t =>
|
||||
{
|
||||
t.AddSource(FuchsTelemetry.ServiceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSqlClientInstrumentation();
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||
t.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
|
||||
})
|
||||
.WithMetrics(m =>
|
||||
{
|
||||
m.AddMeter(FuchsTelemetry.ServiceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||
m.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureApp(WebApplication app)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using CAMTParser;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using programmersdigest.MT940Parser;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// MT940 bank statement parsing service. Replaces the static <c>Banking</c> class.
|
||||
/// Bank statement parsing service. Accepts both MT940 (SWIFT text) and
|
||||
/// CAMT (ISO 20022 camt.052/053/054 XML) and maps either into the same
|
||||
/// banking-transactions DataTable. The format is auto-detected from content.
|
||||
/// </summary>
|
||||
public class BankingService : IBankingService
|
||||
{
|
||||
@@ -18,21 +23,56 @@ public class BankingService : IBankingService
|
||||
|
||||
public string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
|
||||
{
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.ReverseCredit => "RC",
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
|
||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
||||
byte[] bytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
stream.CopyTo(ms);
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
|
||||
string format;
|
||||
if (CamtParser.LooksLikeXml(bytes))
|
||||
{
|
||||
format = "camt";
|
||||
FillFromCamt(tbl, bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
format = "mt940";
|
||||
using var msMt = new MemoryStream(bytes);
|
||||
FillFromMt940(tbl, msMt);
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
sw.Stop();
|
||||
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format));
|
||||
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);
|
||||
return tbl;
|
||||
}
|
||||
|
||||
// ── MT940 ─────────────────────────────────────────────────────────────────
|
||||
private void FillFromMt940(DataTable tbl, Stream stream)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null)
|
||||
nr[key] = value;
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
|
||||
using var ps = new Parser(stream: stream);
|
||||
@@ -47,89 +87,130 @@ public class BankingService : IBankingService
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
SetNfo(nr, "BankReference", line.BankReference);
|
||||
|
||||
var info = line.InformationToOwner;
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation", info.UnstructuredRemittanceInformation);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
|
||||
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails);
|
||||
SetNfo(nr, "TransactionTypeIdCode", line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
|
||||
}
|
||||
|
||||
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
||||
private void FillFromCamt(DataTable tbl, byte[] bytes)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
_logger.LogError(ex, "MT940 statement parse failed.");
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
return tbl;
|
||||
try
|
||||
{
|
||||
var statements = new CamtParser().Parse(bytes);
|
||||
foreach (var stmt in statements)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
|
||||
foreach (var e in stmt.Entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", stmt.AccountIdentification);
|
||||
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);
|
||||
SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
|
||||
SetNfo(nr, "BankReference", e.BankReference);
|
||||
SetNfo(nr, "EndToEndReference", e.EndToEndReference);
|
||||
SetNfo(nr, "MandateReference", e.MandateReference);
|
||||
SetNfo(nr, "CustomerReference", e.CustomerReference);
|
||||
SetNfo(nr, "CreditorReference", e.CreditorReference);
|
||||
SetNfo(nr, "AccountNumberOfPayer", e.CounterpartyIban);
|
||||
SetNfo(nr, "BankCodeOfPayer", e.CounterpartyBic);
|
||||
SetNfo(nr, "NameOfPayer", e.CounterpartyName);
|
||||
SetNfo(nr, "PostingText", e.AdditionalInfo);
|
||||
SetNfo(nr, "TransactionTypeIdCode",e.BankTransactionCode);
|
||||
SetNfo(nr, "SepaRemittanceInformation",
|
||||
string.IsNullOrEmpty(e.RemittanceUnstructured) ? e.RemittanceStructured : e.RemittanceUnstructured);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation", e.RemittanceUnstructured);
|
||||
SetNfo(nr, "UnstructuredData", e.RemittanceUnstructured);
|
||||
SetNfo(nr, "IsUnstructuredData", e.IsUnstructuredData);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
|
||||
}
|
||||
|
||||
private static DataTable BuildDefaultSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
var cols = t.Columns;
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("UnstructuredRemittanceInformation", typeof(string));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email service implementation. Replaces the static <c>FuchsFdsEmail</c> class.
|
||||
/// Reads settings from <c>IOptions<FuchsEmailSettings></c> (appsettings.json)
|
||||
/// instead of <c>System.Configuration.ConfigurationManager</c>.
|
||||
/// </summary>
|
||||
public class FuchsEmailService : IEmailService
|
||||
{
|
||||
private readonly ILogger<FuchsEmailService> _logger;
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly FuchsEmailSettings _emailSettings;
|
||||
private OCORE.email.EmailServerSettings? _cachedSettings;
|
||||
|
||||
private const string ReplyToName = "Sebastian Fuchs - Bad und Heizung";
|
||||
private const string ReplyToAddress = "info@sanitaerfuchs.de";
|
||||
private const string SignatureIntro =
|
||||
"<p> </p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
|
||||
"Herzliche Gr\u00fc\u00dfe aus D\u00fcsseldorf-Bilk<br/>" +
|
||||
"Ihr Team der Firma Sebastian Fuchs</p>";
|
||||
|
||||
public FuchsEmailService(
|
||||
ILogger<FuchsEmailService> logger,
|
||||
Fuchs_intranet intranet,
|
||||
IOptions<FuchsEmailSettings> emailSettings)
|
||||
{
|
||||
_logger = logger;
|
||||
_intranet = intranet;
|
||||
_emailSettings = emailSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
string email, string name, Dictionary<string, byte[]>? attachments)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
string guid = "";
|
||||
string config = "";
|
||||
DateTime sent = default;
|
||||
|
||||
if (!IsValidEmail(email))
|
||||
errors.Add("Die Email-Adresse ist nicht g\u00fcltig.");
|
||||
if (string.IsNullOrEmpty(html))
|
||||
errors.Add("Bitte geben Sie eine Nachricht ein.");
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = GetEmailSettings();
|
||||
string body = html + BuildSignature();
|
||||
|
||||
// Development redirect: replace To with DevRedirectAddress, suppress CC/BCC
|
||||
string devRedirect = _emailSettings.DevRedirectAddress;
|
||||
bool isDevRedirect = !string.IsNullOrWhiteSpace(devRedirect);
|
||||
if (isDevRedirect)
|
||||
{
|
||||
_logger.LogWarning("DEV redirect: email to '{Original}' redirected to '{Redirect}'", email, devRedirect);
|
||||
email = devRedirect;
|
||||
name = "Dev Redirect";
|
||||
}
|
||||
|
||||
if (settings != null)
|
||||
{
|
||||
var msg = new OCORE.email.Email(
|
||||
Mode: OCORE.email.EmailMode.DirectMode,
|
||||
type: settings.type)
|
||||
{
|
||||
EmailSettings = settings,
|
||||
Subject = subject
|
||||
};
|
||||
msg.AddTo(email, name);
|
||||
msg.AddReplyTo(new MailboxAddress(ReplyToName, ReplyToAddress));
|
||||
msg.SetBody(body);
|
||||
if (attachments != null)
|
||||
foreach (var kv in attachments)
|
||||
msg.AttachFile(filecontent: kv.Value, kv.Key);
|
||||
|
||||
var result = await msg.SendAsync(
|
||||
(dref, ex) => _logger.LogError(ex, "Email send error {Reference}", dref));
|
||||
|
||||
guid = msg.MessageId ?? "";
|
||||
config = msg.EmailConfig_serialized;
|
||||
sent = result.Timestamp;
|
||||
errors.AddRange(result.ErrorMessages);
|
||||
}
|
||||
else
|
||||
{
|
||||
var main = _emailSettings.Main;
|
||||
string host = !string.IsNullOrEmpty(main.Host) ? main.Host : _emailSettings.SmtpHost;
|
||||
string user = !string.IsNullOrEmpty(main.Username) ? main.Username : _emailSettings.SmtpUser;
|
||||
string pass = !string.IsNullOrEmpty(main.Password) ? main.Password : _emailSettings.SmtpPass;
|
||||
string from = !string.IsNullOrEmpty(main.From) ? main.From
|
||||
: (!string.IsNullOrEmpty(_emailSettings.SmtpFrom) ? _emailSettings.SmtpFrom : user);
|
||||
string fromName = !string.IsNullOrEmpty(main.Alias) ? main.Alias : _emailSettings.SmtpFromName;
|
||||
int port = main.Port > 0 ? main.Port : _emailSettings.SmtpPort;
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(fromName, from));
|
||||
message.To.Add(new MailboxAddress(name, email));
|
||||
message.ReplyTo.Add(new MailboxAddress(ReplyToName, ReplyToAddress));
|
||||
message.Subject = subject;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = body };
|
||||
if (attachments != null)
|
||||
foreach (var kv in attachments)
|
||||
builder.Attachments.Add(kv.Key, kv.Value);
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
using var client = new MailKit.Net.Smtp.SmtpClient();
|
||||
await client.ConnectAsync(host, port, MailKit.Security.SecureSocketOptions.Auto);
|
||||
if (!string.IsNullOrEmpty(user))
|
||||
await client.AuthenticateAsync(user, pass);
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
guid = message.MessageId!;
|
||||
sent = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
|
||||
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
_logger.LogWarning("SendEmailAsync errors for {Reference}: {Errors}", reference, errors);
|
||||
|
||||
// SQL audit log
|
||||
try
|
||||
{
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@Ref", reference),
|
||||
SQL_VarChar("@guid", guid),
|
||||
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
|
||||
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
|
||||
SQL_Bit("@success", errors.Count == 0),
|
||||
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors))
|
||||
};
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: _intranet.GetDbSecurity());
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
_logger.LogError(logEx, "Failed to log email audit for {Reference}", reference);
|
||||
}
|
||||
|
||||
return errors.Count == 0;
|
||||
}
|
||||
|
||||
private OCORE.email.EmailServerSettings? GetEmailSettings()
|
||||
{
|
||||
if (_cachedSettings != null) return _cachedSettings;
|
||||
|
||||
var main = _emailSettings.Main;
|
||||
if (string.IsNullOrWhiteSpace(main.Host)) return null;
|
||||
|
||||
_cachedSettings = new OCORE.email.EmailServerSettings(main.Type, main.ToOcoreJson());
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
private static string BuildSignature()
|
||||
{
|
||||
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 */ }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var a = new System.Net.Mail.MailAddress(email);
|
||||
return string.Equals(a.Address, email, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// SMTP / email server settings bound from appsettings.json → "Fuchs:Email" section.
|
||||
/// </summary>
|
||||
public class FuchsEmailSettings
|
||||
{
|
||||
/// <summary>Main account for outgoing customer emails (anfrage@sanitaerfuchs.de).</summary>
|
||||
public SmtpAccountSettings Main { get; set; } = new();
|
||||
|
||||
/// <summary>FDS/invoicing account (rechnungen@sanitaerfuchs.de).</summary>
|
||||
public SmtpAccountSettings Fds { get; set; } = new();
|
||||
|
||||
/// <summary>Internal OCORE service account used for system notifications.</summary>
|
||||
public SmtpAccountSettings Service { get; set; } = new();
|
||||
|
||||
/// <summary>Comma-separated list of addresses used as test recipients.</summary>
|
||||
public string TestAddresses { get; set; } = "";
|
||||
|
||||
// ── MailKit fallback settings (used when Main is not configured) ──
|
||||
public string SmtpHost { get; set; } = "";
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
public string SmtpUser { get; set; } = "";
|
||||
public string SmtpPass { get; set; } = "";
|
||||
public string SmtpFrom { get; set; } = "";
|
||||
public string SmtpFromName { get; set; } = "SanitärFuchs";
|
||||
|
||||
/// <summary>
|
||||
/// Development only: when set, all outgoing emails are redirected to this address;
|
||||
/// CC and BCC are cleared. Leave empty in production.
|
||||
/// </summary>
|
||||
public string DevRedirectAddress { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
|
||||
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods
|
||||
/// while providing a DI-friendly injectable wrapper.
|
||||
/// while providing a DI-friendly injectable wrapper, structured logging and
|
||||
/// render-duration metrics.
|
||||
/// </summary>
|
||||
public class FuchsPdfService : IPdfService
|
||||
{
|
||||
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
|
||||
{
|
||||
_logger = logger;
|
||||
FuchsPdf.SetLicense();
|
||||
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
|
||||
}
|
||||
|
||||
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
|
||||
{
|
||||
_logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
|
||||
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
|
||||
}
|
||||
|
||||
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
|
||||
FdsInvoiceData invoice, bool draft = false)
|
||||
{
|
||||
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
|
||||
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
|
||||
}
|
||||
|
||||
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
|
||||
FdsReminderData reminder, bool draft = false)
|
||||
{
|
||||
_logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
|
||||
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, draft);
|
||||
}
|
||||
|
||||
public byte[] DocToPdfBytes(Document doc) => FuchsPdf.DocToPdfBytes(doc);
|
||||
public byte[] DocToPdfBytes(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.render");
|
||||
try
|
||||
{
|
||||
byte[] bytes = FuchsPdf.DocToPdfBytes(doc);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "pdf"));
|
||||
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
|
||||
_logger.LogDebug("DocToPdfBytes rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "DocToPdfBytes failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc) =>
|
||||
FuchsPdf.DocToImageCollection(doc);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.render.images");
|
||||
try
|
||||
{
|
||||
var col = await FuchsPdf.DocToImageCollection(doc);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "images"));
|
||||
act?.SetTag("fuchs.pdf.pages", col.TotalPages);
|
||||
_logger.LogDebug("DocToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
|
||||
return col;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "DocToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes) =>
|
||||
FuchsPdf.BytesToImageCollection(pdfBytes);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.bytes.images");
|
||||
try
|
||||
{
|
||||
var col = await FuchsPdf.BytesToImageCollection(pdfBytes);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "bytes-images"));
|
||||
_logger.LogDebug("BytesToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
|
||||
return col;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "BytesToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,153 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Report service implementation. Replaces the static <c>FuchsReports</c> class.
|
||||
/// Report processing for the Fuchs intranet — SQL-driven reports from the
|
||||
/// fds__ report catalog, rendered as HTML pages, HTML fragments, or PNG charts
|
||||
/// via <see cref="FuchsVisualization"/>. Replaces the static <c>FuchsReports</c>.
|
||||
/// </summary>
|
||||
public class FuchsReportService : IReportService
|
||||
{
|
||||
private const int DefaultReloadSeconds = 60 * 10;
|
||||
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ILogger<FuchsReportService> _logger;
|
||||
|
||||
public FuchsReportService(ILogger<FuchsReportService> logger)
|
||||
public FuchsReportService(Fuchs_intranet intranet, ILogger<FuchsReportService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IActionResult> ProcessRequestAsync(string action, string id,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
|
||||
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters)
|
||||
{
|
||||
// Specific report actions are dispatched here.
|
||||
// Extend with additional cases as needed.
|
||||
return Task.FromResult<IActionResult>(new OkResult());
|
||||
var prms = new Dictionary<string, string>(parameters, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["@authuser"] = userAccountId
|
||||
};
|
||||
|
||||
string tgt = (string.IsNullOrEmpty(fnc)
|
||||
? (prms.TryGetValue("fnc", out var f) ? f : fnc)
|
||||
: fnc).Replace("gct", "generic_content");
|
||||
string report = prms.TryGetValue("report", out var r) && !string.IsNullOrEmpty(r)
|
||||
? r
|
||||
: (!string.IsNullOrEmpty(reportId) ? reportId : "");
|
||||
|
||||
string templatePath = Path.Combine(AppContext.BaseDirectory, "Content", "FDS_Template.html");
|
||||
|
||||
// Report configuration (refresh interval + cache flag) from the catalog.
|
||||
int ciRefresh = -2;
|
||||
bool ciCache = false;
|
||||
try
|
||||
{
|
||||
var catalog = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
|
||||
Conn,
|
||||
new List<SqlParameter> { SQL_VarChar("@report_name", report), SQL_VarChar("@authuser", userAccountId) },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
var cfg = catalog.FirstRow.toObjectDictionary();
|
||||
if (cfg.TryGetValue("refresh", out var rf) && rf is not null && rf is not DBNull &&
|
||||
int.TryParse(rf.ToString(), out var rfi)) ciRefresh = rfi;
|
||||
if (cfg.TryGetValue("functions", out var fn) && fn is not null)
|
||||
ciCache = (fn.ToString() ?? "").Split(',').Contains("cache");
|
||||
}
|
||||
catch (Exception cex)
|
||||
{
|
||||
_logger.LogError(cex, "Report catalog read failed for report {Report}", report);
|
||||
}
|
||||
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("report.process");
|
||||
act?.SetTag("fuchs.report.fn", tgt);
|
||||
act?.SetTag("fuchs.report.id", report);
|
||||
_logger.LogInformation("Report request fnc={Fnc} report={Report} tgt={Tgt} cache={Cache} user={User}",
|
||||
fnc, report, tgt, ciCache, userAccountId);
|
||||
try
|
||||
{
|
||||
switch (tgt)
|
||||
{
|
||||
case "generic_content":
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
return new ContentResult
|
||||
{
|
||||
Content = await FuchsVisualization.RenderContentAsync(
|
||||
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms),
|
||||
ContentType = "text/html"
|
||||
};
|
||||
|
||||
case "generic":
|
||||
{
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
var page = await FuchsVisualization.RenderPageAsync(
|
||||
Conn, dbSec, userAccountId, report, report, FdsQueryType.generic, prms,
|
||||
FdsDestination.web, templatePath, allowcache: ciCache, forceReload: ciForce);
|
||||
ApplyReload(page, prms, ciRefresh);
|
||||
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
|
||||
}
|
||||
|
||||
case "chart":
|
||||
byte[]? png = await FuchsVisualization.RenderQueryAsChartAsync(
|
||||
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms);
|
||||
if (png is null) return new StatusCodeResult(500);
|
||||
return new FileContentResult(png, "image/png")
|
||||
{
|
||||
FileDownloadName = $"{report.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd_HHmm}.png"
|
||||
};
|
||||
|
||||
case "xls":
|
||||
return new StatusCodeResult(501); // not implemented (matches legacy NotImplementedException)
|
||||
|
||||
default:
|
||||
if (Enum.TryParse<FdsQueryType>(fnc, ignoreCase: true, out var qt))
|
||||
{
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
var page = await FuchsVisualization.RenderPageAsync(
|
||||
Conn, dbSec, userAccountId, report, report, qt, prms,
|
||||
FdsDestination.web, templatePath);
|
||||
ApplyReload(page, prms, -2);
|
||||
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
|
||||
}
|
||||
return new StatusCodeResult(300);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt);
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
FuchsTelemetry.ReportRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("fn", tgt));
|
||||
FuchsTelemetry.ReportsRendered.Add(1, new KeyValuePair<string, object?>("fn", tgt));
|
||||
_logger.LogDebug("Report request completed tgt={Tgt} report={Report} in {Ms} ms", tgt, report, sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyReload(FuchsHtmlPage page, IDictionary<string, string> prms, int ciRefresh)
|
||||
{
|
||||
if (prms.TryGetValue("reload", out var rl) && int.TryParse(rl, out var rs)) page.ReloadSeconds = rs;
|
||||
else if (ciRefresh > -2) page.ReloadSeconds = ciRefresh;
|
||||
else if (DefaultReloadSeconds > 0) page.ReloadSeconds = DefaultReloadSeconds;
|
||||
|
||||
if (page.QueryDuration > 180 && page.ReloadSeconds is > 0 and < 3600) page.ReloadSeconds = 1200;
|
||||
else if (page.QueryDuration > 60 && page.ReloadSeconds is > 0 and < 1200) page.ReloadSeconds = 1200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
@@ -12,8 +12,9 @@ using static OCORE.web.mvc_helper_async;
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Widget service implementation. Replaces the static <c>FuchsWidgets</c> class.
|
||||
/// No longer depends on <c>IntranetController</c>.
|
||||
/// Widget service for the Fuchs intranet dashboard. Port of fuchs_fds_widgets.vb —
|
||||
/// SQL-driven widget cases. Replaces the static <c>FuchsWidgets</c> class
|
||||
/// (no longer depends on <c>IntranetController</c>).
|
||||
/// </summary>
|
||||
public class FuchsWidgetService : IWidgetService
|
||||
{
|
||||
@@ -26,39 +27,48 @@ public class FuchsWidgetService : IWidgetService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId,
|
||||
DatabaseSecurity dbSec, HttpRequest request)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("widget.get");
|
||||
act?.SetTag("fuchs.widget.id", widgetId);
|
||||
_logger.LogDebug("GetWidgetAsync widget={WidgetId} user={User}", widgetId, userAccountId);
|
||||
try
|
||||
{
|
||||
return widgetId.ToLower() switch
|
||||
var result = widgetId.ToLower() switch
|
||||
{
|
||||
"my" => await HandleWidgetMy(userAccountId, dbSec),
|
||||
"my" => await HandleWidgetMy(userAccountId, dbSec),
|
||||
"one" => await HandleWidgetOne(userAccountId, dbSec, request),
|
||||
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
|
||||
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
|
||||
};
|
||||
_logger.LogDebug("GetWidgetAsync widget={WidgetId} result={Result} user={User}",
|
||||
widgetId, result.GetType().Name, userAccountId);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SqlParameter> MakeParams(string userAccountId, params SqlParameter[] extra)
|
||||
private static List<Microsoft.Data.SqlClient.SqlParameter> Params(string userAccountId,
|
||||
params Microsoft.Data.SqlClient.SqlParameter[] extra)
|
||||
{
|
||||
var list = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
var list = new List<Microsoft.Data.SqlClient.SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
list.AddRange(extra);
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── "my" — list of widget short-names for the current user ───────────────
|
||||
private async Task<IActionResult> HandleWidgetMy(string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId, SQL_VarChar("@account", "fis")),
|
||||
Security: dbSec);
|
||||
Conn, Params(userAccountId, SQL_VarChar("@account", "fis")), Security: dbSec);
|
||||
var names = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
|
||||
@@ -67,6 +77,7 @@ public class FuchsWidgetService : IWidgetService
|
||||
return await JSONAsync(names);
|
||||
}
|
||||
|
||||
// ── "one" — full widget data for a single widget ──────────────────────────
|
||||
private async Task<IActionResult> HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec,
|
||||
HttpRequest request)
|
||||
{
|
||||
@@ -75,55 +86,120 @@ public class FuchsWidgetService : IWidgetService
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId,
|
||||
Conn,
|
||||
Params(userAccountId,
|
||||
SQL_VarChar("@shortname", shortName),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
Security: dbSec);
|
||||
|
||||
if (dt.Count != 1) return new StatusCodeResult(404);
|
||||
var wdg = dt.FirstRow.toObjectDictionary();
|
||||
return await BuildWidgetResponse(userAccountId, dbSec, wdg);
|
||||
return await BuildWidgetResponse(userAccountId, dbSec, shortName, wdg);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
|
||||
// ── Unknown widget id ──────────────────────────────────────────────────────
|
||||
// The dashboard only requests "my" and "one"; there is no generic widget
|
||||
// source in the schema (the legacy code had no such procedure either).
|
||||
private Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
|
||||
DatabaseSecurity dbSec)
|
||||
{
|
||||
var pl = MakeParams(userAccountId, SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "data" },
|
||||
Security: dbSec);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
data = dset.Tables("data").toArrayofObjectDictionaries()
|
||||
});
|
||||
_ = dbSec;
|
||||
_logger.LogWarning("GetWidgetAsync: unknown widget id '{WidgetId}' requested by user={User}", widgetId, userAccountId);
|
||||
return Task.FromResult<IActionResult>(new NotFoundResult());
|
||||
}
|
||||
|
||||
private async Task<IActionResult> BuildWidgetResponse(string userAccountId,
|
||||
DatabaseSecurity dbSec, Dictionary<string, object?> wdg)
|
||||
// ── Widget renderer dispatcher ────────────────────────────────────────────
|
||||
private async Task<IActionResult> BuildWidgetResponse(string userAccountId, DatabaseSecurity dbSec,
|
||||
string shortName, Dictionary<string, object?> wdg)
|
||||
{
|
||||
string type = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
string dbType = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
var ropts = ParseRenderingOptions(wdg.nz("rendering_options", "") ?? "");
|
||||
string name = wdg.nz("name", "") ?? "";
|
||||
string descr = wdg.nz("description", "") ?? "";
|
||||
|
||||
if (type.StartsWith("sql") && !string.IsNullOrEmpty(sql))
|
||||
object widgetData;
|
||||
|
||||
switch (dbType)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId), Security: dbSec);
|
||||
return await JSONAsync(new
|
||||
case "sql_table":
|
||||
{
|
||||
name = wdg.nz("name", ""),
|
||||
type,
|
||||
rows = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary())
|
||||
.ToArray()
|
||||
});
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "table",
|
||||
rendering_options = ropts,
|
||||
columns = dt.DataTable.Columns.Cast<System.Data.DataColumn>()
|
||||
.Select(c => c.ColumnName).ToArray(),
|
||||
data = dt.DataTable.Rows.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary()).ToArray()
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sql_indicator":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
var firstRow = dt.DataTable.Rows.Count > 0
|
||||
? dt.DataTable.Rows[0].toObjectDictionary()
|
||||
: new Dictionary<string, object?>();
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "ind",
|
||||
rendering_options = ropts,
|
||||
data = new
|
||||
{
|
||||
status = firstRow.nz("status", "") ?? "",
|
||||
value = firstRow.nz("value", "") ?? "",
|
||||
label = firstRow.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "html",
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? ""
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = dbType,
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? "",
|
||||
url = wdg.nz("url", "") ?? "",
|
||||
image = wdg.nz("image", "") ?? "",
|
||||
data = (object)new
|
||||
{
|
||||
status = wdg.nz("status", "") ?? "",
|
||||
value = wdg.nz("value", "") ?? "",
|
||||
label = wdg.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return await JSONAsync(wdg);
|
||||
// Wrap under short_name key so the front-end can do response[wi]
|
||||
return await JSONAsync(new Dictionary<string, object> { [shortName] = widgetData });
|
||||
}
|
||||
|
||||
private static string[] ParseRenderingOptions(string raw) =>
|
||||
string.IsNullOrWhiteSpace(raw)
|
||||
? Array.Empty<string>()
|
||||
: raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for MT940 bank statement parsing.
|
||||
/// Abstraction for bank statement parsing. Supports both MT940 (SWIFT text)
|
||||
/// and CAMT (ISO 20022 camt.052/053/054 XML); the format is auto-detected.
|
||||
/// </summary>
|
||||
public interface IBankingService
|
||||
{
|
||||
/// <summary>Abbreviation for a debit/credit mark.</summary>
|
||||
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
|
||||
|
||||
/// <summary>Parses an MT940 stream into a DataTable.</summary>
|
||||
/// <summary>
|
||||
/// Parses a bank statement stream (MT940 or CAMT, auto-detected) into a DataTable
|
||||
/// matching the banking-transactions schema.
|
||||
/// </summary>
|
||||
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for sending emails with optional attachments and audit logging.
|
||||
/// Abstraction for outbound communication: email and SMS.
|
||||
/// Backed by the ProcessWeb Mailer API (POST /api/mailer?fn=push_com).
|
||||
/// </summary>
|
||||
public interface IEmailService
|
||||
public interface IComService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an email and logs the result to the database.
|
||||
@@ -14,7 +15,15 @@ public interface IEmailService
|
||||
/// <param name="email">Recipient email address.</param>
|
||||
/// <param name="name">Recipient display name.</param>
|
||||
/// <param name="attachments">Optional file attachments (filename → content).</param>
|
||||
/// <returns><c>true</c> when the email was sent successfully.</returns>
|
||||
/// <returns><c>true</c> when the email was accepted successfully.</returns>
|
||||
Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
string email, string name, Dictionary<string, byte[]>? attachments);
|
||||
string email, string name, Dictionary<string, byte[]>? attachments = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an SMS message.
|
||||
/// </summary>
|
||||
/// <param name="mobile">Recipient mobile number (E.164 or local format).</param>
|
||||
/// <param name="message">Text message body.</param>
|
||||
/// <returns><c>true</c> when the SMS was accepted successfully.</returns>
|
||||
Task<bool> SendSmsAsync(string mobile, string message);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQL connections to the Fuchs database.
|
||||
/// </summary>
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
/// <summary>Gets the primary FDS connection string.</summary>
|
||||
string ConnectionString { get; }
|
||||
|
||||
/// <summary>Creates and opens a new SQL connection.</summary>
|
||||
SqlConnection CreateConnection();
|
||||
|
||||
/// <summary>Creates a new SQL connection (not opened).</summary>
|
||||
SqlConnection CreateClosedConnection();
|
||||
}
|
||||
@@ -13,8 +13,8 @@ public interface IInvoiceService
|
||||
/// <summary>Loads an existing invoice by ID.</summary>
|
||||
Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Registers (creates or updates) an invoice from form data.</summary>
|
||||
Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
|
||||
/// <summary>Registers (creates or updates) an invoice from a parsed data object.</summary>
|
||||
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Generates a PDF document for an invoice.</summary>
|
||||
|
||||
@@ -13,8 +13,8 @@ public interface IReminderService
|
||||
/// <summary>Loads an existing reminder by ID.</summary>
|
||||
Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Registers (creates) a reminder from form data.</summary>
|
||||
Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId,
|
||||
/// <summary>Registers (creates) a reminder from a parsed data object.</summary>
|
||||
Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Generates a PDF document for a reminder.</summary>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for report processing.
|
||||
/// Abstraction for SQL-driven report processing (HTML page / fragment / PNG chart).
|
||||
/// </summary>
|
||||
public interface IReportService
|
||||
{
|
||||
/// <summary>Processes a report request.</summary>
|
||||
Task<IActionResult> ProcessRequestAsync(string action, string id,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
/// <summary>
|
||||
/// Processes a report request.
|
||||
/// </summary>
|
||||
/// <param name="fnc">Target function: "generic", "generic_content"/"gct", "chart", or a query-type name.</param>
|
||||
/// <param name="reportId">Report name/id (the URL <c>code</c> segment).</param>
|
||||
/// <param name="userAccountId">Authenticated user id.</param>
|
||||
/// <param name="dbSec">Database security context.</param>
|
||||
/// <param name="parameters">Merged query-string + form parameters.</param>
|
||||
Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
|
||||
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,151 @@
|
||||
using Fuchs.intranet;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using OCORE.security;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice service implementation. Extracts DB operations from <c>FdsInvoiceData</c>
|
||||
/// and PDF generation into a proper DI service.
|
||||
/// Invoice service — load, register, render and store invoices.
|
||||
/// Replaces the controller-coupled, sync-over-async logic that previously lived
|
||||
/// inside <see cref="FdsInvoiceData"/>.
|
||||
/// </summary>
|
||||
public class InvoiceService : IInvoiceService
|
||||
{
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdfService, ILogger<InvoiceService> logger)
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdfService = pdfService;
|
||||
_pdf = pdf;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
// TODO: Complete after FdsInvoiceData is refactored to remove IntranetController dependency
|
||||
throw new NotImplementedException("InvoiceService.LoadInvoiceAsync pending FdsInvoiceData refactor.");
|
||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||
var inv = new FdsInvoiceData();
|
||||
if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadInvoiceAsync called with empty id (user={User})", userAccountId); return inv; }
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", id),
|
||||
SQL_Bit("@includefile", false)
|
||||
};
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
|
||||
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
_logger.LogError("LoadInvoiceAsync sql exception for {Id}: {Ex}", id, dset.Exception);
|
||||
|
||||
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
||||
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
|
||||
return inv;
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.RegisterInvoiceAsync pending FdsInvoiceData refactor.");
|
||||
_logger.LogInformation("RegisterInvoiceAsync change={Change} invId={InvId} user={User}", change, invId, userAccountId);
|
||||
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
|
||||
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (invoice.RawProvisionLocation.Length > 0)
|
||||
{
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
Conn, pl, tablenames: new[] { "inv", "det", "req", "itm" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
|
||||
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.GenerateInvoicePdf pending FdsInvoiceData refactor.");
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||
act?.SetTag("fuchs.invoice.draft", draft);
|
||||
_logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = reg?.getString("Id") ?? "",
|
||||
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
};
|
||||
// WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core).
|
||||
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
|
||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||
_pdf.ApplyInvoice(doc, tb, invoice, draft);
|
||||
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
|
||||
return doc;
|
||||
}
|
||||
|
||||
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.RenderInvoicePdfBytesAsync pending FdsInvoiceData refactor.");
|
||||
}
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.StoreInvoiceDocumentFileAsync pending FdsInvoiceData refactor.");
|
||||
byte[] ba;
|
||||
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
fds.IFdsMfr mfr)
|
||||
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
|
||||
{
|
||||
if (invoice.InvoiceRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory implementation for <see cref="fds.FdsMfrClient"/>.
|
||||
/// Centralizes MFR client creation and supplies logger from DI.
|
||||
/// Centralizes MFR client creation, supplies the logger from DI, and counts
|
||||
/// client instantiations as a proxy for MFR ERP interactions.
|
||||
/// </summary>
|
||||
public class MfrClientFactory : IMfrClientFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<MfrClientFactory> _logger;
|
||||
|
||||
public MfrClientFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
|
||||
}
|
||||
|
||||
public fds.FdsMfrClient Create()
|
||||
{
|
||||
FuchsTelemetry.MfrCalls.Add(1);
|
||||
_logger.LogDebug("Creating new FdsMfrClient instance.");
|
||||
return new fds.FdsMfrClient(_loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound communication service backed by the ProcessWeb Mailer API
|
||||
/// (POST https://api.processweb.de/api/mailer?fn=push_com).
|
||||
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
|
||||
/// only logs the intended communication without calling the API.
|
||||
/// </summary>
|
||||
public class ProcessWebComService : IComService
|
||||
{
|
||||
private readonly ILogger<ProcessWebComService> _logger;
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ProcessWebComSettings _settings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
private const string SignatureIntro =
|
||||
"<p> </p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
|
||||
"Herzliche Grüße aus Düsseldorf-Bilk<br/>" +
|
||||
"Ihr Team der Firma Sebastian Fuchs</p>";
|
||||
|
||||
public ProcessWebComService(
|
||||
ILogger<ProcessWebComService> logger,
|
||||
Fuchs_intranet intranet,
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_intranet = intranet;
|
||||
_settings = settings.Value;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
string email, string name, Dictionary<string, byte[]>? attachments = null)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("email.send");
|
||||
act?.SetTag("fuchs.email.ref", reference);
|
||||
if (!IsValidEmail(email))
|
||||
{
|
||||
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
||||
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
|
||||
return false;
|
||||
}
|
||||
|
||||
string body = html + BuildSignature();
|
||||
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
|
||||
reference, subject, email);
|
||||
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]);
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
string messageId = "";
|
||||
var errors = new List<string>();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Attachments are transmitted inline as base64 in the same push_com POST.
|
||||
// Each entry: { filename, mimeType, contentBase64 }.
|
||||
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
|
||||
.Where(kv => kv.Value is { Length: > 0 })
|
||||
.Select(kv => new
|
||||
{
|
||||
filename = kv.Key,
|
||||
mimeType = GuessMimeType(kv.Key),
|
||||
contentBase64 = Convert.ToBase64String(kv.Value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var payload = new
|
||||
{
|
||||
comType = "email",
|
||||
recipient = email,
|
||||
subject,
|
||||
body,
|
||||
attachments = attachmentPayload
|
||||
};
|
||||
|
||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
|
||||
reference, email, attachmentPayload.Length);
|
||||
|
||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
||||
if (ok)
|
||||
{
|
||||
success = true;
|
||||
messageId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"API error: {responseBody}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Body}", reference, responseBody);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("success", success));
|
||||
if (success) FuchsTelemetry.EmailsSent.Add(1);
|
||||
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
|
||||
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
|
||||
reference, success, sw.ElapsedMilliseconds);
|
||||
|
||||
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
|
||||
return success;
|
||||
}
|
||||
|
||||
public async Task<bool> SendSmsAsync(string mobile, string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mobile))
|
||||
{
|
||||
_logger.LogWarning("SendSmsAsync: empty mobile number");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[ComService DISABLED] Would send SMS to={Mobile} message='{Message}'",
|
||||
mobile, message);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
comType = "sms",
|
||||
recipient = mobile,
|
||||
body = message
|
||||
};
|
||||
|
||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
||||
if (ok)
|
||||
{
|
||||
FuchsTelemetry.SmsSent.Add(1);
|
||||
_logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SendSmsAsync failed for {Mobile}", mobile);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(bool ok, string body)> PostToApiAsync(string fn, object payload)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
string credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
var response = await client.PostAsync($"{_settings.BaseUrl}/api/mailer?fn={fn}", content);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return (response.IsSuccessStatusCode, responseBody);
|
||||
}
|
||||
|
||||
private async Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@Ref", reference),
|
||||
SQL_VarChar("@guid", guid),
|
||||
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
|
||||
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
|
||||
SQL_Bit("@success", success),
|
||||
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
|
||||
};
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: _intranet.GetDbSecurity());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write audit log for {Reference}", reference);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSignature()
|
||||
{
|
||||
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 */ }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string GuessMimeType(string filename) =>
|
||||
Path.GetExtension(filename).ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".txt" => "text/plain",
|
||||
".csv" => "text/csv",
|
||||
".xml" => "application/xml",
|
||||
".zip" => "application/zip",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls" => "application/vnd.ms-excel",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var a = new System.Net.Mail.MailAddress(email);
|
||||
return string.Equals(a.Address, email, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
|
||||
/// </summary>
|
||||
public class ProcessWebComSettings
|
||||
{
|
||||
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
|
||||
public string BaseUrl { get; set; } = "https://api.processweb.de";
|
||||
|
||||
/// <summary>Account ID used for HTTP Basic authentication.</summary>
|
||||
public string AccountId { get; set; } = "";
|
||||
|
||||
/// <summary>API token used for HTTP Basic authentication.</summary>
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>false</c> (default) the service is disabled and only logs the
|
||||
/// intended communication without actually calling the API.
|
||||
/// Set to <c>true</c> to enable live sending.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
}
|
||||
@@ -1,67 +1,179 @@
|
||||
using Fuchs.intranet;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reminder service implementation. Extracts DB operations from <c>FdsReminderData</c>
|
||||
/// into a proper DI service.
|
||||
/// Reminder service — load, register, render and store reminders.
|
||||
/// Replaces the controller-coupled, sync-over-async logic that previously lived
|
||||
/// inside <see cref="FdsReminderData"/>.
|
||||
/// </summary>
|
||||
public class ReminderService : IReminderService
|
||||
{
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly ILogger<ReminderService> _logger;
|
||||
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdfService, ILogger<ReminderService> logger)
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, ILogger<ReminderService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdfService = pdfService;
|
||||
_pdf = pdf;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.LoadReminderAsync pending FdsReminderData refactor.");
|
||||
_logger.LogDebug("LoadReminderAsync id={Id} user={User}", id, userAccountId);
|
||||
var rem = new FdsReminderData();
|
||||
if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadReminderAsync called with empty id (user={User})", userAccountId); return rem; }
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("Id", id),
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_Bit("@includefile", false)
|
||||
};
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||
Conn, pl, tablenames: new[] { "admin", "rem" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
_logger.LogError("LoadReminderAsync sql exception for {Id}: {Ex}", id, dset.Exception);
|
||||
|
||||
rem.ReminderRegistration = new GenericObjectDictionary(dset.Table("rem").FirstRow.toObjectDictionary());
|
||||
rem.IsDraft = rem.ReminderRegistration.getItem("IsFinal") is not true;
|
||||
return rem;
|
||||
}
|
||||
|
||||
public async Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId,
|
||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.RegisterReminderAsync pending FdsReminderData refactor.");
|
||||
_logger.LogInformation("RegisterReminderAsync change={Change} remId={RemId} user={User}", change, remId, userAccountId);
|
||||
if (reminder.Rem == null || reminder.Rem.Count == 0) { _logger.LogWarning("RegisterReminderAsync: no form values supplied (user={User})", userAccountId); return reminder; }
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("InvId", reminder.RawInvId),
|
||||
SQL_Char("type", reminder.Rem["type"]?.ToString() ?? ""),
|
||||
SQL_Float("amount", stringvalue: reminder.NewValues?.nz("amount") ?? ""),
|
||||
SQL_Float("amount_payed", stringvalue: reminder.NewValues?.nz("amount_payed") ?? ""),
|
||||
SQL_VarChar("SendToAddress", string.Join("\n", reminder.RawInvoiceAddress)),
|
||||
SQL_NVarChar("SendToEmail", reminder.RawInvoiceEmail),
|
||||
SQL_NVarChar("subject", reminder.NewValues?.getString("subject") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
||||
};
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change || string.IsNullOrEmpty(remId))
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
else
|
||||
pl.Add(SQL_VarChar("RemId", remId));
|
||||
|
||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
Conn, pl, tablenames: new[] { "rem" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
||||
|
||||
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
|
||||
return reminder;
|
||||
}
|
||||
|
||||
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.GenerateReminderPdf pending FdsReminderData refactor.");
|
||||
using var act = FuchsTelemetry.StartActivity("reminder.render");
|
||||
act?.SetTag("fuchs.reminder.id", reminder.Id);
|
||||
act?.SetTag("fuchs.reminder.draft", draft);
|
||||
_logger.LogDebug("GenerateReminderPdf id={Id} draft={Draft}", reminder.Id, draft);
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = "",
|
||||
Address = reminder.InvoiceAddress,
|
||||
AdminUser = reminder.UserNameFinalized,
|
||||
AdminUserEmail = reminder.UserEmailFinalized,
|
||||
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
|
||||
};
|
||||
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
|
||||
{
|
||||
var o = new GenericObjectDictionary(reminder.RawCustomValues);
|
||||
string oEmail = (string?)o.getItem("contactEmail") ?? "";
|
||||
string oName = (string?)o.getItem("contactName") ?? "";
|
||||
if (!string.IsNullOrEmpty(oEmail) || !string.IsNullOrEmpty(oName))
|
||||
{
|
||||
tb.AdminUser = oName;
|
||||
tb.AdminUserEmail = oEmail;
|
||||
}
|
||||
}
|
||||
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
|
||||
doc.Info.Title = reminder.ReminderTitle;
|
||||
_pdf.ApplyReminder(doc, tb, reminder, draft);
|
||||
FuchsTelemetry.RemindersRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
|
||||
return doc;
|
||||
}
|
||||
|
||||
public async Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData reminder, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.RenderReminderPdfBytesAsync pending FdsReminderData refactor.");
|
||||
}
|
||||
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData reminder, bool draft)
|
||||
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateReminderPdf(reminder, draft)));
|
||||
|
||||
public async Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData reminder, bool draft,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.StoreReminderDocumentFileAsync pending FdsReminderData refactor.");
|
||||
byte[] ba;
|
||||
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("Id", reminder.Id),
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetReminderFileAsync(FdsReminderData reminder, bool draft,
|
||||
fds.IFdsMfr mfr, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.GetReminderFileAsync pending FdsReminderData refactor.");
|
||||
if (reminder.ReminderRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
if (!reminder.ReminderRegistration.ContainsKey("hasFile") ||
|
||||
reminder.ReminderRegistration.getItem("hasFile", false) is not true)
|
||||
await StoreReminderDocumentFileAsync(reminder, draft, userAccountId, dbSec);
|
||||
byte[]? ba = null;
|
||||
mfr.GetFdsDoc(ref ba, reminder.Id, "reminder");
|
||||
return ba ?? Array.Empty<byte>();
|
||||
}
|
||||
return await RenderReminderPdfBytesAsync(reminder, draft);
|
||||
}
|
||||
|
||||
public async Task<(FileInfo? file, byte[]? content)> GetStoredFileAsync(string reminderId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("ReminderService.GetStoredFileAsync pending FdsReminderData refactor.");
|
||||
var dset = await getSQLDataSet_async(
|
||||
"SELECT TOP(1) * FROM [dbo].[fds__reminder] WHERE [Id] = @Id AND [file] is not null;",
|
||||
Conn,
|
||||
new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", reminderId) },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
var row = dset.FirstTable().FirstRow.toObjectDictionary();
|
||||
if (row.Count > 0 && !string.IsNullOrEmpty(row.nz("DocumentName")) && row.no("file", null!) is byte[] b)
|
||||
return (new FileInfo(row.nz("DocumentName")), b);
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// SMTP account configuration for a single email account.
|
||||
/// Property names match the OCORE EmailServerSettings JSON format (lowercase).
|
||||
/// </summary>
|
||||
public class SmtpAccountSettings
|
||||
{
|
||||
[JsonProperty("alias")]
|
||||
public string Alias { get; set; } = "";
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; } = "";
|
||||
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; } = "";
|
||||
|
||||
[JsonProperty("bcc")]
|
||||
public string Bcc { get; set; } = "";
|
||||
|
||||
[JsonProperty("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonProperty("port")]
|
||||
public int Port { get; set; } = 587;
|
||||
|
||||
[JsonProperty("security")]
|
||||
public string Security { get; set; } = "StartTls";
|
||||
|
||||
[JsonProperty("username")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[JsonProperty("password")]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; } = "smtp";
|
||||
|
||||
/// <summary>
|
||||
/// Serializes this instance back to the JSON string format expected by
|
||||
/// <c>OCORE.email.EmailServerSettings(type, raw)</c>.
|
||||
/// </summary>
|
||||
public string ToOcoreJson() => JsonConvert.SerializeObject(this);
|
||||
}
|
||||
+10
-38
@@ -8,9 +8,7 @@
|
||||
"ConnectionStrings--ocms-ConnectionString",
|
||||
"ConnectionStrings--fuchs-fds-ConnectionString",
|
||||
"Fuchs--SMS-APIKey",
|
||||
"Fuchs--Email--Main--password",
|
||||
"Fuchs--Email--Fds--password",
|
||||
"Fuchs--Email--Service--password",
|
||||
"Fuchs--Mailer--Token",
|
||||
"Fuchs--fuchs-captcha-TOTP",
|
||||
"Fuchs--fuchs-intranet-TOTP"
|
||||
]
|
||||
@@ -35,41 +33,15 @@
|
||||
"fuchs_captcha_TOTP": "MANAGED_BY_KEYVAULT",
|
||||
"fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
|
||||
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
|
||||
"Email": {
|
||||
"Main": {
|
||||
"alias": "Sebastian Fuchs - Bad und Heizung",
|
||||
"to": "anfrage@sanitaerfuchs.de",
|
||||
"from": "anfrage@sanitaerfuchs.de",
|
||||
"bcc": "info@processweb.de",
|
||||
"host": "smtp.office365.com",
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "anfrage@sanitaerfuchs.de",
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Fds": {
|
||||
"alias": "Sebastian Fuchs - Bad und Heizung",
|
||||
"to": "",
|
||||
"from": "rechnungen@sanitaerfuchs.de",
|
||||
"bcc": "",
|
||||
"host": "smtp.office365.com",
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "rechnungen@sanitaerfuchs.de",
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Service": {
|
||||
"alias": "ProcessWeb Service",
|
||||
"to": "",
|
||||
"from": "service@emails.processweb.de",
|
||||
"bcc": "",
|
||||
"host": "emails.processweb.de",
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "service@emails.processweb.de",
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"TestAddresses": "st.ott@web.de,info@processweb.de"
|
||||
"Mailer": {
|
||||
"BaseUrl": "https://api.processweb.de",
|
||||
"AccountId": "",
|
||||
"Token": "MANAGED_BY_KEYVAULT",
|
||||
"Enabled": false
|
||||
},
|
||||
"Telemetry": {
|
||||
"Enabled": true,
|
||||
"OtlpEndpoint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Data;
|
||||
using programmersdigest.MT940Parser;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// MT940 bank statement parser helpers.
|
||||
/// </summary>
|
||||
public static class Banking
|
||||
{
|
||||
public static string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
|
||||
{
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.ReverseCredit => "RC",
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public static DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
logger ??= NullLogger.Instance;
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null)
|
||||
nr[key] = value;
|
||||
}
|
||||
|
||||
using var ps = new Parser(stream: stream);
|
||||
try
|
||||
{
|
||||
foreach (var statement in ps.Parse())
|
||||
{
|
||||
if (string.IsNullOrEmpty(statement.AccountIdentification)) continue;
|
||||
foreach (var line in statement.Lines)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
SetNfo(nr, "BankReference", line.BankReference);
|
||||
|
||||
var info = line.InformationToOwner;
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
|
||||
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "SupplementaryDetails",line.SupplementaryDetails);
|
||||
SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { logger.LogError(ex, "MT940 statement parse failed."); }
|
||||
|
||||
tbl.AcceptChanges();
|
||||
return tbl;
|
||||
}
|
||||
|
||||
private static DataTable BuildDefaultSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
var cols = t.Columns;
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("UnstructuredRemittanceInformation", typeof(string));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
+87
-177
@@ -1,10 +1,6 @@
|
||||
using System.Data;
|
||||
using Fuchs.Controllers;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.commons;
|
||||
@@ -12,25 +8,26 @@ using static OCORE.commons;
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates invoice (Rechnung) data. Converted from VB fds__invoice_data class.
|
||||
/// Invoice (Rechnung) data holder. Converted from VB fds__invoice_data.
|
||||
/// Pure data + parameter mapping — all persistence and PDF generation now live
|
||||
/// in <see cref="Fuchs.Services.IInvoiceService"/> (no controller coupling).
|
||||
/// </summary>
|
||||
public class FdsInvoiceData
|
||||
{
|
||||
private readonly JObject? _base;
|
||||
private Document? _letter;
|
||||
|
||||
public GenericObjectDictionary? Admin { get; private set; }
|
||||
public GenericObjectDictionary? NewValues { get; private set; }
|
||||
public GenericObjectDictionary? Sms { get; private set; }
|
||||
public List<Dictionary<string, object>>? Req { get; private set; }
|
||||
|
||||
public GenericObjectDictionary? InvoiceRegistration { get; private set; }
|
||||
public bool IsDraft { get; private set; } = true;
|
||||
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
|
||||
public bool IsDraft { get; internal set; } = true;
|
||||
|
||||
public string Id => InvoiceRegistration?.getString("Id") ?? "";
|
||||
public string PaymentTerms => InvoiceRegistration?.getString("PaymentTerm") ?? "";
|
||||
|
||||
// -- PDF-facing properties (used by FuchsPdf.ApplyInvoice) ----------------
|
||||
// -- PDF-facing properties (used by IPdfService.ApplyInvoice) -------------
|
||||
public string InvoiceType =>
|
||||
InvoiceRegistration?.getString("InvoiceType").Substr(0, 1) ?? "R";
|
||||
public string InvoiceId =>
|
||||
@@ -56,7 +53,7 @@ public class FdsInvoiceData
|
||||
{
|
||||
IEnumerable<Dictionary<string, object?>>? itms =
|
||||
itmsObj as IEnumerable<Dictionary<string, object?>>
|
||||
?? (itmsObj is Newtonsoft.Json.Linq.JArray ja
|
||||
?? (itmsObj is JArray ja
|
||||
? ja.ToObject<List<Dictionary<string, object?>>>()
|
||||
: null);
|
||||
if (itms != null) result.AddRange(itms);
|
||||
@@ -73,33 +70,33 @@ public class FdsInvoiceData
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, object?>>();
|
||||
if (InvoiceRegistration == null) return result;
|
||||
// Primary VAT slot
|
||||
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_1"), out decimal ust1)
|
||||
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net1"), out decimal net1)
|
||||
&& !(ust1 == 0 && net1 == 0))
|
||||
result[ust1.ToString("0.##")] = new Dictionary<string, object?>
|
||||
{ ["vat_amount"] = net1 };
|
||||
// Secondary VAT slot
|
||||
result[ust1.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net1 };
|
||||
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_2"), out decimal ust2)
|
||||
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net2"), out decimal net2)
|
||||
&& !(ust2 == 0 && net2 == 0))
|
||||
result[ust2.ToString("0.##")] = new Dictionary<string, object?>
|
||||
{ ["vat_amount"] = net2 };
|
||||
result[ust2.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net2 };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Raw properties --------------------------------------------------------
|
||||
private string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
||||
private string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
private string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
private string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
||||
private string[] RawProvisionLocation =>
|
||||
// -- Raw form properties (used by parameter mapping) -----------------------
|
||||
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
||||
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
||||
internal string[] RawProvisionLocation =>
|
||||
NewValues?.nz("provisionlocation") is { Length: > 0 } s
|
||||
? s.Replace("\r\n", "\n").Split('\n').Select(t => t.Trim()).Where(t => t != "").ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
// -- Ctors -----------------------------------------------------------------
|
||||
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
|
||||
public FdsInvoiceData() { }
|
||||
|
||||
/// <summary>Parses form data (admin/new/sms/req) into the data object.</summary>
|
||||
public FdsInvoiceData(object ctd)
|
||||
{
|
||||
_base = ctd as JObject;
|
||||
@@ -113,157 +110,14 @@ public class FdsInvoiceData
|
||||
}
|
||||
}
|
||||
|
||||
public FdsInvoiceData(string id, IntranetController ctrl) => RegisterInvoice(id, ctrl);
|
||||
|
||||
// -- PDF -------------------------------------------------------------------
|
||||
public Document InvoicePDF(IntranetController ctrl)
|
||||
// -- Parameter mapping (consumed by InvoiceService.RegisterInvoiceAsync) ---
|
||||
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
|
||||
{
|
||||
if (_letter != null) return _letter;
|
||||
if (InvoiceRegistration == null) RegisterInvoice(Id, ctrl);
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = InvoiceRegistration?.getString("Id") ?? "",
|
||||
Address = InvoiceRegistration?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = InvoiceRegistration?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = InvoiceRegistration?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = InvoiceRegistration?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
};
|
||||
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
|
||||
_letter.Info.Title = InvoiceRegistration?.getString("InvoiceTitle") ?? "";
|
||||
FuchsPdf.ApplyInvoice(_letter, tb, this, draft: IsDraft);
|
||||
return _letter;
|
||||
}
|
||||
|
||||
// -- File operations -------------------------------------------------------
|
||||
public async Task<byte[]?> GetInvoiceFile(IntranetController ctrl)
|
||||
{
|
||||
if (InvoiceRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
byte[]? ba = null;
|
||||
ctrl._mfr.GetFdsDoc(ref ba, Id, "invoice");
|
||||
return ba;
|
||||
}
|
||||
return await RenderToPdfBytes(ctrl);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFile(IntranetController ctrl)
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderToPdfBytes(ctrl); }
|
||||
catch { ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", Id));
|
||||
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
|
||||
{
|
||||
var pdfrend = new PdfDocumentRenderer() { Document = InvoicePDF(ctrl) };
|
||||
pdfrend.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
pdfrend.PdfDocument.Save(ms, false);
|
||||
ms.Position = 0;
|
||||
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
|
||||
}
|
||||
|
||||
// -- Registration ----------------------------------------------------------
|
||||
public void RegisterInvoice(IntranetController ctrl, bool change, string invId)
|
||||
{
|
||||
if (NewValues == null) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist();
|
||||
pl.AddRange(BuildInvoiceParams(change, invId));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (RawProvisionLocation.Length > 0)
|
||||
{
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "inv", "det", "req", "itm" },
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice - sql exception",
|
||||
data: new { exception = invdset.Exception });
|
||||
|
||||
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public void RegisterInvoice(string id, IntranetController ctrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", id));
|
||||
pl.Add(SQL_Bit("@includefile", false));
|
||||
var invdset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id) - sql exception",
|
||||
data: new { exception = invdset.Exception });
|
||||
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
IsDraft = InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id)", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
// -- Param builder ---------------------------------------------------------
|
||||
private List<SqlParameter> BuildInvoiceParams(bool change, string invId)
|
||||
{
|
||||
var vatsDic = new Dictionary<string, string>();
|
||||
if (Req != null)
|
||||
{
|
||||
foreach (var rq in Req)
|
||||
{
|
||||
if (rq.TryGetValue("items", out var itmsObj) &&
|
||||
itmsObj is List<object> itms)
|
||||
{
|
||||
foreach (var itm in itms.OfType<Dictionary<string, object?>>())
|
||||
{
|
||||
string vatKey = itm.nz("vat", "").Replace("%", "").Trim();
|
||||
if (!string.IsNullOrEmpty(vatKey) && vatKey != "0")
|
||||
vatsDic.TryAdd(vatKey, vatsDic.TryGetValue(vatKey, out var ve) ? ve : "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
string vathigh = vatsDic.Keys.OrderByDescending(k => double.TryParse(k, out var d) ? d : 0).FirstOrDefault() ?? "19";
|
||||
_ = change; _ = invId;
|
||||
// VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
|
||||
// matching the legacy contract. Item-level VAT strings are German-formatted and
|
||||
// single-rate procs only store one rate, so the highest rate wins.
|
||||
var (vatRate, vatNet) = HighestVat(Sms);
|
||||
|
||||
return new List<SqlParameter>
|
||||
{
|
||||
@@ -271,8 +125,8 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"),
|
||||
SQL_VarChar("@InvoiceVAT_1", vathigh),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
|
||||
@@ -281,8 +135,64 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||
SQL_VarChar("@InvoiceOptions",
|
||||
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
|
||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
||||
/// selects the set-pricing display mode (the default <c>setprice</c> is omitted).
|
||||
/// Read back by <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/> (set mode)
|
||||
/// and the §13b PDF note. Both ride the <c>admin</c> object the editor posts.
|
||||
/// </summary>
|
||||
internal string BuildInvoiceOptions()
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
|
||||
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
|
||||
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the highest VAT rate and its net VAT amount from the editor's
|
||||
/// computed <c>sms.vat</c> map (rate string → amount), e.g. {"19,0%": 123.45}.
|
||||
/// Mirrors the legacy contract (VAT comes from the totals block, not line items).
|
||||
/// Returns invariant numeric strings — rate ("19") and amount ("123.45") — ready
|
||||
/// for the <c>numeric</c> proc params. Empty/absent map → ("0","0").
|
||||
/// </summary>
|
||||
internal static (string rate, string netAmount) HighestVat(GenericObjectDictionary? sms)
|
||||
{
|
||||
if (sms == null || !sms.TryGetValue("vat", out var vobj) || vobj is null)
|
||||
return ("0", "0");
|
||||
|
||||
bool found = false; double bestRate = 0, bestAmount = 0;
|
||||
void Consider(string key, double amount)
|
||||
{
|
||||
double r = ParseRate(key);
|
||||
if (!found || r > bestRate) { found = true; bestRate = r; bestAmount = amount; }
|
||||
}
|
||||
|
||||
if (vobj is JObject jo)
|
||||
foreach (var p in jo)
|
||||
Consider(p.Key, p.Value is { } t && t.Type is JTokenType.Float or JTokenType.Integer ? t.Value<double>() : 0);
|
||||
else if (vobj is IDictionary<string, object?> dict)
|
||||
foreach (var kv in dict)
|
||||
Consider(kv.Key, double.TryParse(Convert.ToString(kv.Value, CultureInfo.InvariantCulture),
|
||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var a) ? a : 0);
|
||||
|
||||
if (!found) return ("0", "0");
|
||||
string rate = bestRate == Math.Floor(bestRate)
|
||||
? ((long)bestRate).ToString(CultureInfo.InvariantCulture)
|
||||
: bestRate.ToString(CultureInfo.InvariantCulture);
|
||||
return (rate, bestAmount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Parses a VAT rate string ("19,0%", "7%", "19") to a number (German or invariant).</summary>
|
||||
private static double ParseRate(string? key)
|
||||
{
|
||||
string s = (key ?? "").Replace("%", "").Trim().Replace(',', '.');
|
||||
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-167
@@ -1,42 +1,35 @@
|
||||
using System.Data;
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.commons;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a reminder (Zahlungserinnerung) data object.
|
||||
/// Converted from VB fds__reminder_data class.
|
||||
/// Reminder (Zahlungserinnerung) data holder. Converted from VB fds__reminder_data.
|
||||
/// Pure data — persistence and PDF generation live in
|
||||
/// <see cref="Fuchs.Services.IReminderService"/> (no controller coupling).
|
||||
/// </summary>
|
||||
public class FdsReminderData
|
||||
{
|
||||
private readonly JObject? _base;
|
||||
private Document? _letter;
|
||||
|
||||
public GenericObjectDictionary? NewValues { get; private set; }
|
||||
public GenericObjectDictionary? Rem { get; private set; }
|
||||
public GenericObjectDictionary? ReminderRegistration { get; private set; }
|
||||
public bool IsDraft { get; private set; } = true;
|
||||
public GenericObjectDictionary? ReminderRegistration { get; internal set; }
|
||||
public bool IsDraft { get; internal set; } = true;
|
||||
|
||||
public string Id => ReminderRegistration?.getString("Id") ?? "";
|
||||
|
||||
// -- Raw props from form data ---------------------------------------------
|
||||
public string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
|
||||
internal string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
|
||||
? s.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n")
|
||||
.Replace("\r\n", "\n").Replace("\n\n", "\n").Split('\n')
|
||||
.Select(t => System.Web.HttpUtility.HtmlDecode(t.Trim())).Where(t => t != "").ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
public string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
public string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
|
||||
public string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
internal string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
|
||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
|
||||
// -- Computed props from registration -------------------------------------
|
||||
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
|
||||
@@ -59,11 +52,10 @@ public class FdsReminderData
|
||||
get
|
||||
{
|
||||
if (ReminderRegistration == null) return new List<Dictionary<string, object?>>();
|
||||
// Items are stored as nested JSON under "invoices" key in the registration
|
||||
var raw = ReminderRegistration.getItem("invoices");
|
||||
try
|
||||
{
|
||||
if (raw is Newtonsoft.Json.Linq.JArray ja)
|
||||
if (raw is JArray ja)
|
||||
return ja.ToObject<List<Dictionary<string, object?>>>() ?? new();
|
||||
if (raw is string s && s.StartsWith("["))
|
||||
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object?>>>(s) ?? new();
|
||||
@@ -74,6 +66,10 @@ public class FdsReminderData
|
||||
}
|
||||
|
||||
// -------------------------------- Ctors ----------------------------------
|
||||
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
|
||||
public FdsReminderData() { }
|
||||
|
||||
/// <summary>Parses form data (new/rem) into the data object.</summary>
|
||||
public FdsReminderData(object ctd)
|
||||
{
|
||||
if (ctd is JObject jo) { _base = jo; }
|
||||
@@ -84,153 +80,4 @@ public class FdsReminderData
|
||||
if (_base.ContainsKey("rem")) Rem = new GenericObjectDictionary(_base["rem"]!.ToObject<Dictionary<string, object>>()!);
|
||||
}
|
||||
}
|
||||
|
||||
public FdsReminderData(string id, IntranetController ctrl) => RegisterReminder(id, ctrl);
|
||||
|
||||
// -------------------------------- PDF ------------------------------------
|
||||
public Document ReminderPDF(IntranetController ctrl)
|
||||
{
|
||||
if (_letter != null) return _letter;
|
||||
if (ReminderRegistration == null) RegisterReminder(Id, ctrl);
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = "",
|
||||
Address = InvoiceAddress,
|
||||
AdminUser = UserNameFinalized,
|
||||
AdminUserEmail = UserEmailFinalized,
|
||||
AdminDatumValue = DateCreated ?? DateTime.Now
|
||||
};
|
||||
if (!string.IsNullOrEmpty(RawCustomValues))
|
||||
{
|
||||
var o = new GenericObjectDictionary(RawCustomValues);
|
||||
string oEmail = (string?)o.getItem("contactEmail") ?? "";
|
||||
string oName = (string?)o.getItem("contactName") ?? "";
|
||||
if (!string.IsNullOrEmpty(oEmail) || !string.IsNullOrEmpty(oName))
|
||||
{
|
||||
tb.AdminUser = oName;
|
||||
tb.AdminUserEmail = oEmail;
|
||||
}
|
||||
}
|
||||
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
|
||||
_letter.Info.Title = ReminderTitle;
|
||||
FuchsPdf.ApplyReminder(_letter, tb, this, draft: IsDraft);
|
||||
return _letter;
|
||||
}
|
||||
|
||||
// --------------------------- File operations -----------------------------
|
||||
public async Task<byte[]> GetReminderFile(IntranetController ctrl)
|
||||
{
|
||||
if (ReminderRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
if (!ReminderRegistration.ContainsKey("hasFile") || ReminderRegistration.getItem("hasFile", false) is not true)
|
||||
await StoreReminderDocumentFile(ctrl);
|
||||
byte[]? ba = null;
|
||||
ctrl._mfr.GetFdsDoc(ref ba, Id, "reminder");
|
||||
return ba ?? Array.Empty<byte>();
|
||||
}
|
||||
return await RenderToPdfBytes(ctrl);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreReminderDocumentFile(IntranetController ctrl)
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderToPdfBytes(ctrl); }
|
||||
catch { ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
var pl = ctrl.StdParamlist("Id", Id);
|
||||
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
|
||||
bool r = await setSQLValue_async("EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
|
||||
{
|
||||
var pdfrend = new PdfDocumentRenderer() { Document = ReminderPDF(ctrl) };
|
||||
pdfrend.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
pdfrend.PdfDocument.Save(ms, false);
|
||||
ms.Position = 0;
|
||||
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
|
||||
}
|
||||
|
||||
// ---------------------------- Registration -------------------------------
|
||||
public void RegisterReminder(IntranetController ctrl, bool change, string remId)
|
||||
{
|
||||
if (Rem == null || Rem.Count == 0) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist();
|
||||
pl.Add(SQL_VarChar("InvId", RawInvId));
|
||||
pl.Add(SQL_Char("type", Rem["type"]?.ToString() ?? ""));
|
||||
pl.Add(SQL_Float("amount", stringvalue: NewValues?.nz("amount") ?? ""));
|
||||
pl.Add(SQL_Float("amount_payed", stringvalue: NewValues?.nz("amount_payed") ?? ""));
|
||||
pl.Add(SQL_VarChar("SendToAddress", string.Join("\n", RawInvoiceAddress)));
|
||||
pl.Add(SQL_NVarChar("SendToEmail", RawInvoiceEmail));
|
||||
pl.Add(SQL_NVarChar("subject", NewValues?.getString("subject") ?? "", dbNull_IfEmpty: true));
|
||||
pl.Add(SQL_NVarChar("text", NewValues?.nz("text") ?? "", dbNull_IfEmpty: true));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change || string.IsNullOrEmpty(remId))
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
else
|
||||
pl.Add(SQL_VarChar("RemId", remId));
|
||||
|
||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec,
|
||||
tablenames: new[] { "rem" }, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder - sql exception",
|
||||
data: new { exception = remdset.Exception });
|
||||
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public void RegisterReminder(string id, IntranetController ctrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist("Id", id);
|
||||
pl.Add(SQL_Bit("@includefile", false));
|
||||
var remdset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec,
|
||||
tablenames: new[] { "admin", "rem" }, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id) - sql exception",
|
||||
data: new { exception = remdset.Exception });
|
||||
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
IsDraft = !(ReminderRegistration.getItem("IsFinal") is true);
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id)", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public static FileInfo? GetStoredFile(ref byte[]? file, string reminderId, IntranetController ctrl)
|
||||
{
|
||||
var sqlrw = Task.Run(async () =>
|
||||
(await getSQLDataSet_async(
|
||||
"SELECT TOP(1) * FROM [dbo].[fds__reminder] WHERE [Id] = @Id AND [file] is not null;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(SQL_VarChar("@Id", reminderId)),
|
||||
Security: ctrl.DbSec))
|
||||
.FirstTable().FirstRow.toObjectDictionary()).Result;
|
||||
|
||||
if (sqlrw.Count > 0 && !string.IsNullOrEmpty(sqlrw.nz("DocumentName")) && sqlrw.no("file", null!) is byte[] b)
|
||||
{
|
||||
file = b;
|
||||
return new FileInfo(sqlrw.nz("DocumentName"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
using Cfg = System.Configuration.ConfigurationManager;
|
||||
using Fuchs.intranet;
|
||||
using MimeKit;
|
||||
using Newtonsoft.Json;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Email sending helper for Fuchs intranet. Full port of fuchs_fds_email.vb.
|
||||
/// Uses OCORE.email.Emailcommons.SendEmail_async when settings are available,
|
||||
/// falls back to MailKit direct send otherwise.
|
||||
/// </summary>
|
||||
public static class FuchsFdsEmail
|
||||
{
|
||||
private static OCORE.email.EmailServerSettings? _settings;
|
||||
|
||||
private static OCORE.email.EmailServerSettings? GetEmailSettings()
|
||||
{
|
||||
if (_settings != null) return _settings;
|
||||
string raw = Cfg.AppSettings["FDS_EmailSettings"] ?? "";
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
// The serialised string is JSON containing a "type" field
|
||||
string type = "smtp";
|
||||
try
|
||||
{
|
||||
var dic = JsonConvert.DeserializeObject<Dictionary<string, object>>(raw);
|
||||
if (dic?.TryGetValue("type", out var t) == true)
|
||||
type = t?.ToString() ?? "smtp";
|
||||
}
|
||||
catch { /* keep default type */ }
|
||||
|
||||
_settings = new OCORE.email.EmailServerSettings(type, raw);
|
||||
return _settings;
|
||||
}
|
||||
|
||||
private const string ReplyToName = "Sebastian Fuchs - Bad und Heizung";
|
||||
private const string ReplyToAddress = "info@sanitaerfuchs.de";
|
||||
private const string SignatureIntro =
|
||||
"<p> </p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
|
||||
"Herzliche Gr\u00fc\u00dfe aus D\u00fcsseldorf-Bilk<br/>" +
|
||||
"Ihr Team der Firma Sebastian Fuchs</p>";
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email and logs the result to the Fuchs FDS database.
|
||||
/// Returns true on success.
|
||||
/// </summary>
|
||||
public static async Task<bool> SendEmail(
|
||||
string @ref,
|
||||
string subject,
|
||||
string html,
|
||||
string email,
|
||||
string name,
|
||||
Dictionary<string, byte[]>? files,
|
||||
Fuchs_intranet intranet)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
string guid = "";
|
||||
string config = "";
|
||||
DateTime sent = default;
|
||||
|
||||
if (!IsValidEmail(email))
|
||||
errors.Add("Die Email-Adresse ist nicht g\u00fcltig.");
|
||||
if (string.IsNullOrEmpty(html))
|
||||
errors.Add("Bitte geben Sie eine Nachricht ein.");
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = GetEmailSettings();
|
||||
string body = html + BuildSignature();
|
||||
|
||||
if (settings != null)
|
||||
{
|
||||
// ── OCORE path ─────────────────────────────────────────
|
||||
var msg = new OCORE.email.Email(
|
||||
Mode: OCORE.email.EmailMode.DirectMode,
|
||||
type: settings.type)
|
||||
{
|
||||
EmailSettings = settings,
|
||||
Subject = subject
|
||||
};
|
||||
msg.AddTo(email, name);
|
||||
msg.AddReplyTo(new MailboxAddress(ReplyToName, ReplyToAddress));
|
||||
msg.SetBody(body);
|
||||
if (files != null)
|
||||
foreach (var kv in files)
|
||||
msg.AttachFile(filecontent: kv.Value, kv.Key);
|
||||
|
||||
var result = await msg.SendAsync(
|
||||
(dref, ex) => intranet.debug_log(
|
||||
$"FuchsFdsEmail.SendEmail {dref}", ex));
|
||||
|
||||
guid = msg.MessageId ?? "";
|
||||
config = msg.EmailConfig_serialized;
|
||||
sent = result.Timestamp;
|
||||
errors.AddRange(result.ErrorMessages);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── MailKit fallback (app settings) ────────────────────
|
||||
string host = Cfg.AppSettings["smtp_host"] ?? "";
|
||||
string user = Cfg.AppSettings["smtp_user"] ?? "";
|
||||
string pass = Cfg.AppSettings["smtp_pass"] ?? "";
|
||||
string from = Cfg.AppSettings["smtp_from"] ?? user;
|
||||
string fromName = Cfg.AppSettings["smtp_fromname"] ?? "Sanit\u00e4rFuchs";
|
||||
int port = int.TryParse(Cfg.AppSettings["smtp_port"], out int p) ? p : 587;
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(fromName, from));
|
||||
message.To.Add(new MailboxAddress(name, email));
|
||||
message.ReplyTo.Add(new MailboxAddress(ReplyToName, ReplyToAddress));
|
||||
message.Subject = subject;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = body };
|
||||
if (files != null)
|
||||
foreach (var kv in files)
|
||||
builder.Attachments.Add(kv.Key, kv.Value);
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
using var client = new MailKit.Net.Smtp.SmtpClient();
|
||||
await client.ConnectAsync(host, port,
|
||||
MailKit.Security.SecureSocketOptions.Auto);
|
||||
if (!string.IsNullOrEmpty(user))
|
||||
await client.AuthenticateAsync(user, pass);
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
guid = message.MessageId!;
|
||||
sent = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
|
||||
intranet.debug_log("FuchsFdsEmail.SendEmail inner", ex, data: errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
intranet.debug_log("FuchsFdsEmail.SendEmail",
|
||||
data: new { @ref, email, errors });
|
||||
|
||||
// ── SQL audit log ──────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@Ref", @ref),
|
||||
SQL_VarChar("@guid", guid),
|
||||
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
|
||||
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
|
||||
SQL_Bit("@success", errors.Count == 0),
|
||||
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors))
|
||||
};
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
||||
intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: intranet.GetDbSecurity());
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
intranet.debug_log("FuchsFdsEmail.SendEmail log", logEx);
|
||||
}
|
||||
|
||||
return errors.Count == 0;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
private static string BuildSignature()
|
||||
{
|
||||
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 */ }
|
||||
return "";
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var a = new System.Net.Mail.MailAddress(email);
|
||||
return string.Equals(a.Address, email, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
+162
-21
@@ -473,25 +473,26 @@ public static class FuchsPdf
|
||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||
}
|
||||
|
||||
// Data rows — from InvoiceItems
|
||||
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
|
||||
// For invoices without sets this passes items through unchanged; for sets it
|
||||
// emits set header + members per the chosen mode, blanking price cells where
|
||||
// a line should show no price. Totals come from the registration balance, so
|
||||
// the mode is purely presentational.
|
||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||
int pos = 1;
|
||||
foreach (var itm in inv.InvoiceItems)
|
||||
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
|
||||
{
|
||||
string title = itm.nz("title", "");
|
||||
string desc = itm.nz("desc", "");
|
||||
string qty = itm.nz("qty", "1");
|
||||
ParseDec(itm.no("price_net", 0), out decimal priceNet);
|
||||
ParseDec(itm.no("total_net", 0), out decimal totalNet);
|
||||
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title);
|
||||
if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"<div>{desc}</div>");
|
||||
row.Cells[2].AddParagraph(qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum";
|
||||
titleCell.Style = "TblCell_RTitle";
|
||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||
else titleCell.AddText(line.Title);
|
||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||
@@ -523,16 +524,95 @@ public static class FuchsPdf
|
||||
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
|
||||
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
|
||||
|
||||
// Payment terms note
|
||||
string terms = TranslatePaymentTerm(inv.PaymentTerms);
|
||||
string ibanLine = p13b
|
||||
? "Die Steuerschuldnerschaft geht auf den Leistungsempf\u00e4nger \u00fcber (§ 13b UStG)."
|
||||
: $"Bitte \u00fcberweisen Sie den Rechnungsbetrag innerhalb von {terms} auf unser Konto:\n" +
|
||||
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)";
|
||||
|
||||
// ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
|
||||
var reg = inv.InvoiceRegistration;
|
||||
void Note(string text, string style = "InvoiceNotes")
|
||||
{
|
||||
var p = sec.AddParagraph(); p.Style = "InvoiceNotes";
|
||||
p.AddText(ibanLine);
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
|
||||
}
|
||||
|
||||
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
|
||||
string serviceGross = Currency(reg?.getItem("InvoiceService"));
|
||||
|
||||
if (p13b)
|
||||
{
|
||||
string ustString = ParseDec(reg?.getItem("InvoiceVAT_1"), out decimal ust)
|
||||
? $" mit einem Steuersatz von {ust.ToString("0.##", DeCulture)}%" : "";
|
||||
Note("Gem. §13b Umsatzsteuergesetz unterliegen Sie der Steuerschuldnerschaft des " +
|
||||
$"Leistungsempfängers zur Umsatzsteuer aus dieser Rechnung{ustString}.");
|
||||
}
|
||||
|
||||
if (inv.InvoiceType == "i")
|
||||
{
|
||||
Note("Für bereits erbrachte Arbeiten, Dienstleistungen, Materiallieferungen und getätigte " +
|
||||
"Bestellvorgänge zum oben genannten Bauvorhaben, die sich aus dem mit Ihnen geschlossenen " +
|
||||
"Vertrag ergeben, stellen wir Ihnen vertragsgemäß unsere Akontozahlung in Rechnung. " +
|
||||
"Eine Endabrechnung erhalten Sie als Schlussrechnung nach Abschluss des gesamten Bauvorhabens. " +
|
||||
"Das Ausführungsdatum entnehmen Sie bitte dem Schlusstext dieser Rechnung. Wir danken Ihnen " +
|
||||
"herzlich für das entgegengebrachte Vertrauen und bitten Sie um kurzfristigen Ausgleich der Akontorechnung.");
|
||||
}
|
||||
else if (invService > 0 && !p13b && serviceGross != "?")
|
||||
{
|
||||
Note($"Im Bruttobetrag sind {serviceGross} Lohnkosten enthalten " +
|
||||
$"(netto {Currency(reg?.getItem("InvoiceService_net"))}). " +
|
||||
$"Die darin enthaltene Mehrwertsteuer beträgt {Currency(reg?.getItem("InvoiceService_VAT"))}.");
|
||||
}
|
||||
|
||||
Note("Bitte beachten Sie, nach §14 Abs. 1 Umsatzsteuergesetz ist diese Rechnung ein Zahlungsbeleg " +
|
||||
"oder eine andere beweiskräftige Unterlage für 2 Jahre nach Ablauf des Kalenderjahres der " +
|
||||
"Ausstellung dieser Rechnung aufzubewahren, soweit nicht aufgrund anderer gesetzlicher Regelungen " +
|
||||
"andere ggf. längere Aufbewahrungsfristen gelten.");
|
||||
|
||||
if (inv.InvoiceType != "i" && invService > 0 && !p13b)
|
||||
{
|
||||
decimal refundRate = ParseDec(reg?.getItem("tax_servicerefund"), out decimal rr) ? rr : 0.2m;
|
||||
Note(($"Privathaushalten erstattet das Finanzamt bis zu {Currency(invService * refundRate)} " +
|
||||
"des Arbeitslohns mit der nächsten Steuererklärung.").ToUpper(), "InvoiceNotes_ucb");
|
||||
}
|
||||
|
||||
Note("Unsere Allgemeinen und ihnen bekannten Geschäftsbedingungen gelten für alle unsere Angebote. " +
|
||||
"Wir liefern oder leisten ausschließlich zu diesen Bedingungen. Andere Bedingungen werden nicht " +
|
||||
"Vertragsinhalt, auch wenn wir diesen nicht ausdrücklich widersprochen haben. Ergänzend zu diesen " +
|
||||
"Bedingungen gelten unsere Zusatzbedingungen für allgemeine Dienstverträge, Handwerksleitungen und " +
|
||||
"Wartungsverträge. Spätestens mit der Entgegennahme der entsprechenden Lieferung und/oder Leistung " +
|
||||
"gelten unsere Bedingungen als angenommen. Sie gelten auch für künftige Geschäftsbeziehungen, auch " +
|
||||
"wenn sie nicht nochmals ausdrücklich vereinbart werden. Insbesondere auch was die Datenverarbeitung " +
|
||||
"nach Datenschutz-Grundverordnung (DSGVO) Artikel 5 anbelangt.");
|
||||
Note("Steuernummer: 106/5849/2962");
|
||||
|
||||
string paymentTermPhrase = (reg?.nz("PaymentTermPhrase") ?? "")
|
||||
.ne($"Zahlbar innerhalb von {TranslatePaymentTerm(reg?.nz("PaymentTerm") ?? "")}.");
|
||||
Note("Freistellungsbescheinigung zum Steuerabzug bei Bauleistungen gemäß § 48 Abs. 1 Satz1 des EStG " +
|
||||
"liegt vor. Es gelten unsere derzeit gültigen allgemeinen Liefer- und Zahlungsbedingungen. " +
|
||||
$"{paymentTermPhrase} Danach erfolgt Verzugseintritt ohne Mahnung (§ 286 Absatz II BGB).");
|
||||
Note("Hinweis zu unseren Verrechnungssätzen: In den ausgewiesenen Arbeitswerten sind die Dienstleistungen " +
|
||||
"als Arbeitslohn auf Basis der benötigten Zeit enthalten, inklusive der Fahrtzeit, Rüstzeit, " +
|
||||
"Auftragsvorbereitung und Werkzeugen (ausgenommen Spezialwerkzeuge wie Pressen Stemmhammer, etc.) " +
|
||||
"und die Verfügbarkeit von gängigen Ersatzteilen im Kundendienstfahrzeug. Alle Reparatureinsätze " +
|
||||
"(ggf. nur oder einschließlich Störungsdiagnoseeinsätze) des Kundendienst werden grundsätzlich mit " +
|
||||
"einem Verrechnungssatz nach Aufwand (1 Arbeitswert/Stück Zeiteinheit = 10 Minuten) abgerechnet. " +
|
||||
"Der Verrechnungssatz „Servicepauschale/ Notdienst” ausschließlich ausserhalb unserer Öffnungszeiten " +
|
||||
"am Samstag und Sonntag sowie feiertags.");
|
||||
Note("Weitere Informationen erhalten Sie unter www.sanitaerfuchs.de");
|
||||
Note("\"Ach übrigens, wenn Sie mit uns zufrieden waren, dann sagen Sie es doch bitte den anderen. " +
|
||||
"Und falls Sie mal nicht so zufrieden sind, dann sagen Sie es bitte gleich uns.\" Denn schließlich " +
|
||||
"ist die Zufriedenheit unserer Kunden unser wichtigstes Ziel - und ihre Weiterempfehlung unsere " +
|
||||
"Beste Visitenkarte.");
|
||||
Note("PLANT-MY-TREE für jedes gebaute Badezimmer und für jede gebaute Heizung spenden wir einen Baum. " +
|
||||
"PLANT MY TREE führt als Unternehmen eigene Erstaufforstungsprojekte auf eigenen Flächen in " +
|
||||
"Deutschland durch, die zuvor anderweitig genutzt wurden. Im Vorfeld arbeiten wir dabei eng mit den " +
|
||||
"lokalen Forstbehörden zusammen. Unser Ziel ist die langfristige CO2-Kompensierung und damit der " +
|
||||
"nachhaltige Umwelt- und Klimaschutz. Nach der Vermeidung des CO2-Ausstoß bzw. der Reduzierung ist " +
|
||||
"die Aufforstung nicht nur unserer Meinung nach der beste und nachhaltigste Weg, das Klima und damit " +
|
||||
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
|
||||
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
|
||||
|
||||
// GiroCode payment QR (only on finalized invoices with a positive balance)
|
||||
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
|
||||
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
|
||||
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
|
||||
}
|
||||
|
||||
// ── ApplyReminder ─────────────────────────────────────────────────────────
|
||||
@@ -602,6 +682,10 @@ public static class FuchsPdf
|
||||
$"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
|
||||
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)");
|
||||
|
||||
// GiroCode payment QR (only on finalized reminders with a positive open amount)
|
||||
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
|
||||
AddGirocode(sec, openTotal, $"Rechnung {rem.InvoiceId}");
|
||||
|
||||
// Greeting
|
||||
var greet = sec.AddParagraph(); greet.Style = "BodyText";
|
||||
greet.Format.SpaceBefore = cm(0.8);
|
||||
@@ -711,6 +795,63 @@ public static class FuchsPdf
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
|
||||
/// matching the legacy fuchs_fds_pdf.vb invoice/reminder layout. No-op on failure.
|
||||
/// </summary>
|
||||
private static void AddGirocode(Section sec, decimal amount, string purpose)
|
||||
{
|
||||
byte[]? girocodeImg = null;
|
||||
try
|
||||
{
|
||||
using var girocode = GetPaycode(
|
||||
iban: "DE52301502000002091478", bic: "WELADED1KSD",
|
||||
name: "Sebastian Fuchs Bad und Heizung", amount: amount, purpose: purpose);
|
||||
girocodeImg = ImageToByteArray(girocode);
|
||||
}
|
||||
catch { girocodeImg = null; }
|
||||
if (girocodeImg == null) return;
|
||||
|
||||
var spacer = sec.AddParagraph();
|
||||
spacer.Format.SpaceBefore = cm(2);
|
||||
spacer.AddText("");
|
||||
|
||||
var girotbl = sec.AddTable();
|
||||
girotbl.AddColumn(cm(17 - 3 - 0.6));
|
||||
girotbl.AddColumn(cm(3 + 0.6));
|
||||
girotbl.Borders.Color = Colors.Black;
|
||||
girotbl.Borders.Width = 0.5;
|
||||
girotbl.Borders.Style = BorderStyle.Single;
|
||||
girotbl.Borders.Distance = cm(1);
|
||||
|
||||
var rw = girotbl.AddRow();
|
||||
|
||||
rw.Cells[0].Borders.Right.Visible = false;
|
||||
rw.Cells[0].Format.LeftIndent = cm(0.6);
|
||||
var p = rw.Cells[0].AddParagraph();
|
||||
p.Format.SpaceBefore = cm(0.3);
|
||||
p.Format.SpaceAfter = cm(0.2);
|
||||
p.AddText("Zahlen mit Girocode. Mit dem GiroCode bezahlen Sie Ihre Rechnungen schnell, " +
|
||||
"sicher und vor allem fehlerfrei. Ihre Banking App liest aus dem Code alle " +
|
||||
"relevanten Daten für Ihre Überweisung.");
|
||||
p = rw.Cells[0].AddParagraph();
|
||||
p.AddText("Weitere Infos finden Sie unter ");
|
||||
p.AddHyperlink("http://www.girocode.de", HyperlinkType.Web).AddText("http://www.girocode.de");
|
||||
p.AddText(".");
|
||||
|
||||
rw.Cells[1].Borders.Left.Visible = false;
|
||||
var imgPara = rw.Cells[1].AddParagraph();
|
||||
var img = imgPara.AddImage(MigraDocFilenameFromByteArray(girocodeImg));
|
||||
img.Resolution = 300;
|
||||
img.WrapFormat.Style = WrapStyle.TopBottom;
|
||||
img.RelativeHorizontal = RelativeHorizontal.Column;
|
||||
img.RelativeVertical = RelativeVertical.Line;
|
||||
img.Width = cm(3);
|
||||
img.LockAspectRatio = true;
|
||||
img.Left = ShapePosition.Right;
|
||||
img.Top = ShapePosition.Top;
|
||||
}
|
||||
|
||||
private static string MigraDocFilenameFromByteArray(byte[] image) =>
|
||||
"base64:" + Convert.ToBase64String(image);
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Report processing helpers for the Fuchs intranet.
|
||||
/// Delegates to the SQL-driven report catalog.
|
||||
/// </summary>
|
||||
public static class FuchsReports
|
||||
{
|
||||
public static async Task<IActionResult> ProcessFdsRequest(
|
||||
IntranetController ctrl, string action, string id)
|
||||
{
|
||||
// Specific report actions are dispatched here.
|
||||
// Extend with additional cases from the VB fuchs_reports.vb as needed.
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using System.Data;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.GenericCharts;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
// Ported from OCORE_web/imported/commons/ocms_visualization.vb.
|
||||
// Renders SQL-driven reports (from the fds__ report catalog) as HTML pages,
|
||||
// HTML fragments, or PNG charts. Reuses the ported OCORE chart engine
|
||||
// (OCORE.GenericCharts.Chart) for chart visualizations.
|
||||
|
||||
public enum FdsQueryType { udf = 1, udp = 2, generic = 3, dashboard = 4 }
|
||||
public enum FdsDestination { web, email, content }
|
||||
|
||||
/// <summary>
|
||||
/// HTML page builder for reports. Optionally injects content into an HTML
|
||||
/// template (FDS_Template.html); falls back to a minimal document otherwise.
|
||||
/// </summary>
|
||||
public class FuchsHtmlPage
|
||||
{
|
||||
public string Title { get; set; } = "Reporting";
|
||||
public string Style { get; set; } = "body { font-family: Arial, sans-serif; padding: 3rem; margin: 0; } ";
|
||||
public string Script { get; set; } = "";
|
||||
public int ReloadSeconds { get; set; } = -1;
|
||||
public int QueryDuration { get; set; }
|
||||
public readonly List<string> Links = new();
|
||||
|
||||
private readonly StringBuilder _content = new();
|
||||
private readonly string _templatePath;
|
||||
|
||||
public FuchsHtmlPage(string title, string templatePath, int queryDuration = 0)
|
||||
{
|
||||
Title = string.IsNullOrEmpty(title) ? "Report" : title;
|
||||
_templatePath = templatePath;
|
||||
QueryDuration = queryDuration;
|
||||
}
|
||||
|
||||
public void Add(string html) => _content.Append(html);
|
||||
|
||||
private bool TemplateExists()
|
||||
{
|
||||
try { return !string.IsNullOrEmpty(_templatePath) && File.Exists(_templatePath); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public string ToHtml(FdsDestination destination)
|
||||
{
|
||||
string htmlcode;
|
||||
if (destination == FdsDestination.web && TemplateExists())
|
||||
{
|
||||
try { htmlcode = File.ReadAllText(_templatePath); }
|
||||
catch { htmlcode = BasicShell(); }
|
||||
}
|
||||
else htmlcode = BasicShell();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(htmlcode);
|
||||
var headNode = doc.DocumentNode.SelectSingleNode("//head");
|
||||
var bodyNode = doc.DocumentNode.SelectSingleNode("//body");
|
||||
var mainNode = doc.DocumentNode.SelectSingleNode("//body/main");
|
||||
|
||||
if (headNode != null)
|
||||
{
|
||||
if (destination == FdsDestination.web && ReloadSeconds > 9)
|
||||
{
|
||||
var meta = HtmlNode.CreateNode($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
|
||||
var firstMeta = headNode.SelectSingleNode("//meta");
|
||||
if (firstMeta != null) headNode.InsertAfter(meta, firstMeta);
|
||||
else headNode.ChildNodes.Add(meta);
|
||||
}
|
||||
if (destination == FdsDestination.web)
|
||||
{
|
||||
var titleNode = headNode.SelectSingleNode("//title");
|
||||
if (titleNode != null) titleNode.InnerHtml = WebUtility.HtmlEncode(Title);
|
||||
else headNode.AppendChild(HtmlNode.CreateNode($"<title>{WebUtility.HtmlEncode(Title)}</title>"));
|
||||
foreach (var lnk in Links) headNode.AppendChild(HtmlNode.CreateNode(lnk));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Style))
|
||||
headNode.AppendChild(HtmlNode.CreateNode($"<style type=\"text/css\">{Style}</style>"));
|
||||
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
|
||||
headNode.AppendChild(HtmlNode.CreateNode($"<script type=\"text/javascript\">{Script}</script>"));
|
||||
}
|
||||
|
||||
if (mainNode != null) mainNode.InnerHtml = _content.ToString();
|
||||
else if (bodyNode != null) bodyNode.InnerHtml = _content.ToString();
|
||||
|
||||
return doc.DocumentNode.OuterHtml;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (destination == FdsDestination.web) sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\"><head><meta charset=\"utf-8\" />");
|
||||
if (ReloadSeconds > 9) sb.AppendLine($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
|
||||
if (destination == FdsDestination.web)
|
||||
{
|
||||
sb.AppendLine($"<title>{WebUtility.HtmlEncode(Title)}</title>");
|
||||
foreach (var lnk in Links) sb.AppendLine(lnk);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Style)) sb.AppendLine($"<style type=\"text/css\">{Style}</style>");
|
||||
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
|
||||
sb.AppendLine($"<script type=\"text/javascript\">{Script}</script>");
|
||||
sb.AppendLine("</head><body>");
|
||||
sb.Append(_content);
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BasicShell() =>
|
||||
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\" /><title></title></head><body></body></html>";
|
||||
}
|
||||
|
||||
/// <summary>Simple file-based report cache (tmp/*.cache), TTL 72h.</summary>
|
||||
internal sealed class ManagedCache
|
||||
{
|
||||
private readonly DirectoryInfo? _tmp;
|
||||
private readonly string _file;
|
||||
private const string Ext = "cache";
|
||||
|
||||
public bool IsValid { get; }
|
||||
|
||||
public ManagedCache(string filename)
|
||||
{
|
||||
_file = string.IsNullOrEmpty(filename) ? "" : (filename.EndsWith(Ext) ? filename : filename + "." + Ext);
|
||||
try
|
||||
{
|
||||
var baseDir = ApplicationBase();
|
||||
if (baseDir != null)
|
||||
{
|
||||
_tmp = baseDir.GetDirectories("tmp").FirstOrDefault()
|
||||
?? baseDir.CreateSubdirectory("tmp");
|
||||
IsValid = _tmp.Exists;
|
||||
if (IsValid)
|
||||
foreach (var f in _tmp.GetFiles("*." + Ext))
|
||||
try { if (DateTime.UtcNow.Subtract(f.CreationTimeUtc.Date).TotalHours > 72) f.Delete(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
catch { IsValid = false; }
|
||||
}
|
||||
|
||||
private string FullPath => Path.Combine(_tmp!.FullName, _file);
|
||||
|
||||
public bool Exists => IsValid && (File.Exists(FullPath) || File.Exists(FullPath.Replace(" ", "+")));
|
||||
|
||||
public string Read()
|
||||
{
|
||||
try { return Exists ? File.ReadAllText(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")) : ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public void Write(string content)
|
||||
{
|
||||
try { if (IsValid && !string.IsNullOrEmpty(content)) File.WriteAllText(FullPath, content, Encoding.Unicode); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
try { if (Exists) File.Delete(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static class FuchsVisualization
|
||||
{
|
||||
// ── Query execution ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Runs a report (fds__r_ / fds__xls_) and returns its admin table (ADT) + data tables.</summary>
|
||||
public static async Task<(DataTable? adt, List<DataTable> dt)> GetQuery(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, IDictionary<string, string> prms)
|
||||
{
|
||||
var dtList = new List<DataTable>();
|
||||
DataTable? adt = null;
|
||||
|
||||
bool isReport = query.StartsWith("fds__r_");
|
||||
bool isXls = query.StartsWith("fds__xls_");
|
||||
if (!isReport && !isXls) return (adt, dtList);
|
||||
|
||||
var reportinfo = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
|
||||
connStr,
|
||||
new List<SqlParameter> { SQL_VarChar("@report_name", query), SQL_VarChar("@authuser", userAccountId) },
|
||||
tablenames: new[] { "procedures", "parameter", "categories", "tags" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (!reportinfo.Contains("procedures") || reportinfo.Tables("procedures").Rows.Count == 0)
|
||||
return (adt, dtList);
|
||||
|
||||
var qparams = GetQParams(reportinfo.Tables("parameter"), prms);
|
||||
var procRow = reportinfo.Tables("procedures").Rows[0];
|
||||
string sql = $"EXECUTE [dbo].[{procRow["name"]}] {procRow["parameter"]};";
|
||||
|
||||
var dset = await getSQLDataSet_async(sql, connStr, qparams,
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (isXls)
|
||||
{
|
||||
for (int i = 0; i < dset.Count; i++) dtList.Add(dset.Tables(i));
|
||||
}
|
||||
else if (dset.Count >= 1)
|
||||
{
|
||||
if (dset.Count > 1)
|
||||
{
|
||||
for (int i = 1; i < dset.Count; i++) dtList.Add(dset.Tables(i));
|
||||
adt = dset.Tables(0);
|
||||
}
|
||||
else dtList.Add(dset.Tables(0));
|
||||
}
|
||||
return (adt, dtList);
|
||||
}
|
||||
|
||||
private static List<SqlParameter> GetQParams(DataTable paramTbl, IDictionary<string, string> prms)
|
||||
{
|
||||
var list = new List<SqlParameter>();
|
||||
foreach (DataRow prw in paramTbl.Rows)
|
||||
{
|
||||
string name = prw["name"]?.ToString() ?? "";
|
||||
string type = (prw.Table.Columns.Contains("Type") ? prw["Type"]?.ToString() : "")?.ToLower() ?? "";
|
||||
string val = ParamValue(prms, name);
|
||||
switch (type)
|
||||
{
|
||||
case "int": list.Add(SQL_Int(name, stringvalue: val)); break;
|
||||
case "bigint": list.Add(SQL_BigInt(name, stringvalue: val)); break;
|
||||
default: list.Add(new SqlParameter(name, (object?)(string.IsNullOrEmpty(val) ? null : val) ?? DBNull.Value)); break;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string ParamValue(IDictionary<string, string> prms, string name)
|
||||
{
|
||||
if (prms.TryGetValue(name, out var v) && v != null) return v;
|
||||
string trimmed = name.TrimStart('@');
|
||||
if (prms.TryGetValue(trimmed, out var v2) && v2 != null) return v2;
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Chart settings ──────────────────────────────────────────────────────
|
||||
private static ChartSettingsDic GetChartSettings(DataRow adtRow, DataTable dataTbl)
|
||||
{
|
||||
var cs = new ChartSettingsDic();
|
||||
if (!adtRow.Table.Columns.Contains("settings") ||
|
||||
adtRow["settings"] is DBNull ||
|
||||
string.IsNullOrEmpty(adtRow["settings"]?.ToString()))
|
||||
return cs;
|
||||
|
||||
var imported = JsonConvert.DeserializeObject<Dictionary<string, object?>>(adtRow["settings"]!.ToString()!);
|
||||
if (imported != null) cs.Import(imported, true, true);
|
||||
|
||||
cs["title"] = ColValue(adtRow, "title");
|
||||
cs["label"] = ColValue(adtRow, "label");
|
||||
if (!cs.ContainsKey("x1_column") && dataTbl.Columns.Count == 2) cs["x1_column"] = dataTbl.Columns[0].ColumnName;
|
||||
if (!cs.ContainsKey("y1_column") && dataTbl.Columns.Count == 2) cs["y1_column"] = dataTbl.Columns[1].ColumnName;
|
||||
return cs;
|
||||
}
|
||||
|
||||
private static async Task<string?> ChartDataUriAsync(DataTable data, ChartSettingsDic cs)
|
||||
{
|
||||
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
|
||||
try
|
||||
{
|
||||
var chart = new Chart(data, DateTime.Now);
|
||||
chart.Init(cs, null);
|
||||
string b64 = await chart.ToBase64string(System.Drawing.Imaging.ImageFormat.Png);
|
||||
return "data:image/png;base64," + b64;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── HTML form builder (tabpages with table / chart / html visualizations) ─
|
||||
private static async Task<string> BuildFormHtmlAsync(
|
||||
DataTable? adt, List<DataTable> dt, FdsQueryType qtype, FdsDestination dest,
|
||||
IDictionary<string, string> prms, FuchsHtmlPage page, string query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<div class=\"frm\" id=\"frm\">");
|
||||
|
||||
if (qtype != FdsQueryType.dashboard)
|
||||
{
|
||||
int selectedtab = (prms.TryGetValue("tab", out var tv) && int.TryParse(tv, out var ti)) ? ti : -1;
|
||||
int from = selectedtab > 0 ? selectedtab - 1 : 0;
|
||||
int to = selectedtab > 0 ? selectedtab - 1 : dt.Count - 1;
|
||||
|
||||
for (int dti = from; dti <= to && dti < dt.Count; dti++)
|
||||
{
|
||||
bool hasAdmin = adt != null && adt.Rows.Count > dti;
|
||||
DataRow? aRow = hasAdmin ? adt!.Rows[dti] : null;
|
||||
|
||||
string label = AdtStr(aRow, "Label", query);
|
||||
string css = AdtStr(aRow, "class", "");
|
||||
string style = AdtStr(aRow, "style", "");
|
||||
string vtype = AdtStr(aRow, "typ", "table");
|
||||
|
||||
sb.Append($"<div class=\"tabpage {WebUtility.HtmlEncode(css)}\" id=\"tab_{dti}\" data-title=\"{WebUtility.HtmlEncode(label)}\">");
|
||||
|
||||
if (dt[dti].Rows.Count > 0)
|
||||
{
|
||||
sb.Append("<div class=\"headline\" style=\"margin-bottom: 2rem; line-height: 1.3;\">");
|
||||
sb.Append($"<h1>{WebUtility.HtmlEncode(label)}</h1>");
|
||||
sb.Append($"<h3>{WebUtility.HtmlEncode(AdtStr(aRow, "SubLabel", ""))}</h3>");
|
||||
sb.Append("</div>");
|
||||
|
||||
switch (vtype)
|
||||
{
|
||||
case "html":
|
||||
sb.Append("<div>");
|
||||
if (dt[dti].Rows.Count == 1 && dt[dti].Columns.Count == 1 &&
|
||||
(dt[dti].Rows[0][0]?.ToString() ?? "").StartsWith("<"))
|
||||
sb.Append(dt[dti].Rows[0][0]);
|
||||
sb.Append("</div>");
|
||||
break;
|
||||
|
||||
case "chart":
|
||||
sb.Append("<div class=\"chartframe\">");
|
||||
if (aRow != null)
|
||||
{
|
||||
var cs = GetChartSettings(aRow, dt[dti]);
|
||||
string? src = await ChartDataUriAsync(dt[dti], cs);
|
||||
if (src != null) sb.Append($"<img src=\"{src}\" />");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
break;
|
||||
|
||||
default: // "table"
|
||||
sb.Append(BuildTableHtml(dt[dti]));
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(style)) page.Style += " " + style;
|
||||
string legend = AdtStr(aRow, "legend", "");
|
||||
if (!string.IsNullOrEmpty(legend))
|
||||
sb.Append($"<div class=\"legend\">{legend}</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div style=\"font-size:14px; color: red;\">No Data Found</div>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
}
|
||||
}
|
||||
else if (adt != null && adt.Rows.Count == 1)
|
||||
{
|
||||
// Dashboards: data via JSON (script) + layout via id-based css/js
|
||||
page.Script = JsonConvert.SerializeObject(dt);
|
||||
string id = AdtStr(adt.Rows[0], "id", "");
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
page.Links.Add($"<link rel=\"stylesheet\" href=\"/Content/{id}.css\" type=\"text/css\" />");
|
||||
page.Links.Add($"<script src=\"/Scripts/{id}.js\" type=\"text/javascript\"></script>");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div style=\"font-size:14px; color: red;\">Nothing Found</div>");
|
||||
}
|
||||
|
||||
sb.Append("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildTableHtml(DataTable tbl)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<table style=\"border-collapse: collapse;border: 1px solid #000;\"><thead><tr>");
|
||||
for (int ci = 0; ci < tbl.Columns.Count; ci++)
|
||||
{
|
||||
var c = tbl.Columns[ci];
|
||||
if (c.ColumnName == "order" || c.ColumnName.StartsWith("css") || c.ColumnName.StartsWith("style")) continue;
|
||||
sb.Append($"<th class=\"trh {ToCssClass(c.ColumnName, ci + 1, 0)}\" " +
|
||||
"style=\"white-space:nowrap; padding: 0.5rem 1rem; font-weight: 600;border:1px solid #727272;border-bottom: 3px double #727272;\">" +
|
||||
$"{WebUtility.HtmlEncode(c.ColumnName)}</th>");
|
||||
}
|
||||
sb.Append("</tr></thead><tbody>");
|
||||
|
||||
var sorted = tbl.Select("", tbl.Columns.Contains("order") ? "order" : "");
|
||||
for (int rwi = 0; rwi < sorted.Length; rwi++)
|
||||
{
|
||||
var rw = sorted[rwi];
|
||||
string trStyle = "font-weight: 400";
|
||||
if (tbl.Columns.Contains("css") && rw["css"] is not DBNull && !string.IsNullOrEmpty(rw["css"]?.ToString()))
|
||||
trStyle = rw["css"]!.ToString()!;
|
||||
sb.Append($"<tr class=\"{(rwi == sorted.Length - 1 ? "last" : "")}\" style=\"{WebUtility.HtmlEncode(trStyle)}\">");
|
||||
|
||||
for (int ci = 0; ci < tbl.Columns.Count; ci++)
|
||||
{
|
||||
var c = tbl.Columns[ci];
|
||||
if (c.ColumnName == "order") continue;
|
||||
if (c.ColumnName.StartsWith("css:") || c.ColumnName.StartsWith("style:")) continue;
|
||||
|
||||
string tdStyle = "padding: 0.5rem 1rem; border:1px solid #727272;";
|
||||
string extraCls = "";
|
||||
if (tbl.Columns.Contains("css:" + c.ColumnName) && rw["css:" + c.ColumnName] is not DBNull &&
|
||||
!string.IsNullOrEmpty(rw["css:" + c.ColumnName]?.ToString()))
|
||||
extraCls = " " + rw["css:" + c.ColumnName];
|
||||
if (tbl.Columns.Contains("style:" + c.ColumnName) && rw["style:" + c.ColumnName] is not DBNull &&
|
||||
!string.IsNullOrEmpty(rw["style:" + c.ColumnName]?.ToString()))
|
||||
tdStyle = (tdStyle + ";" + rw["style:" + c.ColumnName]).Replace(";;", ";");
|
||||
|
||||
sb.Append($"<td class=\"{ToCssClass(c.ColumnName, ci + 1, rwi + 1)}{extraCls}\" style=\"{tdStyle}\">");
|
||||
|
||||
if (rw[c] is not DBNull)
|
||||
{
|
||||
string val = rw[c]?.ToString() ?? "";
|
||||
bool largeText = c.DataType == typeof(string) && (c.MaxLength == -1 || c.MaxLength > 100);
|
||||
if (largeText)
|
||||
{
|
||||
sb.Append("<div>");
|
||||
if (val.StartsWith("<") && val.EndsWith(">"))
|
||||
sb.Append(WebUtility.HtmlEncode(val)); // matches legacy SOC .text() (InnerText) behavior
|
||||
else
|
||||
sb.Append(string.Join("<br />",
|
||||
val.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n').Select(WebUtility.HtmlEncode)));
|
||||
sb.Append("</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(WebUtility.HtmlEncode(val));
|
||||
}
|
||||
}
|
||||
sb.Append("</td>");
|
||||
}
|
||||
sb.Append("</tr>");
|
||||
}
|
||||
sb.Append("</tbody></table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToCssClass(string columnName, int c, int r) =>
|
||||
$"c{c} r{r} _{columnName.Replace(" ", "_").ToLower()}";
|
||||
|
||||
private static string AdtStr(DataRow? row, string col, string fallback)
|
||||
{
|
||||
if (row == null || !row.Table.Columns.Contains(col) || row[col] is DBNull) return fallback;
|
||||
string v = row[col]?.ToString() ?? "";
|
||||
return string.IsNullOrEmpty(v) ? fallback : v;
|
||||
}
|
||||
|
||||
private static string ColValue(DataRow row, string col) =>
|
||||
row.Table.Columns.Contains(col) && row[col] is not DBNull ? row[col]?.ToString() ?? "" : "";
|
||||
|
||||
// ── Public render entry points ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a report as a bare HTML fragment (destination = content).</summary>
|
||||
public static async Task<string> RenderContentAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, FdsQueryType qtype, IDictionary<string, string> prms)
|
||||
{
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
var page = new FuchsHtmlPage("", "");
|
||||
return await BuildFormHtmlAsync(adt, dt, qtype, FdsDestination.content, prms, page, query);
|
||||
}
|
||||
|
||||
/// <summary>Renders a report as a full HTML page (destination = web / email), with optional caching.</summary>
|
||||
public static async Task<FuchsHtmlPage> RenderPageAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string uniquename, string query, FdsQueryType qtype,
|
||||
IDictionary<string, string> prms, FdsDestination dest, string templatePath,
|
||||
bool allowcache = false, bool forceReload = false, string title = "")
|
||||
{
|
||||
ManagedCache? cache = null;
|
||||
string cached = "", cachedTitle = "";
|
||||
if (allowcache)
|
||||
{
|
||||
try
|
||||
{
|
||||
var illegal = new Regex($"[{Regex.Escape(new string(Path.GetInvalidFileNameChars()))}]");
|
||||
var keys = prms.Keys.OrderBy(k => k, StringComparer.Ordinal);
|
||||
string paramStr = string.Join("&", keys.Select(k => $"{WebUtility.UrlEncode(k)}={WebUtility.UrlEncode(prms[k])}"));
|
||||
string cacheName = illegal.Replace($"{DateTime.Now:yyyyMMdd}_{uniquename}$${paramStr}.cache", "_");
|
||||
if (cacheName.Length <= 255)
|
||||
{
|
||||
cache = new ManagedCache(cacheName);
|
||||
if (forceReload) cache.Delete();
|
||||
else cached = cache.Read();
|
||||
}
|
||||
}
|
||||
catch { /* caching is best-effort */ }
|
||||
}
|
||||
|
||||
var page = new FuchsHtmlPage(string.IsNullOrEmpty(title) ? "Report" : title, templatePath);
|
||||
string formHtml;
|
||||
|
||||
if (string.IsNullOrEmpty(cached))
|
||||
{
|
||||
var start = DateTime.Now;
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
page.QueryDuration = (int)DateTime.Now.Subtract(start).TotalSeconds;
|
||||
|
||||
// Title from the admin table when available
|
||||
if (adt != null && adt.Columns.Contains("title") && adt.Rows.Count > 0 && adt.Rows[0]["title"] is not DBNull)
|
||||
page.Title = adt.Rows[0]["title"]?.ToString() ?? "FDS Berichte";
|
||||
else
|
||||
page.Title = "FDS Berichte";
|
||||
|
||||
formHtml = await BuildFormHtmlAsync(adt, dt, qtype, dest, prms, page, query);
|
||||
|
||||
if (allowcache && cache is { IsValid: true })
|
||||
try
|
||||
{
|
||||
cache.Write(JsonConvert.SerializeObject(new Dictionary<string, string>
|
||||
{ ["title"] = page.Title, ["frm"] = formHtml }));
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
var dic = JsonConvert.DeserializeObject<Dictionary<string, string>>(cached);
|
||||
cachedTitle = dic != null && dic.TryGetValue("title", out var t) ? t : "";
|
||||
formHtml = dic != null && dic.TryGetValue("frm", out var f) ? f : "";
|
||||
if (!string.IsNullOrEmpty(cachedTitle)) page.Title = cachedTitle;
|
||||
}
|
||||
|
||||
if (dest == FdsDestination.web)
|
||||
{
|
||||
page.Style = ".tabpage.inactive { display: none; } " + page.Style;
|
||||
page.Links.Insert(0, "<script src=\"/Scripts/jquery.min.js\" type=\"text/javascript\"></script>");
|
||||
if (qtype != FdsQueryType.dashboard)
|
||||
{
|
||||
page.Links.Add("<link rel=\"stylesheet\" href=\"/Web/ctm.css\" type=\"text/css\" />");
|
||||
page.Links.Add("<script src=\"/Web/ctm.js\" type=\"text/javascript\"></script>");
|
||||
}
|
||||
}
|
||||
page.Add(formHtml);
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Renders a report query directly as a PNG chart.</summary>
|
||||
public static async Task<byte[]?> RenderQueryAsChartAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, FdsQueryType qtype, IDictionary<string, string> prms)
|
||||
{
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
if (adt == null || dt.Count == 0) return null;
|
||||
|
||||
var cs = GetChartSettings(adt.Rows[0], dt[0]);
|
||||
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
|
||||
try
|
||||
{
|
||||
var chart = new Chart(dt[0], DateTime.Now);
|
||||
chart.Init(cs, null);
|
||||
return await chart.ToByteArray(System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Widget helpers for the Fuchs intranet dashboard.
|
||||
/// Port of fuchs_fds_widgets.vb — SQL-driven widget cases.
|
||||
/// Weather widget (wetter.com API) removed: API deprecated.
|
||||
/// </summary>
|
||||
public static class FuchsWidgets
|
||||
{
|
||||
public static async Task<IActionResult> IntranetWdg(IntranetController ctrl, string widgetId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return widgetId.ToLower() switch
|
||||
{
|
||||
"my" => await HandleWidgetMy(ctrl),
|
||||
"one" => await HandleWidgetOne(ctrl),
|
||||
_ => await HandleWidgetGeneric(ctrl, widgetId)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ctrl._intranet.debug_log("FuchsWidgets.IntranetWdg", ex, ctrl.UserAccountID,
|
||||
new { widgetId });
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── "my" — list of widget short-names for the current user ───────────────
|
||||
private static async Task<IActionResult> HandleWidgetMy(IntranetController ctrl)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(SQL_VarChar("@account", "fis")),
|
||||
Security: ctrl.DbSec);
|
||||
var names = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
|
||||
.Select(r => r.nz("short_name"))
|
||||
.ToArray();
|
||||
return await JSONAsync(names);
|
||||
}
|
||||
|
||||
// ── "one" — full widget data for a single widget ──────────────────────────
|
||||
private static async Task<IActionResult> HandleWidgetOne(IntranetController ctrl)
|
||||
{
|
||||
string shortName = ctrl.Request.Form["short_name"].ToString() ?? "";
|
||||
if (string.IsNullOrEmpty(shortName)) return new BadRequestResult();
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(
|
||||
SQL_VarChar("@shortname", shortName),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
Security: ctrl.DbSec);
|
||||
|
||||
if (dt.Count != 1) return new StatusCodeResult(404);
|
||||
var wdg = dt.FirstRow.toObjectDictionary();
|
||||
return await BuildWidgetResponse(ctrl, shortName, wdg);
|
||||
}
|
||||
|
||||
// ── Generic widget by id ──────────────────────────────────────────────────
|
||||
private static async Task<IActionResult> HandleWidgetGeneric(IntranetController ctrl, string widgetId)
|
||||
{
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "data" },
|
||||
Security: ctrl.DbSec);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
data = dset.Tables("data").toArrayofObjectDictionaries()
|
||||
});
|
||||
}
|
||||
|
||||
// ── Widget renderer dispatcher ────────────────────────────────────────────
|
||||
private static async Task<IActionResult> BuildWidgetResponse(
|
||||
IntranetController ctrl, string shortName, Dictionary<string, object?> wdg)
|
||||
{
|
||||
string dbType = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
var ropts = ParseRenderingOptions(wdg.nz("rendering_options", "") ?? "");
|
||||
string name = wdg.nz("name", "") ?? "";
|
||||
string descr = wdg.nz("description", "") ?? "";
|
||||
|
||||
object widgetData;
|
||||
|
||||
switch (dbType)
|
||||
{
|
||||
case "sql_table":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(), Security: ctrl.DbSec);
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "table",
|
||||
rendering_options = ropts,
|
||||
columns = dt.DataTable.Columns
|
||||
.Cast<System.Data.DataColumn>()
|
||||
.Select(c => c.ColumnName)
|
||||
.ToArray(),
|
||||
data = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary())
|
||||
.ToArray()
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sql_indicator":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(), Security: ctrl.DbSec);
|
||||
var firstRow = dt.DataTable.Rows.Count > 0
|
||||
? dt.DataTable.Rows[0].toObjectDictionary()
|
||||
: new Dictionary<string, object?>();
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "ind",
|
||||
rendering_options = ropts,
|
||||
data = new
|
||||
{
|
||||
status = firstRow.nz("status", "") ?? "",
|
||||
value = firstRow.nz("value", "") ?? "",
|
||||
label = firstRow.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "html",
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? ""
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pass through with normalised rendering_options
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = dbType,
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? "",
|
||||
url = wdg.nz("url", "") ?? "",
|
||||
image = wdg.nz("image", "") ?? "",
|
||||
data = (object)new
|
||||
{
|
||||
status = wdg.nz("status", "") ?? "",
|
||||
value = wdg.nz("value", "") ?? "",
|
||||
label = wdg.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Wrap under short_name key so JS can do response[wi]
|
||||
return await JSONAsync(new Dictionary<string, object> { [shortName] = widgetData });
|
||||
}
|
||||
|
||||
private static string[] ParseRenderingOptions(string raw) =>
|
||||
string.IsNullOrWhiteSpace(raw)
|
||||
? Array.Empty<string>()
|
||||
: raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// How a "set" (mfr__items with <c>Type = "set"</c>) and its member items are
|
||||
/// priced/displayed on the invoice. This is the shared contract between the
|
||||
/// front-end (which lets the user pick the mode and groups the items) and the
|
||||
/// back-end PDF renderer. Unit-tested in <c>InvoiceSetPricingTests</c>.
|
||||
/// </summary>
|
||||
public enum SetDisplayMode
|
||||
{
|
||||
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
|
||||
SetPrice,
|
||||
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
|
||||
ItemPrices,
|
||||
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
|
||||
SetOnly
|
||||
}
|
||||
|
||||
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
|
||||
public sealed class InvoiceSetLine
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string Desc { get; init; } = "";
|
||||
public string Qty { get; init; } = "1";
|
||||
public decimal PriceNet { get; init; }
|
||||
public decimal TotalNet { get; init; }
|
||||
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
|
||||
public bool ShowPrice { get; init; } = true;
|
||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||
public bool IsSetHeader { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms raw invoice line items into display lines according to a
|
||||
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
||||
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
|
||||
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
||||
///
|
||||
/// The invoice total is taken from the registration balance, not from these
|
||||
/// lines, so switching modes is purely presentational and never changes the
|
||||
/// invoice sum — set price always equals the sum of its members.
|
||||
/// </summary>
|
||||
public static class InvoiceSetPricing
|
||||
{
|
||||
public static SetDisplayMode ParseMode(string? raw) =>
|
||||
(raw ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
|
||||
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
||||
_ => SetDisplayMode.SetPrice
|
||||
};
|
||||
|
||||
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
||||
public static SetDisplayMode ModeFromInvoiceOptions(string? invoiceOptions)
|
||||
{
|
||||
foreach (var token in (invoiceOptions ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseMode(token["setmode:".Length..]);
|
||||
}
|
||||
return SetDisplayMode.SetPrice;
|
||||
}
|
||||
|
||||
/// <summary>True if any item in the list is a set header or set member.</summary>
|
||||
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
||||
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||
|
||||
/// <summary>
|
||||
/// Produces the ordered display lines for the given items and mode.
|
||||
/// Standalone items are always shown with their price.
|
||||
/// </summary>
|
||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||
{
|
||||
var result = new List<InvoiceSetLine>();
|
||||
if (items.Count == 0) return result;
|
||||
|
||||
// Group members by their set id.
|
||||
var membersBySet = items
|
||||
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
|
||||
.GroupBy(SetIdOf)
|
||||
.ToDictionary(g => g.Key!, g => g.ToList());
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (IsSetHeader(item))
|
||||
{
|
||||
string setId = HeaderIdOf(item);
|
||||
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
||||
decimal setTot = HeaderTotal(item, members);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case SetDisplayMode.SetPrice:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
||||
break;
|
||||
case SetDisplayMode.ItemPrices:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count)
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
||||
break;
|
||||
case SetDisplayMode.SetOnly:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(SetIdOf(item)))
|
||||
{
|
||||
// Member — already emitted next to its header above; skip standalone emission.
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(MemberLine(item, showPrice: true)); // standalone item
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Text/Title lines are headings/free text with no price — their price/total
|
||||
/// cells render blank (matching the editor and the legacy invoice), regardless
|
||||
/// of the chosen mode.
|
||||
/// </summary>
|
||||
private static bool IsNoPriceLine(Dictionary<string, object?> i)
|
||||
{
|
||||
string t = i.nz("type", "").ToLowerInvariant();
|
||||
return t is "text" or "title";
|
||||
}
|
||||
|
||||
private static string? SetIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
|
||||
private static string HeaderIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
||||
}
|
||||
|
||||
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> members)
|
||||
{
|
||||
FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot);
|
||||
if (headerTot != 0) return headerTot;
|
||||
decimal sum = 0;
|
||||
foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; }
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = setTotal,
|
||||
TotalNet = setTotal,
|
||||
ShowPrice = showPrice,
|
||||
IsSetHeader = true
|
||||
};
|
||||
|
||||
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice)
|
||||
{
|
||||
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
|
||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||
return new InvoiceSetLine
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||
IsSetHeader = false
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -147,7 +147,7 @@ gulp.task("copy", async () => {
|
||||
const jobs = [];
|
||||
for (const cpy of copyconfig) {
|
||||
jobs.push(
|
||||
pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe())
|
||||
pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe(), logSink("Copied: "))
|
||||
);
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
|
||||
@@ -96,6 +96,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -258,7 +261,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -403,7 +406,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -646,12 +649,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -663,7 +673,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -838,6 +848,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -857,12 +914,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -884,7 +961,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
|
||||
@@ -224,7 +224,7 @@ function getMonday(d) {
|
||||
$.fn.rwText = function (text, addtitle, options) {
|
||||
var tgt = $(this).empty();
|
||||
options = $.extend({ wrap: true }, options);
|
||||
var sa = Array.isArray(text) === true ? text : (text || '').split('\n');
|
||||
var sa = Array.isArray(text) === true ? text : (text == null ? '' : String(text)).split('\n');
|
||||
$.each(sa, function (ti, tx) {
|
||||
if ((tx || '') !== '') {
|
||||
if (ti > 0) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "fuchs",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Id=ctm_01krg7sa3rpyvdbwx3mnvv1y4d;Kind=Community;ExpiryDateUtc=2027-08-11T11:27:58.0492360Z;Key=zHKV/xQMPGRV4RU3G7LQNunqLBoQBDsZmHPVN3AzxjuQ02NgCFxHBTkm6HanXsRbcmYCFjJm/PWdY+NOHt85fQ==
|
||||
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -637,6 +643,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -799,7 +808,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -944,7 +953,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1187,12 +1196,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1204,7 +1220,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1379,6 +1395,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1398,12 +1461,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1425,7 +1508,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1317,7 +1317,7 @@ function getMonday(d) {
|
||||
$.fn.rwText = function (text, addtitle, options) {
|
||||
var tgt = $(this).empty();
|
||||
options = $.extend({ wrap: true }, options);
|
||||
var sa = Array.isArray(text) === true ? text : (text || '').split('\n');
|
||||
var sa = Array.isArray(text) === true ? text : (text == null ? '' : String(text)).split('\n');
|
||||
$.each(sa, function (ti, tx) {
|
||||
if ((tx || '') !== '') {
|
||||
if (ti > 0) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -618,6 +624,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -780,7 +789,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -925,7 +934,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,9 +23,9 @@ public class FdsService : ServiceControl
|
||||
new PeriodicJobDefinition("MfrSync", interval, async ct =>
|
||||
{
|
||||
bool debug = FdsConfig.DebugDetails;
|
||||
await mfr.UpdateIfNecessary_async(debug);
|
||||
await mfr.UpdateRequested_async(debug);
|
||||
await mfr.GetInvoiceFiles_async(debug);
|
||||
await mfr.UpdateIfNecessary_async(debug, ct);
|
||||
await mfr.UpdateRequested_async(debug, ct);
|
||||
await mfr.GetInvoiceFiles_async(debug, ct);
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
+35
-20
@@ -43,15 +43,19 @@ public class FdsMfr : IFdsMfr
|
||||
None = 0
|
||||
}
|
||||
|
||||
public async Task UpdateIfNecessary_async(bool debugDetails = false)
|
||||
/// <summary>Max parallel invoice-file downloads (independent per file).</summary>
|
||||
private const int InvoiceFileDownloadConcurrency = 4;
|
||||
|
||||
public async Task UpdateIfNecessary_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
{
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename: "DebugDetail.txt");
|
||||
await mfr.Update__Entitytables(debugDetails);
|
||||
await mfr.Update__Entitytables(debugDetails, cancellationToken: cancellationToken);
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("UpdateIfNecessary_async - main unn", exc: ex);
|
||||
@@ -59,15 +63,16 @@ public class FdsMfr : IFdsMfr
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateRequested_async(bool debugDetails = false)
|
||||
public async Task UpdateRequested_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
{
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - start awaited", filename: "DebugDetail.txt");
|
||||
await mfr.Update__EntityRequests(debugDetails);
|
||||
await mfr.Update__EntityRequests(debugDetails, cancellationToken);
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("UpdateRequested_async - main unn", exc: ex);
|
||||
@@ -75,7 +80,7 @@ public class FdsMfr : IFdsMfr
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GetInvoiceFiles_async(bool debugDetails = false)
|
||||
public async Task GetInvoiceFiles_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
@@ -86,35 +91,45 @@ public class FdsMfr : IFdsMfr
|
||||
FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions());
|
||||
if (dtbl.Count > 0)
|
||||
{
|
||||
foreach (DataRow ivrw in dtbl.DataTable.Rows)
|
||||
{
|
||||
string id = ivrw.nz("id"), docName = ivrw.nz("DocumentName"), fileurl = ivrw.nz("URI");
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(docName) && !string.IsNullOrEmpty(fileurl) && docName.EndsWith("pdf"))
|
||||
var rows = dtbl.DataTable.Rows.Cast<DataRow>()
|
||||
.Select(r => (id: r.nz("id"), docName: r.nz("DocumentName"), url: r.nz("URI")))
|
||||
.Where(r => !string.IsNullOrEmpty(r.id) && !string.IsNullOrEmpty(r.docName)
|
||||
&& !string.IsNullOrEmpty(r.url) && r.docName.EndsWith("pdf"))
|
||||
.ToList();
|
||||
|
||||
int downloaded = 0;
|
||||
// Files are independent → download (and store) in parallel with bounded concurrency.
|
||||
await Parallel.ForEachAsync(rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = InvoiceFileDownloadConcurrency, CancellationToken = cancellationToken },
|
||||
async (r, ct) =>
|
||||
{
|
||||
var fl = mfr.GetFile(fileurl);
|
||||
if (fl != null && fl.Length > 0)
|
||||
try
|
||||
{
|
||||
try
|
||||
var fl = await mfr.GetFileAsync(r.url, throwErrorIfNotOk: false, cancellationToken: ct);
|
||||
if (fl is { Length: > 0 })
|
||||
{
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;",
|
||||
FdsShared.FDSConnectionString(),
|
||||
SqlParameterList: new ParamList(
|
||||
SQL_VarChar("@Id", id),
|
||||
SQL_VarChar("@filename", docName),
|
||||
SQL_VarChar("@Id", r.id),
|
||||
SQL_VarChar("@filename", r.docName),
|
||||
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
|
||||
options: new FdsSqlOptions());
|
||||
}
|
||||
catch (Exception fsex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
|
||||
Interlocked.Increment(ref downloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception fsex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
|
||||
}
|
||||
});
|
||||
_logger.LogInformation("GetInvoiceFiles_async stored {Downloaded}/{Total} invoice files.", downloaded, rows.Count);
|
||||
}
|
||||
if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - main unn", exc: ex);
|
||||
|
||||
@@ -42,6 +42,9 @@ public class FdsMfrClient : IDisposable
|
||||
public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) =>
|
||||
_mfrClient.GetFile(address, throwErrorIfNotOk);
|
||||
|
||||
public Task<byte[]?> GetFileAsync(string address, bool throwErrorIfNotOk = true, CancellationToken cancellationToken = default) =>
|
||||
_mfrClient.GetFileAsync(address, throwErrorIfNotOk, cancellationToken);
|
||||
|
||||
public async Task<ODataEnvelope> ReadOData(string address, bool throwErrorIfNotOk = true) =>
|
||||
await _mfrClient.ReadOData(address, throwErrorIfNotOk);
|
||||
|
||||
@@ -374,7 +377,8 @@ public class FdsMfrClient : IDisposable
|
||||
private static string NewDatatableSql(string tablename) =>
|
||||
$"Select TOP(0) [setid] = CAST('' as varchar(50)), * FROM [dbo].[{tablename}];";
|
||||
|
||||
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null)
|
||||
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
|
||||
{
|
||||
@@ -398,6 +402,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
foreach (DataRow rw in updateableTables.Select("updateneed > 0", "updateneed DESC"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string etname = rw.nz("entity_name", "");
|
||||
try
|
||||
{
|
||||
@@ -424,7 +429,7 @@ public class FdsMfrClient : IDisposable
|
||||
catch (Exception exa) { dlg("outer frame", "", "", exa); }
|
||||
}
|
||||
|
||||
public async Task Update__EntityRequests(bool debugDetails = false)
|
||||
public async Task Update__EntityRequests(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
|
||||
{
|
||||
@@ -448,6 +453,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
foreach (DataRow rw in updateableRequests.Select("", "order"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string etname = rw.nz("entity_name", "");
|
||||
long tgtid = rw.nint64("Id", -1);
|
||||
if (tgtid > -1 && !string.IsNullOrWhiteSpace(etname))
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class FdsConfig
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static void Initialize(IConfiguration configuration)
|
||||
{
|
||||
_config = configuration;
|
||||
}
|
||||
|
||||
// -- Connection strings ---------------------------------------------------
|
||||
internal static string SQLConnectionString() =>
|
||||
Current.GetConnectionString("fuchs_ConnectionString")
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE_web\OCORE_web.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE\OCORE\OCORE.csproj" />
|
||||
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
|
||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="7z.dll">
|
||||
@@ -37,9 +37,9 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<PackageReference Include="Topshelf" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.6" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<name>Fuchs_DataService</name>
|
||||
</assembly>
|
||||
<members>
|
||||
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
|
||||
<summary>Max parallel invoice-file downloads (independent per file).</summary>
|
||||
</member>
|
||||
<member name="T:fds.FdsConfig">
|
||||
<summary>
|
||||
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> built from appsettings.json.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user