Compare commits

...

19 Commits

Author SHA1 Message Date
Stefan 8ecf97ed29 -
Playwright Tests / test (push) Has been cancelled
2026-06-12 18:21:51 +02:00
Stefan ae5c90b915 Refactor FdsConfig init; update copy task and assets
- FdsConfig.Initialize now accepts IConfiguration for DI support; Program.cs updated to pass builder.Configuration.
- Gulp "copy" task logs copied files for better feedback.
- Bank statement file input now accepts all file types.
- Updated glyphicon font binary assets.
2026-06-09 11:31:31 +02:00
Stefan 7653b2f0b5 Add XML doc for InvoiceFileDownloadConcurrency field
Playwright Tests / test (push) Has been cancelled
Added summary documentation for FdsMfr.InvoiceFileDownloadConcurrency, clarifying its role as the max parallel invoice-file downloads.
2026-06-06 17:42:00 +02:00
Stefan bfc695ed6a Invoice set pricing: wire editor + align editor/backend contract
Front-end (fis.inv_shared.js / fis.inv_txt_de.js, rebuilt bundles):
- 3-way set display switch (setprice/itemprices/setonly) via admin.setmode,
  emitted into InvoiceOptions by FdsInvoiceData.BuildInvoiceOptions.
- Each request block now posts items[] in the backend contract shape
  (title/desc/qty/price_net/total_net + set type/setId tags) via itemToContract.
- invcPayload normalises the editor model to the field names BuildInvoiceParams
  reads (sms totals -> new.total_net/total_gross, invoicetitle->title,
  loc->provisionlocation, admin.paymentterms->new.paymentterm, CustomerId->customerid).

Back-end:
- BuildInvoiceOptions adds the setmode token alongside §13b.
- VAT rate+amount now taken from sms.vat (HighestVat) instead of the broken
  items 'is List<object>' detection that pinned the rate to 19.
- InvoiceSetPricing blanks price/total cells for text/title heading lines.

Tests: set-pricing text-line blanking, HighestVat selection/parsing, updated
the VAT param test to the sms.vat contract. 180 passing.

Note: the second VAT slot (InvoiceVAT_2) stays unused by design; mixed-rate
invoices store only the highest rate (pre-existing, accepted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:48:14 +02:00
Stefan ebdb92713a Docs: evaluation of backend-cached invoice editing over SignalR
Assesses the proposed server-held edit state + SignalR live-edit channel against
the current stateless design. Recommendation: don't do the full rewrite now (it
adds stateful-server/scaling/reconnect complexity for a single-editor back-office
flow); instead add a stateless inv/calc endpoint that reuses the backend pricing
(InvoiceSetPricing/VAT) so the editor stops duplicating the math — capturing the
real value without sockets. Add SignalR later only as transport if real-time
co-editing becomes a genuine requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:43:54 +02:00
Stefan c358fdbdb2 Invoice set pricing: 3 display modes (backend contract + PDF + tests)
Sets (mfr__items Type='set') can be shown three ways on the invoice:
- SetPrice (default): set line priced, member items shown without price
- ItemPrices: member items priced, set line as a heading without price
- SetOnly: only the set line (priced), members removed

- InvoiceSetPricing (new): the authoritative, unit-tested transformation
  (SetDisplayMode + Build) that both sides agree on; set price always equals the
  sum of members. Mode is read from InvoiceOptions ("setmode:<mode>").
- FuchsPdf.ApplyInvoice renders through it: lines flagged ShowPrice=false print
  blank price/total cells; set headers are emphasised. Invoices without sets are
  unchanged. Totals come from the registration balance, so modes are purely
  presentational and never change the sum.
- InvoiceSetPricingTests (+14): all three modes, set-price = member sum, header
  total fallback, no-set pass-through, option parsing.
- Docs/INVOICE_SET_PRICING.md documents the front-end contract (the editor sets
  the mode token + tags set header/member items); the back-end does the rest.

Front-end editor wiring is specified in the doc but intentionally not shipped
blind (cannot validate the running editor here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:42:44 +02:00
Stefan 2c17171e77 Optimize Fuchs_DataService: parallel file sync, shared HttpClient, cancellation
Reviewed the data service against mfr_interface_description.md. The OData entity
sync already follows @odata.nextLink and now inherits the MFR client's transient
retry + timeout, so it is spec-aligned. Reliability/performance improvements:

- MFRClient.GetFile no longer news up an HttpClient per call (socket-exhaustion
  risk); added GetFileAsync backed by one shared static HttpClient with
  per-request auth, and GetFile delegates to it.
- GetInvoiceFiles_async now downloads + stores invoice PDFs in parallel
  (bounded concurrency 4) via Parallel.ForEachAsync instead of sequentially.
- Threaded CancellationToken from the MfrSync job through UpdateIfNecessary_async/
  UpdateRequested_async/GetInvoiceFiles_async and the entity-sync loops for
  graceful shutdown (cooperative checks between iterations). Entity-table sync
  is left sequential on purpose (referential ordering by updateneed).

IFdsMfr sync methods gained optional CancellationToken params (default) — the
web app only uses the read methods, so this stays source-compatible with Fuchs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:27:55 +02:00
Stefan 2a75664625 Docs: reference mfr_interface_description.md + Fuchs_Database
- ARCHITECTURE.md: add Fuchs_Database (SSDT) and the MFR interface doc to the
  project table; new MFR ERP Integration and Database sections.
- copilot-instructions.md + CLAUDE.md (kept in sync): add MFR ERP integration
  and Database sections pointing to MFR_RESTClient/Docs/mfr_interface_description.md
  as the contract to read before changing the client; CLAUDE.md doc map updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:05:49 +02:00
Stefan 27becf7c68 MFR client: align with interface doc; remove VB-era files
Per MFR_RESTClient/Docs/mfr_interface_description.md (added):
- MFRClientConfig: configurable TimeoutMs (default 30000), UserAgent, MaxRetries,
  and a derived RestRoot (/mfr) alongside the OData BaseUrl.
- MFRClient: apply request Timeout + UserAgent, send Accept: application/json,
  and retry idempotent GETs on transient failures (HTTP 429/5xx and
  network/timeout) with exponential backoff + jitter, honouring Retry-After.
- Added ReadODataAllPages to follow @odata.nextLink pagination.

Cleanup: removed legacy VB project files (MFR_RESTClient.vbproj, .vbproj.user,
app.config My.MySettings) and stopped tracking the generated MFR_RESTClient.xml
(now git-ignored). The active project is MFR_RESTClient.csproj.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:04:19 +02:00
Stefan a00ec1da3b Fix backend↔database mismatches found verifying against Fuchs_Database
Verified every [dbo].[...] object the backend calls against the SSDT project.
Two real mismatches fixed (both would fail at runtime):

- Banking search (bam/btl mode=s) called a non-existent
  [dbo].[fds__getBankingtransactions_list2] and dropped @tgtdate. The actual
  proc (and the legacy call) is [dbo].[fds__getBankingtransfers_list2]
  (@tgtdate,@mode,@search,@authuser) — corrected name + parameters.
- Widget generic branch called a phantom [dbo].[fds__getWidget] that never
  existed (legacy only had my/one; the dashboard only requests wdg/my, wdg/one).
  The default branch now returns 404 instead of hitting a missing proc.

(The 'fuchs__admin_logdebug' reference is only in a commented-out line.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:03:51 +02:00
Stefan 10ecdfa2e4 Add Fuchs_Database SSDT project (schema source of truth)
Adds the SQL Server Data Tools project for the fuchs_fds database — tables,
table types, functions and stored procedures that the backend calls (e.g.
fds__getInvoice, fds__merge_bankingtransactions, fds__tt__bankingtransactions,
fds__admin_getReportCatalog, fis_* auth). Build/model caches (bin, obj,
*.dbmdl, *.jfm, *.user) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:50:54 +02:00
Stefan 1376779224 Docs: update ARCHITECTURE + copilot instructions, add CLAUDE.md + USER_GUIDE
- ARCHITECTURE.md: reflect the implemented DI service layer, CAMTParser,
  OpenTelemetry/observability, the ported report engine, and CAMT+MT940
  banking; mark the resolved observations.
- copilot-instructions.md: add Services/DI, dual-format banking, observability
  and testing sections; add an Instruction-Sync banner.
- CLAUDE.md (new): Claude Code project instructions mirroring the shared rules,
  plus build/test workflow notes. Both files state they must stay in sync.
- USER_GUIDE.md (new, Fuchs/Docs): end-user process guide (login, invoices,
  reminders, requests, banking incl. MT940/CAMT upload, DATEV, reports).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:45:39 +02:00
Stefan 7ee4e5302a Add CAMTParser (ISO 20022) alongside MT940; accept both formats
- New CAMTParser project: namespace-agnostic parser for camt.052/053/054
  producing a statement/entry model aligned with the banking columns
  (account, amount, debit/credit, dates, counterparty, references, remittance).
- BankingService now auto-detects the upload format (XML→CAMT, else MT940)
  and maps either into the same fds__tt__bankingtransactions DataTable, so the
  bam/up handler transparently accepts both.
- Frontend (fis.bam.de.js) upload field now advertises accept for both
  MT940 (.sta/.mt940/.txt) and CAMT (.xml/.camt).
- Tests (+14, 151 total): CamtParserTests cover parsing (credit/debit,
  namespace-version agnostic, reversals), detection, and failure/edge inputs
  (empty, invalid XML, non-CAMT); BankingDualFormatTests verify CAMT and MT940
  both land in the same DataTable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:39:54 +02:00
Stefan e04d590c3a Add OpenTelemetry, performance metrics, and broaden logging + tests
Observability:
- New FuchsTelemetry (ActivitySource + Meter) defining business counters
  (invoices/reminders/reports rendered, emails/sms sent+failed, MT940 rows,
  MFR calls) and duration histograms (PDF render, report render, email send).
- Program.cs wires OpenTelemetry tracing (ASP.NET Core, HttpClient, SqlClient,
  app source) and metrics (ASP.NET Core, HttpClient, runtime, app meter).
  OTLP export is enabled only when Fuchs:Telemetry:OtlpEndpoint is set, so a
  missing collector never affects the app; disable via Fuchs:Telemetry:Enabled.

Instrumentation + logging:
- Services (Pdf, Invoice, Reminder, Report, Com, Banking, Widget, MfrFactory)
  now emit spans, record metrics, and log entry/result/timing/errors.
- Added dispatch + key-action logging to the previously silent handlers
  (Banking, Reminder, Reports, Requests).

Tests (137 total, +10):
- ProcessWebComServiceTests with a stub HttpMessageHandler cover success
  (API 200), failure (API 500, invalid email, empty mobile), disabled mode,
  the base64 attachment payload contract, and metric emission via MeterListener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:02:13 +02:00
Stefan 8dee630abb Complete DI migration: wire all business services end-to-end
Move the intranet off the static-helper / Active-Record pattern onto
constructor-injected services, removing controller coupling and the
sync-over-async (Task.Run().Wait()) hot spots in the data classes.

Services now registered and consumed via DI:
- IBankingService, IPdfService, IMfrClientFactory (singletons)
- IWidgetService, IReportService, IInvoiceService, IReminderService (scoped)

Key changes:
- FuchsWidgetService: real widget logic (sql_table/indicator/html +
  rendering_options) ported from the static class, which is deleted.
- FuchsReportService + FuchsVisualization: report engine decoupled from
  IntranetController (takes connStr/dbSec/userAccountId); static
  FuchsReports deleted.
- InvoiceService / ReminderService: implement load/register/render/store
  (previously NotImplementedException stubs). FdsInvoiceData /
  FdsReminderData are now pure data holders — all DB + PDF work moved into
  the services, async throughout (no Task.Run().Wait()).
- Controllers inject and call the services; all `new FdsMfrClient()` calls
  go through IMfrClientFactory.
- Deleted dead code: static Banking, FuchsWidgets, FuchsReports, and the
  unused IDbConnectionFactory.
- InternalsVisibleTo("Fuchs.Tests") for testing internal mapping logic.

Tests: 127 passing (Banking tests moved to the service; added data-holder
tests for FdsInvoiceData/FdsReminderData). Full solution builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:57:59 +02:00
Stefan c81619fa53 Restore legacy parity gaps lost in the VB.NET → C# migration
Close functional regressions found by comparing the legacy applications
(Intranet_Legacy/) against their C# counterparts:

- ProcessWebComService: send attachments inline (base64) with the
  push_com POST so invoice/reminder PDFs are attached again.
- FuchsPdf: wire GetPaycode into ApplyInvoice/ApplyReminder to restore
  the SEPA giro-code payment QR, and restore the full standard invoice
  text block (§35a labor-cost note, Akonto text, §14/§48 notes, AGB,
  Steuernummer, Verrechnungssätze, etc.).
- IntranetController: restore changepassword validation (password
  strength, confirmation match, current-password verification) and the
  mfr empty-id OData $metadata response.
- Reports: port the ocms_visualization engine to C# (FuchsVisualization)
  and wire FuchsReports.ProcessFdsRequest to render generic/
  generic_content/chart reports instead of returning an empty OK stub.

Adds smoke tests for the giro QR generator and report page builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:41:46 +02:00
Stefan c8a4d18f1a added legacy code for reference
Playwright Tests / test (push) Has been cancelled
2026-06-04 14:56:43 +02:00
Stefan dbe6cd8653 Add structured logging to IntranetController actions
Extensive structured logging was added throughout IntranetController and all invoice/account handlers to improve traceability and debugging. Logging now covers action entry/exit, error conditions, and key parameters (user IDs, invoice IDs, etc.). Handlers log warnings for missing/invalid input and info/debug for significant events. Minor refactoring extracts form values for better logging. The jQuery `rwText` plugin was hardened against null input. Updated minified JS, font assets, and OCORE submodule. No functional changes to `tools.js`.
2026-06-04 14:21:14 +02:00
Stefan 8f8d462045 Update IdentityModel and test dependencies to latest
Bump Microsoft.IdentityModel.* packages to 8.19.1 in MFR_RESTClient and OCORE. Update Microsoft.NET.Test.Sdk and coverlet.collector in Fuchs.Tests. Refresh OCORE submodule reference. No functional changes; dependency updates only.
2026-06-03 20:37:43 +02:00
529 changed files with 46499 additions and 1374 deletions
+36
View File
@@ -1,5 +1,13 @@
# Copilot Instructions # 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 ## Project Overview
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`. - **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}``IntranetController.Index`; `/do/{fn?}/{id?}/{code?}``IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}``IntranetController.Index`; `/do/{fn?}/{id?}/{code?}``IntranetController.Do`.
@@ -31,6 +39,34 @@
- Do not use OCMS or OCMS_sharp; use only OCORE or OCORE_web. - 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. - 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 ## Azure Key Vault — Secret Naming
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces). - Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`. - Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
+7
View File
@@ -7,6 +7,13 @@
bin/ bin/
obj/ obj/
# SSDT / SQL database project caches (regenerated)
*.dbmdl
*.jfm
# Generated XML documentation output (regenerated on build)
MFR_RESTClient/MFR_RESTClient.xml
# NuGet # NuGet
packages/ packages/
*.nupkg *.nupkg
+12
View File
@@ -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>
+81
View File
@@ -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",
_ => ""
};
}
+237
View File
@@ -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;
}
+69
View File
@@ -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.
+131
View File
@@ -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>
+186
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+134
View File
@@ -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
+146
View File
@@ -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
+226
View File
@@ -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
+500
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Fuchs_Dataservice.exe install --autostart
+9
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
Fuchs_Dataservice.exe uninstall
+19 -14
View File
@@ -1,17 +1,20 @@
using System.Data; using System.Data;
using System.IO; using System.IO;
using System.Text; using System.Text;
using Fuchs.intranet; using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using programmersdigest.MT940Parser; using programmersdigest.MT940Parser;
using Xunit; using Xunit;
namespace Fuchs.Tests; namespace Fuchs.Tests;
/// <summary> /// <summary>
/// Banking helper robustness tests. /// Banking service robustness tests.
/// </summary> /// </summary>
public class BankingDebitCreditMarkTests public class BankingDebitCreditMarkTests
{ {
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
[Theory] [Theory]
[InlineData(DebitCreditMark.Credit, "C")] [InlineData(DebitCreditMark.Credit, "C")]
[InlineData(DebitCreditMark.Debit, "D")] [InlineData(DebitCreditMark.Debit, "D")]
@@ -19,18 +22,20 @@ public class BankingDebitCreditMarkTests
[InlineData(DebitCreditMark.ReverseDebit, "RD")] [InlineData(DebitCreditMark.ReverseDebit, "RD")]
public void DebitCreditMarkAbb_ReturnsExpected(DebitCreditMark mark, string expected) public void DebitCreditMarkAbb_ReturnsExpected(DebitCreditMark mark, string expected)
{ {
Assert.Equal(expected, Banking.DebitCreditMarkAbb(mark)); Assert.Equal(expected, Svc.DebitCreditMarkAbb(mark));
} }
[Fact] [Fact]
public void DebitCreditMarkAbb_UndefinedValue_ReturnsEmpty() public void DebitCreditMarkAbb_UndefinedValue_ReturnsEmpty()
{ {
Assert.Equal("", Banking.DebitCreditMarkAbb((DebitCreditMark)999)); Assert.Equal("", Svc.DebitCreditMarkAbb((DebitCreditMark)999));
} }
} }
public class BankingParseToDatatableTests public class BankingParseToDatatableTests
{ {
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
private static readonly string MinimalMT940 = private static readonly string MinimalMT940 =
"\r\n:20:STARTUMSE\r\n" + "\r\n:20:STARTUMSE\r\n" +
":25:DE12345678901234567890\r\n" + ":25:DE12345678901234567890\r\n" +
@@ -48,7 +53,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_ReturnsOneRow() public void ParseToDatatable_ValidMT940_ReturnsOneRow()
{ {
using var stream = ToStream(MinimalMT940); using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.Equal(1, table.Rows.Count); Assert.Equal(1, table.Rows.Count);
} }
@@ -56,7 +61,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasAccountColumn() public void ParseToDatatable_ValidMT940_HasAccountColumn()
{ {
using var stream = ToStream(MinimalMT940); using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.True(table.Columns.Contains("AccountIdentification")); Assert.True(table.Columns.Contains("AccountIdentification"));
Assert.Equal("DE12345678901234567890", table.Rows[0]["AccountIdentification"]); Assert.Equal("DE12345678901234567890", table.Rows[0]["AccountIdentification"]);
} }
@@ -65,7 +70,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasAmountColumn() public void ParseToDatatable_ValidMT940_HasAmountColumn()
{ {
using var stream = ToStream(MinimalMT940); using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.True(table.Columns.Contains("Amount")); Assert.True(table.Columns.Contains("Amount"));
Assert.Equal(500m, table.Rows[0]["Amount"]); Assert.Equal(500m, table.Rows[0]["Amount"]);
} }
@@ -74,7 +79,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasDebitCreditMark() public void ParseToDatatable_ValidMT940_HasDebitCreditMark()
{ {
using var stream = ToStream(MinimalMT940); using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.Equal("C", table.Rows[0]["DebitCreditMark"]); Assert.Equal("C", table.Rows[0]["DebitCreditMark"]);
} }
@@ -82,7 +87,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_EmptyStream_ReturnsEmptyTable() public void ParseToDatatable_EmptyStream_ReturnsEmptyTable()
{ {
using var stream = ToStream(""); using var stream = ToStream("");
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.Equal(0, table.Rows.Count); Assert.Equal(0, table.Rows.Count);
} }
@@ -90,7 +95,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_EmptyStream_HasDefaultSchema() public void ParseToDatatable_EmptyStream_HasDefaultSchema()
{ {
using var stream = ToStream(""); 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("AccountIdentification"));
Assert.True(table.Columns.Contains("Amount")); Assert.True(table.Columns.Contains("Amount"));
Assert.True(table.Columns.Contains("DebitCreditMark")); Assert.True(table.Columns.Contains("DebitCreditMark"));
@@ -104,7 +109,7 @@ public class BankingParseToDatatableTests
schema.Columns.Add("Amount", typeof(decimal)); schema.Columns.Add("Amount", typeof(decimal));
using var stream = ToStream(MinimalMT940); 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); Assert.Equal(2, table.Columns.Count);
} }
@@ -113,7 +118,7 @@ public class BankingParseToDatatableTests
{ {
var multi = MinimalMT940 + "\n" + MinimalMT940; var multi = MinimalMT940 + "\n" + MinimalMT940;
using var stream = ToStream(multi); using var stream = ToStream(multi);
var table = Banking.ParseToDatatable(stream); var table = Svc.ParseToDatatable(stream);
Assert.Equal(2, table.Rows.Count); Assert.Equal(2, table.Rows.Count);
} }
@@ -121,7 +126,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_MalformedContent_DoesNotThrow() public void ParseToDatatable_MalformedContent_DoesNotThrow()
{ {
using var stream = ToStream("This is not MT940 data at all"); 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); Assert.Null(ex);
} }
} }
+223
View File
@@ -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);
}
}
+83
View File
@@ -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"));
}
}
+3 -2
View File
@@ -9,14 +9,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="coverlet.collector" Version="10.0.0"> <PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@@ -27,6 +27,7 @@
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" /> <ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" /> <ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" /> <ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+52
View File
@@ -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);
}
}
+111
View File
@@ -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" })));
}
}
+191
View File
@@ -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);
}
}
+194
View File
@@ -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) 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()) switch (id.ToLower())
{ {
case "auth": case "auth":
return await JSONAsync(new { manage = 1 }); return await JSONAsync(new { manage = 1 });
case "up": case "up":
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
Request.Form.Files.Count, UserAccountID);
foreach (var fle in Request.Form.Files) foreach (var fle in Request.Form.Files)
{ {
using var stream = fle.OpenReadStream(); using var stream = fle.OpenReadStream();
@@ -29,7 +32,7 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
Security: DbSec, options: SqlOpt(fn, id, code))).DataTable; 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 tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString) var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
@@ -50,6 +53,8 @@ public partial class IntranetController
dtwa.OnCommandAfterError += (_, exc) => dtwa.OnCommandAfterError += (_, exc) =>
_intranet.debug_log("IntranetController.bam.up - command-after exception", _intranet.debug_log("IntranetController.bam.up - command-after exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
tbl.Rows.Count, UserAccountID);
dtwa.DoSubmit(); dtwa.DoSubmit();
} }
return Ok(); return Ok();
@@ -76,11 +81,13 @@ public partial class IntranetController
string mode = Form("mode").ToLower(); string mode = Form("mode").ToLower();
if (mode == "s" && Form("tgt").Contains(':')) if (mode == "s" && Form("tgt").Contains(':'))
{ {
// Search mode: @tgtdate is unused by the proc but required by its signature.
var pl = StdParamlist( var pl = StdParamlist(
SQL_VarChar("@mode", Form("mode")), SQL_Date("@tgtdate", DBNull.Value),
SQL_VarChar("@mode", Form("mode").ne("m")),
SQL_VarChar("@search", Form("tgt"))); SQL_VarChar("@search", Form("tgt")));
var dset = await getSQLDataSet_async( 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, _intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "bank" }, tablenames: new[] { "admin", "bank" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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) 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()) switch (id.ToLower())
{ {
case "auth": case "auth":
_logger.LogDebug("Invoice auth check for user {User}", UserAccountID);
return await JSONAsync(new { manage = 1 }); return await JSONAsync(new { manage = 1 });
case "setpyd": case "setpyd":
if (!HasForm("id")) return BadRequest400(); if (!HasForm("id")) { _logger.LogWarning("setpyd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
return await setSQLValue_async( {
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;", "EXECUTE [dbo].[fds__setInvoicePayed] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", invoiceId)),
Security: DbSec, options: SqlOpt(fn, id, code)) Security: DbSec, options: SqlOpt(fn, id, code));
? Ok() : StatusCode(500); if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
return ok ? Ok() : StatusCode(500);
}
case "setupd": case "setupd":
if (!HasForm("id")) return BadRequest400(); if (!HasForm("id")) { _logger.LogWarning("setupd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
return await setSQLValue_async( {
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;", "EXECUTE [dbo].[fds__setInvoiceUNPayed] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", invoiceId)),
Security: DbSec, options: SqlOpt(fn, id, code)) Security: DbSec, options: SqlOpt(fn, id, code));
? Ok() : StatusCode(500); if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID);
return ok ? Ok() : StatusCode(500);
}
case "setvat": case "setvat":
if (!float.TryParse(Form("val").Replace("%", "").Replace(",", ".").Trim(), if (!float.TryParse(Form("val").Replace("%", "").Replace(",", ".").Trim(),
NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out float vatVal)) 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( var pl = StdParamlist(
SQL_BigInt("@id", Form("id")), SQL_BigInt("@id", Form("id")),
SQL_VarChar("@entitytype", "report"), SQL_VarChar("@entitytype", "report"),
@@ -53,42 +70,90 @@ public partial class IntranetController
string sqlEx = ""; int? sqlCode = null; string sqlEx = ""; int? sqlCode = null;
setSQLValue("EXECUTE [dbo].[fds__setReportVAT] @id, @entitytype, @vat, @authuser;", setSQLValue("EXECUTE [dbo].[fds__setReportVAT] @id, @entitytype, @vat, @authuser;",
_intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec); _intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec);
if (!string.IsNullOrEmpty(sqlEx))
_logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID);
return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx }); return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx });
} }
case "sis": 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( var dt2 = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;", "EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt2.Exception))
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
} }
case "pget": return await HandleInvoicePget(fn, id, code); case "pget":
case "get": return await HandleInvoiceGet(fn, id, code); _logger.LogDebug("pget: invoice PDF get, user={User}", UserAccountID);
case "icget": return await HandleInvoiceIcGet(fn, id, code); 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 "storno":
case "credit": return await HandleInvoiceStornoCredit(fn, id, code); case "credit":
case "invl": return await HandleInvoiceList(fn, id, code); _logger.LogInformation("{Action}: invoice storno/credit, user={User}", id, UserAccountID);
case "rqi": return await HandleInvoiceRequestItems(fn, id, code); return await HandleInvoiceStornoCredit(fn, id, code);
case "pyi": return await HandleInvoicePayments(fn, id, code);
case "datev": return await HandleDatev(fn, id, code); case "invl":
case "rdoc": return await HandleReportDoc(fn, id, code, Form("id")); _logger.LogDebug("invl: invoice list, user={User}", UserAccountID);
case "rdocn": return await HandleReportDocByName(fn, id, code); return await HandleInvoiceList(fn, id, code);
case "datevzip": return await HandleDatevZip(fn, id, code);
case "getrem": return await HandleGetReminder(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": case "mfrrel":
if (!HasForm("id") || !long.TryParse(Form("id"), out long relId)) return BadRequest400(); if (!HasForm("id") || !long.TryParse(Form("id"), out long relId))
using (var mfr = new fds.FdsMfrClient()) {
_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, await mfr.Update__entitytable(EntityTypes.Invoice,
fds.FdsMfr.UpdateNeed.Reset, new[] { relId }); fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
return Ok(); return Ok();
default: return Ok(); default:
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
return Ok();
} }
} }
} }
+135 -37
View File
@@ -18,11 +18,16 @@ public partial class IntranetController
{ {
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code) private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{ {
if (!HasForm("id")) return BadRequest400(); if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) return BadRequest400(); if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
using (var mfr = new fds.FdsMfrClient()) _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, await mfr.Update__entitytable(EntityTypes.Invoice,
fds.FdsMfr.UpdateNeed.Reset, new[] { tgtid }); fds.FdsMfr.UpdateNeed.Reset, new[] { tgtid });
}
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fds__getInvoiceTreeIds](@srqid);", "SELECT * FROM [dbo].[fds__getInvoiceTreeIds](@srqid);",
@@ -30,6 +35,7 @@ public partial class IntranetController
StdParamlist(SQL_BigInt("@srqid", tgtid)), StdParamlist(SQL_BigInt("@srqid", tgtid)),
Security: DbSec, options: SqlOpt(fn, id, code)); 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) if (dt.Count > 0)
{ {
var invIds = new List<long>(); 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; 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) foreach (var iid in invIds)
await mfr2.Update__entitytable(EntityTypes.Invoice, fds.FdsMfr.UpdateNeed.Reset, new[] { iid }); await mfr2.Update__entitytable(EntityTypes.Invoice, fds.FdsMfr.UpdateNeed.Reset, new[] { iid });
foreach (var iid in srqIds) foreach (var iid in srqIds)
await mfr2.Update__entitytable(EntityTypes.ServiceRequest, fds.FdsMfr.UpdateNeed.Reset, new[] { iid }); 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(); return Ok();
} }
@@ -56,35 +65,48 @@ public partial class IntranetController
{ {
try 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( var sqldset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;", "EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", invoiceId)),
tablenames: new[] { "admin", "inv", "req", "itm" }, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
var ldic = BuildInvoiceRequestList(sqldset); var ldic = BuildInvoiceRequestList(sqldset);
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary(); var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary(); var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
if (invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b")) bool has13b = invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b");
if (has13b)
adminDic["p13b"] = true; 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 }); 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) 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( var sqldset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__prepStorno_recreate] @InvId, @authuser;", "EXECUTE [dbo].[fds__prepStorno_recreate] @InvId, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@InvId", Form("id"))), StdParamlist(SQL_VarChar("@InvId", invoiceId)),
tablenames: new[] { "admin", "requests", "items", "steps", "companies", "locations" }, tablenames: new[] { "admin", "requests", "items", "steps", "companies", "locations" },
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
var ldic = BuildRequestItemList(sqldset); var ldic = BuildRequestItemList(sqldset);
_logger.LogDebug("HandleInvoiceIcGet invoiceId={InvoiceId} requestCount={ReqCount} user={User}",
invoiceId, ldic.Count, UserAccountID);
return await JSONAsync(new return await JSONAsync(new
{ {
admin = sqldset.Table("admin").FirstRow.toObjectDictionary(), 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) private async Task<IActionResult> HandleInvoiceStornoCredit(string fn, string id, string code)
{ {
if (!HasForm("id", "mode")) return BadRequest400(); if (!HasForm("id", "mode")) { _logger.LogWarning("HandleInvoiceStornoCredit: missing required form fields user={User}", UserAccountID); return BadRequest400(); }
string sqlcmd = Form("mode") switch 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;", "credit" => "EXECUTE [dbo].[fds__createCredit_simple] @Id, @authuser;",
"simple" => "EXECUTE [dbo].[fds__createStorno_simple] @Id, @authuser;", "simple" => "EXECUTE [dbo].[fds__createStorno_simple] @Id, @authuser;",
"copy" => "EXECUTE [dbo].[fds__createStorno_copy] @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, var sqldset = await getSQLDataSet_async(sqlcmd,
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", invoiceId)),
tablenames: new[] { "admin", "inv", "req", "itm" }, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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 return await JSONAsync(new
{ {
admin = sqldset.Table("admin").FirstRow.toObjectDictionary(), admin = sqldset.Table("admin").FirstRow.toObjectDictionary(),
inv = sqldset.Table("inv").FirstRow.toObjectDictionary(), inv = sqldset.Table("inv").FirstRow.toObjectDictionary(),
req = BuildInvoiceRequestList(sqldset) req = reqList
}); });
} }
private async Task<IActionResult> HandleInvoiceList(string fn, string id, string code) 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(); string mode = Form("mode").ToLower();
_logger.LogDebug("HandleInvoiceList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
if (mode == "s" && Form("tgt").Contains(':')) 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( var pl = StdParamlist(
SQL_Date("@tgtdate", DBNull.Value), SQL_Date("@tgtdate", DBNull.Value),
SQL_VarChar("@mode", Form("mode").ne("m")), SQL_VarChar("@mode", Form("mode").ne("m")),
@@ -134,6 +169,8 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "invoices" }, tablenames: new[] { "admin", "invoices" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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 return await JSONAsync(new
{ {
admin = dset.Table("admin").FirstRow.toObjectDictionary(), admin = dset.Table("admin").FirstRow.toObjectDictionary(),
@@ -142,17 +179,25 @@ public partial class IntranetController
} }
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd", if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate)) CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
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( var pl = StdParamlist(
SQL_Date("@tgtdate", tgtdate), SQL_Date("@tgtdate", tgtdate),
SQL_VarChar("@mode", Form("mode").ne("m")), 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( var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoices_list_vario] @tgtdate, @mode, @includes, @authuser;", "EXECUTE [dbo].[fds__getInvoices_list_vario] @tgtdate, @mode, @includes, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "invoices" }, tablenames: new[] { "admin", "invoices" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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 return await JSONAsync(new
{ {
admin = dset.Table("admin").FirstRow.toObjectDictionary(), 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) 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( var sqldt = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvRequestItems] @invoiceid, @authuser;", "EXECUTE [dbo].[fds__getInvRequestItems] @invoiceid, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@invoiceid", Form("id"))), StdParamlist(SQL_VarChar("@invoiceid", invoiceId)),
tablenames: new[] { "requests", "items" }, tablenames: new[] { "requests", "items" },
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
var ldic = new List<Dictionary<string, object?>>(); var ldic = new List<Dictionary<string, object?>>();
@@ -180,18 +227,25 @@ public partial class IntranetController
.Select(r => r.toObjectDictionary()).ToList(); .Select(r => r.toObjectDictionary()).ToList();
ldic.Add(sdic!); ldic.Add(sdic!);
} }
_logger.LogDebug("HandleInvoiceRequestItems invoiceId={InvoiceId} requestCount={ReqCount} user={User}",
invoiceId, ldic.Count, UserAccountID);
return await JSONAsync(new { requests = ldic }); return await JSONAsync(new { requests = ldic });
} }
private async Task<IActionResult> HandleInvoicePayments(string fn, string id, string code) 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( var sqldt = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvPayments] @invoiceid, @authuser;", "EXECUTE [dbo].[fds__getInvPayments] @invoiceid, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@invoiceid", Form("id"))), StdParamlist(SQL_VarChar("@invoiceid", invoiceId)),
tablenames: new[] { "items" }, tablenames: new[] { "items" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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() }); 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", if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate)) CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
{
_logger.LogWarning("HandleDatev: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
return BadRequest400(); 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( var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;", "EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
_intranet.Intranet__SQLConnectionString, _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" }, tablenames: new[] { "files", "invoices", "debits" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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 return await JSONAsync(new
{ {
files = dset.Tables("files").toArrayofObjectDictionaries(), 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) 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; byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid); 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" return Form("typ") != "img"
? await FileContentResultAsync(content!, file.MimeType(), file.Name) ? await FileContentResultAsync(content!, file.MimeType(), file.Name)
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }); : 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) 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(); 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( var so = await getSQLValue_async(
"SELECT [dbo].[fds__fn_InvoiceIdByName](@nme);", "SELECT [dbo].[fds__fn_InvoiceIdByName](@nme);",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@nme", nme)), StdParamlist(SQL_VarChar("@nme", nme)),
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
string reportid = so.Result?.ToString() ?? ""; string reportid = so.Result?.ToString() ?? "";
return string.IsNullOrEmpty(reportid) if (string.IsNullOrEmpty(reportid))
? StatusCode(404) {
: await HandleReportDoc(fn, id, code, 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) private async Task<IActionResult> HandleDatevZip(string fn, string id, string code)
{ {
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd", if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate)) CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
{
_logger.LogWarning("HandleDatevZip: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
return BadRequest400(); 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(); Stream? ms = new MemoryStream();
var file = _mfr.GetDatevZip(ref ms, tgtdate, var file = _mfr.GetDatevZip(ref ms, tgtdate,
mode: Form("mode").ne("m"), mode: mode,
authUser: UserAccountID, authUser: UserAccountID,
includeFiles: Form("files", "1") != "0"); includeFiles: includeFiles);
if (file == null) return BadRequest400(); 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; ms!.Position = 0;
return await FileStreamResultAsync(ms, file.MimeType(), file.Name); return await FileStreamResultAsync(ms, file.MimeType(), file.Name);
} }
private async Task<IActionResult> HandleGetReminder(string fn, string id, string code) 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( var pl = StdParamlist(
SQL_VarChar("@InvId", Form("id")), SQL_VarChar("@InvId", invoiceId),
SQL_Bit("@include_drafts", Form("drafts"))); SQL_Bit("@include_drafts", includeDrafts));
var sqldt = await getSQLDataSet_async( var sqldt = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoiceReminder] @InvId, @include_drafts, @authuser;", "EXECUTE [dbo].[fds__getInvoiceReminder] @InvId, @include_drafts, @authuser;",
_intranet.Intranet__SQLConnectionString, pl, _intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "reminder" }, tablenames: new[] { "reminder" },
Security: DbSec, options: SqlOpt(fn, id, code)); 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()); return await JSONAsync(sqldt.Table("reminder").DataTable.toArrayofObjectDictionaries());
} }
@@ -315,9 +413,9 @@ public partial class IntranetController
return ldic; 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; return imgcol.ImgB64Array;
} }
} }
@@ -17,6 +17,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Reminder(string fn, string id, string code) 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()) switch (id.ToLower())
{ {
case "get": case "get":
@@ -38,11 +39,11 @@ public partial class IntranetController
{ {
if (!HasForm("remc")) return BadRequest400(); if (!HasForm("remc")) return BadRequest400();
var ctd = JsonConvert.DeserializeObject(Form("remc"))!; var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
var fdRem = new FdsReminderData(ctd); var fdRem = await _reminders.RegisterReminderAsync(
fdRem.RegisterReminder(this, change: false, remId: ""); new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdRem.Id)) 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 await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" }); return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" });
@@ -64,12 +65,11 @@ public partial class IntranetController
case "rdoc": case "rdoc":
{ {
if (!HasForm("id")) return BadRequest400(); if (!HasForm("id")) return BadRequest400();
byte[]? fc = null; var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
var file = FdsReminderData.GetStoredFile(ref fc, Form("id"), this); if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
if (file == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
return Form("typ") != "img" return Form("typ") != "img"
? await FileContentResultAsync(fc!, file.MimeType(), file.Name) ? await FileContentResultAsync(fc, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc!) }); : await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
} }
case "idoc": return await HandleReminderIdoc(fn, id, code); case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -107,8 +107,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true) if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{ {
string remId = frdic["Id"]?.ToString() ?? ""; string remId = frdic["Id"]?.ToString() ?? "";
var fdRem = new FdsReminderData(remId, this); var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
byte[] filebyte = await fdRem.StoreReminderDocumentFile(this); byte[] filebyte = await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec);
string email = frdic.nz("SendToEmail", ""); string email = frdic.nz("SendToEmail", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0) if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{ {
@@ -118,6 +118,8 @@ public partial class IntranetController
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
remId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync( bool sent = await _comService.SendEmailAsync(
$"inv_{remId}", $"inv_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
@@ -140,17 +142,17 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code) private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404); if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) 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" }); 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") if (Form("typ") != "img")
{ {
byte[] ct = Form("create", "0") != "1" byte[] ct = Form("create", "0") != "1"
? (await fdRem.GetReminderFile(this)) is { Length: > 0 } f1 ? f1 : await fdRem.StoreReminderDocumentFile(this) ? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
: FuchsPdf.DocToPdfBytes(fdRem.ReminderPDF(this)); : _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true); 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 }); return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
@@ -13,6 +13,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Reports(string fn, string id, string code) 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()) switch (id.ToLower())
{ {
case "auth": case "auth":
@@ -46,7 +47,7 @@ public partial class IntranetController
} }
default: default:
return await FuchsReports.ProcessFdsRequest(this, id.ToLower(), code); return await _reports.ProcessRequestAsync(id.ToLower(), code, UserAccountID, DbSec, RequestParamsDict());
} }
} }
} }
@@ -19,6 +19,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Requests(string fn, string id, string code) 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()) switch (id.ToLower())
{ {
case "auth": case "auth":
@@ -45,8 +46,9 @@ public partial class IntranetController
case "save": case "save":
{ {
if (!HasForm("invc")) return BadRequest400(); if (!HasForm("invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!); var fdInv = await _invoices.RegisterInvoiceAsync(
fdInv.RegisterInvoice(this, change: !string.IsNullOrEmpty(Form("id")), invId: Form("id")); new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
return !string.IsNullOrEmpty(fdInv.Id) return !string.IsNullOrEmpty(fdInv.Id)
? await JSONAsync(new { id = fdInv.Id }) ? await JSONAsync(new { id = fdInv.Id })
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" }); : StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" });
@@ -55,11 +57,12 @@ public partial class IntranetController
case "sprep": case "sprep":
{ {
if (!HasForm("invc")) return BadRequest400(); if (!HasForm("invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!); var fdInv = await _invoices.RegisterInvoiceAsync(
fdInv.RegisterInvoice(this, change: false, invId: ""); new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: false, invId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id)) 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 await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
@@ -68,11 +71,12 @@ public partial class IntranetController
case "sedit": case "sedit":
{ {
if (!HasForm("id", "invc")) return BadRequest400(); if (!HasForm("id", "invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!); var fdInv = await _invoices.RegisterInvoiceAsync(
fdInv.RegisterInvoice(this, change: true, invId: Form("id")); new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: true, invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id)) 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 await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
@@ -171,7 +175,7 @@ public partial class IntranetController
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] = [EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
new fds.FdsMfrClient.DatabaseSchema(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, await mfr.Update__entitytable(EntityTypes.ServiceRequest,
fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic); fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic);
return Ok(); return Ok();
@@ -258,8 +262,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true) if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{ {
string invId = frdic["Id"]?.ToString() ?? ""; string invId = frdic["Id"]?.ToString() ?? "";
var fdInv = new FdsInvoiceData(invId, this); var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
byte[] filebyte = await fdInv.StoreInvoiceDocumentFile(this); byte[] filebyte = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
var dtset = await getSQLDataSet_async( var dtset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;", "EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
@@ -274,6 +278,8 @@ public partial class IntranetController
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0)); double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen"); string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
string body = BuildInvoiceBody(bal, terms); string body = BuildInvoiceBody(bal, terms);
_logger.LogInformation("Request sconf: emailing finalized invoice {InvId} to {Email} user={User}",
invId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync( bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}", $"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
body, email.Trim(), "", inv); body, email.Trim(), "", inv);
@@ -293,19 +299,19 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404); if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) 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" }); 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") if (Form("typ") != "img")
{ {
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await fdInv.GetInvoiceFile(this) is { Length: > 0 } f1 ? f1 : await fdInv.StoreInvoiceDocumentFile(this) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this)); : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return ct != null return ct != null
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true) ? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" }); : 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 }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
@@ -322,8 +328,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true) if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{ {
string invId = frdic["Id"]?.ToString() ?? ""; string invId = frdic["Id"]?.ToString() ?? "";
var fdInv = new FdsInvoiceData(invId, this); var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
byte[] filebyte = FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this)); byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
string email = frdic.nz("SendToEmail", ""); string email = frdic.nz("SendToEmail", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0) if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{ {
+171 -21
View File
@@ -26,6 +26,13 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
internal readonly fds.IFdsMfr _mfr; internal readonly fds.IFdsMfr _mfr;
private readonly ILogger<IntranetController> _logger; private readonly ILogger<IntranetController> _logger;
private readonly IComService _comService; 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> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new() private readonly List<string> _allowedGet = new()
{ {
@@ -41,12 +48,40 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
public string UserAccountID => UserIdent.UserAccountId; public string UserAccountID => UserIdent.UserAccountId;
public string AuthAccount => UserIdent.Email; public string AuthAccount => UserIdent.Email;
public IntranetController(Fuchs_intranet intranet, fds.IFdsMfr mfr, ILogger<IntranetController> logger, IComService comService) 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; _intranet = intranet;
_mfr = mfr; _mfr = mfr;
_logger = logger; _logger = logger;
_comService = comService; _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) ──────────────────────── // ── Standard param list (pre-populates @authuser) ────────────────────────
@@ -76,23 +111,27 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Index (GET /) ───────────────────────────────────────────────────────── // ── Index (GET /) ─────────────────────────────────────────────────────────
[AllowAnonymous] [AllowAnonymous]
public IActionResult Index(string? fn, string? id, string? code) => public IActionResult Index([FromRoute] string? fn, [FromRoute] string? id, [FromRoute] string? code) =>
View("intranet"); View("intranet");
// ── Do (POST+GET /do/{fn}/{id}/{code}) ───────────────────────────────── // ── Do (POST+GET /do/{fn}/{id}/{code}) ─────────────────────────────────
[AllowAnonymous] [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(); fn = (fn ?? "").ToLower();
id ??= ""; id ??= "";
code ??= ""; code ??= "";
bool isGet = HttpContext.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase); 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 (!UserIdent.IsAuthenticated && !(new string[] { "login","logout" }).Contains(fn.ToLower()) && !_allowedNonAuth.Contains(fn.ToLower()))
{ {
if (!_allowedGet.Contains(fn.ToLower()) && !_allowedGet.Contains($"{fn.ToLower()}|{id.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(); return Unauthorized401();
} }
} }
@@ -102,7 +141,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IActionResult? result = fn.ToLower() switch IActionResult? result = fn.ToLower() switch
{ {
"ping" => Ok(), "ping" => Ok(),
"wdg" => await FuchsWidgets.IntranetWdg(this, id), "wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
"todos" => new PhysicalFileResult( "todos" => new PhysicalFileResult(
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"), Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
"text/html"), "text/html"),
@@ -121,12 +160,16 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
"logout" => await HandleLogout(), "logout" => await HandleLogout(),
_ => null _ => 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(); return result ?? Ok();
} }
catch (Exception ex) catch (Exception ex)
{ {
_intranet.debug_log("IntranetController.Do", ex, UserAccountID, _logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
data: new { fn, id, code }); fn, id, code, UserAccountID);
return ServerError(); return ServerError();
} }
} }
@@ -134,8 +177,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Auth helper ─────────────────────────────────────────────────────────── // ── Auth helper ───────────────────────────────────────────────────────────
private async Task<IActionResult> HandleAuth(string fn, string id, string code) 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(); 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") if (Request.Form["array"] == "1")
{ {
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
@@ -143,6 +192,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@module", module)), StdParamlist(SQL_VarChar("@module", module)),
Security: DbSec, options: SqlOpt(fn, id, code)); 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")); return await JSONAsync(dt.DataTable.ToDictionary(KeyColumn: "module", ValueColumn: "auth"));
} }
else else
@@ -152,6 +203,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_intranet.Intranet__SQLConnectionString, -1, _intranet.Intranet__SQLConnectionString, -1,
StdParamlist(SQL_VarChar("@module", module)), StdParamlist(SQL_VarChar("@module", module)),
Security: DbSec, options: SqlOpt(fn, id, code)); 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 }); return await JSONAsync(new { auth = val.Result });
} }
} }
@@ -162,13 +215,13 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{ {
string email = Request.Form["userinfo"].ToString(); string email = Request.Form["userinfo"].ToString();
string password = Request.Form["userpass"].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); var row = await _intranet.AuthenticateAsync(email, password);
if (row == null) if (row == null)
{ {
_logger.LogWarning("Login failed for '{Email}' from {IP}", _logger.LogWarning("Login failed for email={Email} ip={IP}",
email, HttpContext.Connection.RemoteIpAddress); email, HttpContext.Connection.RemoteIpAddress);
_intranet.debug_log("HandleLogin: failed",
data: new { email, ip = HttpContext.Connection.RemoteIpAddress?.ToString() });
return Unauthorized401(); return Unauthorized401();
} }
@@ -179,6 +232,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme); var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme);
var principal = new System.Security.Claims.ClaimsPrincipal(identity); var principal = new System.Security.Claims.ClaimsPrincipal(identity);
await HttpContext.SignInAsync(Fuchs_intranet.AuthScheme, principal); 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 return await JSONAsync(new
{ {
login = userEmail, login = userEmail,
@@ -192,7 +247,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private async Task<IActionResult> HandleLogout() private async Task<IActionResult> HandleLogout()
{ {
_logger.LogInformation("Logout user={User} ip={IP}",
UserAccountID, HttpContext.Connection.RemoteIpAddress);
await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme); await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme);
_logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID);
return Ok(); return Ok();
} }
@@ -201,16 +259,27 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{ {
string? lastname = Request.Form["lastname"]; string? lastname = Request.Form["lastname"];
string? email = Request.Form["email"]; 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); var row = await _intranet.GetUserAccountByEmailAsync(email);
if (row != null && row.nz("email").Length > 5 && if (row != null && row.nz("email").Length > 5 &&
row.nz("name").ToLower().Trim() == lastname.ToLower().Trim() && row.nz("name").ToLower().Trim() == lastname.ToLower().Trim() &&
row.nz("mobile").Length > 5 && !Request.Host.Host.ToLower().Contains("localhost")) row.nz("mobile").Length > 5 && !Request.Host.Host.ToLower().Contains("localhost"))
{ {
_logger.LogInformation("Sending password code SMS to mobile for email={Email}", email);
string totp = OCORE.security.TFA.generateTotp_12h(_intranet.Intranet__TOTPsharedsecret_base); string totp = OCORE.security.TFA.generateTotp_12h(_intranet.Intranet__TOTPsharedsecret_base);
await _comService.SendSmsAsync(row.nz("mobile"), await _comService.SendSmsAsync(row.nz("mobile"),
"Zur Bestätigung des Passwortversands auf sanitarfuchs.de, verwenden Sie bitte folgenden Code:" + totp); "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 return Ok(); // always OK to prevent enumeration
} }
@@ -220,34 +289,57 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
string? lastname = Request.Form["lastname"]; string? lastname = Request.Form["lastname"];
string? email = Request.Form["email"]; string? email = Request.Form["email"];
string? totpCode = Request.Form["code"]; string? totpCode = Request.Form["code"];
_logger.LogDebug("HandleSendPassword called for email={Email}", email);
if (string.IsNullOrEmpty(lastname) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(totpCode)) 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); var row = await _intranet.GetUserAccountByEmailAsync(email, includePassword: true);
if (row != null && row.nz("email").Length > 5) if (row != null && row.nz("email").Length > 5)
{ {
_logger.LogInformation("Sending password email to email={Email}", email);
await _comService.SendEmailAsync("pw_" + row.nz("email"), await _comService.SendEmailAsync("pw_" + row.nz("email"),
"sanitaerfuchs.de Intranet Passwort", "sanitaerfuchs.de Intranet Passwort",
$"<p>Guten Tag {row.nz("firstname")} {row.nz("name")},<br/>Ihr Passwort: {HttpUtility.HtmlEncode(row.nz("password"))}</p>", $"<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); 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(); return Ok();
} }
private async Task<IActionResult> HandleAccount(string fn, string id, string code) private async Task<IActionResult> HandleAccount(string fn, string id, string code)
{ {
_logger.LogDebug("HandleAccount action={Action} user={User}", id, UserAccountID);
switch (id.ToLower()) switch (id.ToLower())
{ {
case "sms": case "sms":
var row = await _intranet.GetUserAccountByEmailAsync(UserIdent.Email, includePassword: true); var row = await _intranet.GetUserAccountByEmailAsync(UserIdent.Email, includePassword: true);
if (row != null && row.nz("mobile").Length > 5 && !Request.Host.Host.Contains("localhost")) if (row != null && row.nz("mobile").Length > 5 && !Request.Host.Host.Contains("localhost"))
{ {
_logger.LogInformation("Sending change-password confirmation SMS to user={User}", UserAccountID);
string totp2 = OCORE.security.TFA.generateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR"); string totp2 = OCORE.security.TFA.generateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR");
await _comService.SendSmsAsync(row.nz("mobile"), await _comService.SendSmsAsync(row.nz("mobile"),
"Zur Bestätigung der Passwortänderung auf sanitarfuchs.de: " + totp2); "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(); return Ok();
@@ -256,52 +348,110 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
string? npwc = Request.Form["npwc"]; string? npwc = Request.Form["npwc"];
string? totpCode = Request.Form["code"]; string? totpCode = Request.Form["code"];
if (string.IsNullOrEmpty(npw) || string.IsNullOrEmpty(npwc) || string.IsNullOrEmpty(totpCode)) if (string.IsNullOrEmpty(npw) || string.IsNullOrEmpty(npwc) || string.IsNullOrEmpty(totpCode))
{
_logger.LogWarning("HandleAccount changepassword: missing required fields for user={User}", UserAccountID);
return BadRequest400(); 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) 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" }); 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( await setSQLValue_async(
"EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;", "EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
new List<SqlParameter> new List<SqlParameter>
{ {
SQL_VarChar("@useraccount_id", UserAccountID), SQL_VarChar("@useraccount_id", UserAccountID),
SQL_VarChar("@oldpassword", Request.Form["opw"].ToString()), SQL_VarChar("@oldpassword", oldPw),
SQL_VarChar("@newpassword", npw) SQL_VarChar("@newpassword", npw)
}, },
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("Password changed successfully for user={User}", UserAccountID);
return Ok(); return Ok();
} }
_logger.LogWarning("HandleAccount unknown action={Action} user={User}", id, UserAccountID);
return Ok(); return Ok();
} }
private async Task<IActionResult> HandleMfr(string fn, string id, string code) 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) if (!string.IsNullOrEmpty(UserAccountID) && UserIdent.Authorization > 3)
{ {
string path = id + (!string.IsNullOrEmpty(code) ? "/" + code : HttpUtility.UrlDecode(Request.QueryString.Value ?? "")); 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); 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"); return Content(JsonConvert.SerializeObject(result), "application/json");
} }
_logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}",
UserAccountID, UserIdent.Authorization);
return Ok(); return Ok();
} }
private async Task<IActionResult> HandleMfrUpdate(string fn, string id, string code) 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"])) 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); await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
return Ok(); return Ok();
} }
if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"])) if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"]))
{ {
var need = fds.FdsMfr.UpdateNeedValue(Request.Form["need"].ToString()); var need = fds.FdsMfr.UpdateNeedValue(needParam);
using var mfr = new fds.FdsMfrClient(); _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); await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
return Ok(); return Ok();
} }
_logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID);
return BadRequest400(); return BadRequest400();
} }
} }
+52 -11
View File
@@ -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** | ASP.NET Core Web (MVC) | Main web application — the intranet |
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) | | **Fuchs_DataService** | 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** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
| **OCORE_web** | Class Library (shared) | Web utilities: MVC helpers, middleware, auth, captcha | | **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_web_pdf** | Class Library (shared) | PDF generation (MigraDoc/PDFsharp, HTML→PDF) |
| **OCORE_Charting** | Class Library (shared) | Data visualization / charting (ported System.Windows.Forms.DataVisualization) | | **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`.** **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 ### 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. `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 ### 4.3 Service Layer (Dependency Injection)
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. 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 ### 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. 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 ## 7. Additional Observations
1. **`System.Configuration.ConfigurationManager` usage** in `FuchsFdsEmail.cs` directly violates the project's coding standards (`appsettings.json` only). 1. **Resolved** — email/SMS moved off `ConfigurationManager` into `IComService` (ProcessWeb Mailer API).
2. **No dependency injection in `FdsInvoiceData`/`FdsReminderData`** — these classes receive the entire controller, creating circular-style dependencies. 2. **Resolved**`FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
3. **`FdsMfrClient` is `new`-ed directly** in controller partials (e.g., `IntranetController.Invoices2.cs`) instead of being injected. 3. **Resolved**`FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
4. **`OCORE_Charting`** is in the solution but not directly referenced by any project — verify if it's still needed. 4. **Resolved**`OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
5. **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment. 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`.
+98
View File
@@ -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.
+99
View File
@@ -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.
+128
View File
@@ -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).
+11
View File
@@ -7,6 +7,10 @@
<Configurations>db-dev.processweb.de;Debug;Release;server02.processweb.de</Configurations> <Configurations>db-dev.processweb.de;Debug;Release;server02.processweb.de</Configurations>
<NoWarn>CA1416</NoWarn> <NoWarn>CA1416</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Expose internal members (e.g. FdsInvoiceData.BuildInvoiceParams) to the test project -->
<InternalsVisibleTo Include="Fuchs.Tests" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
</PropertyGroup> </PropertyGroup>
@@ -17,6 +21,7 @@
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" /> <ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" /> <ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" /> <ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" /> <Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
@@ -29,6 +34,12 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.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="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" /> <PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" /> <PackageReference Include="PDFsharp" Version="6.2.4" />
+56
View File
@@ -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);
}
+46 -1
View File
@@ -1,5 +1,6 @@
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Logging; using Fuchs.Logging;
using Fuchs.Observability;
using OCORE_web.Secrets; using OCORE_web.Secrets;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OCORE.security; using OCORE.security;
using OCORE.web; using OCORE.web;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Fuchs; namespace Fuchs;
@@ -36,7 +40,7 @@ public class Program
FuchsOcmsIntranet.Initialize(builder.Configuration); FuchsOcmsIntranet.Initialize(builder.Configuration);
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings // 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 // FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>(); builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
@@ -75,6 +79,47 @@ public class Program
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer")); builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
builder.Services.AddHttpClient("ProcessWebMailer"); builder.Services.AddHttpClient("ProcessWebMailer");
builder.Services.AddScoped<IComService, ProcessWebComService>(); 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) private static void ConfigureApp(WebApplication app)
+96 -15
View File
@@ -1,11 +1,16 @@
using System.Data; using System.Data;
using System.Diagnostics;
using CAMTParser;
using Fuchs.Observability;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using programmersdigest.MT940Parser; using programmersdigest.MT940Parser;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <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> /// </summary>
public class BankingService : IBankingService public class BankingService : IBankingService
{ {
@@ -27,12 +32,47 @@ public class BankingService : IBankingService
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null) public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
{ {
using var act = FuchsTelemetry.StartActivity("banking.parse");
var sw = Stopwatch.StartNew();
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema(); 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) void SetNfo(DataRow nr, string key, object? value)
{ {
if (tbl.Columns.Contains(key) && value != null) if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
nr[key] = value;
} }
using var ps = new Parser(stream: stream); using var ps = new Parser(stream: stream);
@@ -72,29 +112,70 @@ public class BankingService : IBankingService
SetNfo(nr, "TransactionCode", info.TransactionCode); SetNfo(nr, "TransactionCode", info.TransactionCode);
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData); SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
SetNfo(nr, "UnstructuredData", info.UnstructuredData); SetNfo(nr, "UnstructuredData", info.UnstructuredData);
SetNfo(nr, "UnstructuredRemittanceInformation", info.UnstructuredRemittanceInformation); SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark)); SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails); SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails);
SetNfo(nr, "TransactionTypeIdCode", line.TransactionTypeIdCode); SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
SetNfo(nr, "ValueDate", line.ValueDate); SetNfo(nr, "ValueDate", line.ValueDate);
tbl.Rows.Add(nr); tbl.Rows.Add(nr);
} }
catch (Exception ex) catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
{
_logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
} }
} }
} }
} catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
catch (Exception ex)
{
_logger.LogError(ex, "MT940 statement parse failed.");
} }
tbl.AcceptChanges(); // ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
return tbl; private void FillFromCamt(DataTable tbl, byte[] bytes)
{
void SetNfo(DataRow nr, string key, object? value)
{
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
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() private static DataTable BuildDefaultSchema()
+74 -7
View File
@@ -1,4 +1,6 @@
using Fuchs.intranet; using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
/// <summary> /// <summary>
/// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods /// 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> /// </summary>
public class FuchsPdfService : IPdfService public class FuchsPdfService : IPdfService
{ {
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
{ {
_logger = logger; _logger = logger;
FuchsPdf.SetLicense(); FuchsPdf.SetLicense();
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
} }
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft) public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
{ {
_logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture); return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
} }
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false) FdsInvoiceData invoice, bool draft = false)
{ {
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft); FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
} }
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsReminderData reminder, bool draft = false) FdsReminderData reminder, bool draft = false)
{ {
_logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, 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) => public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
FuchsPdf.DocToImageCollection(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) => public async Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes)
FuchsPdf.BytesToImageCollection(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;
}
}
} }
+134 -8
View File
@@ -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 Microsoft.Extensions.Logging;
using OCORE.security; using OCORE.security;
using OCORE.SQL; using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <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> /// </summary>
public class FuchsReportService : IReportService public class FuchsReportService : IReportService
{ {
private const int DefaultReloadSeconds = 60 * 10;
private readonly Fuchs_intranet _intranet;
private readonly ILogger<FuchsReportService> _logger; private readonly ILogger<FuchsReportService> _logger;
public FuchsReportService(ILogger<FuchsReportService> logger) public FuchsReportService(Fuchs_intranet intranet, ILogger<FuchsReportService> logger)
{ {
_intranet = intranet;
_logger = logger; _logger = logger;
} }
public Task<IActionResult> ProcessRequestAsync(string action, string id, private string Conn => _intranet.Intranet__SQLConnectionString;
string userAccountId, DatabaseSecurity dbSec)
public async Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters)
{ {
// Specific report actions are dispatched here. var prms = new Dictionary<string, string>(parameters, StringComparer.OrdinalIgnoreCase)
// Extend with additional cases as needed. {
return Task.FromResult<IActionResult>(new OkResult()); ["@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;
} }
} }
+117 -41
View File
@@ -1,7 +1,7 @@
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OCORE.security; using OCORE.security;
using OCORE.SQL; using OCORE.SQL;
@@ -12,8 +12,9 @@ using static OCORE.web.mvc_helper_async;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Widget service implementation. Replaces the static <c>FuchsWidgets</c> class. /// Widget service for the Fuchs intranet dashboard. Port of fuchs_fds_widgets.vb —
/// No longer depends on <c>IntranetController</c>. /// SQL-driven widget cases. Replaces the static <c>FuchsWidgets</c> class
/// (no longer depends on <c>IntranetController</c>).
/// </summary> /// </summary>
public class FuchsWidgetService : IWidgetService public class FuchsWidgetService : IWidgetService
{ {
@@ -26,39 +27,48 @@ public class FuchsWidgetService : IWidgetService
_logger = logger; _logger = logger;
} }
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId, public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId,
DatabaseSecurity dbSec, HttpRequest request) 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 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), "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) catch (Exception ex)
{ {
act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId); _logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
return new StatusCodeResult(500); 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); list.AddRange(extra);
return list; return list;
} }
// ── "my" — list of widget short-names for the current user ───────────────
private async Task<IActionResult> HandleWidgetMy(string userAccountId, DatabaseSecurity dbSec) private async Task<IActionResult> HandleWidgetMy(string userAccountId, DatabaseSecurity dbSec)
{ {
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);", "SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
_intranet.Intranet__SQLConnectionString, Conn, Params(userAccountId, SQL_VarChar("@account", "fis")), Security: dbSec);
MakeParams(userAccountId, SQL_VarChar("@account", "fis")),
Security: dbSec);
var names = dt.DataTable.Rows var names = dt.DataTable.Rows
.Cast<System.Data.DataRow>() .Cast<System.Data.DataRow>()
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "") .OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
@@ -67,6 +77,7 @@ public class FuchsWidgetService : IWidgetService
return await JSONAsync(names); return await JSONAsync(names);
} }
// ── "one" — full widget data for a single widget ──────────────────────────
private async Task<IActionResult> HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec, private async Task<IActionResult> HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec,
HttpRequest request) HttpRequest request)
{ {
@@ -75,55 +86,120 @@ public class FuchsWidgetService : IWidgetService
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;", "SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
_intranet.Intranet__SQLConnectionString, Conn,
MakeParams(userAccountId, Params(userAccountId,
SQL_VarChar("@shortname", shortName), SQL_VarChar("@shortname", shortName),
SQL_VarChar("@account", "fis")), SQL_VarChar("@account", "fis")),
Security: dbSec); Security: dbSec);
if (dt.Count != 1) return new StatusCodeResult(404); if (dt.Count != 1) return new StatusCodeResult(404);
var wdg = dt.FirstRow.toObjectDictionary(); 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) DatabaseSecurity dbSec)
{ {
var pl = MakeParams(userAccountId, SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true)); _ = dbSec;
var dset = await getSQLDataSet_async( _logger.LogWarning("GetWidgetAsync: unknown widget id '{WidgetId}' requested by user={User}", widgetId, userAccountId);
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;", return Task.FromResult<IActionResult>(new NotFoundResult());
_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()
});
} }
private async Task<IActionResult> BuildWidgetResponse(string userAccountId, // ── Widget renderer dispatcher ────────────────────────────────────────────
DatabaseSecurity dbSec, Dictionary<string, object?> wdg) private async Task<IActionResult> BuildWidgetResponse(string userAccountId, DatabaseSecurity dbSec,
string shortName, Dictionary<string, object?> wdg)
{ {
string type = (wdg.nz("type", "") ?? "").ToLower(); string dbType = (wdg.nz("type", "") ?? "").ToLower();
string sql = wdg.nz("sql", "") ?? ""; 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, case "sql_table":
_intranet.Intranet__SQLConnectionString,
MakeParams(userAccountId), Security: dbSec);
return await JSONAsync(new
{ {
name = wdg.nz("name", ""), var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
type, widgetData = new
rows = dt.DataTable.Rows {
.Cast<System.Data.DataRow>() name,
.Select(r => r.toObjectDictionary()) description = descr,
.ToArray() 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;
} }
return await JSONAsync(wdg); 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;
}
// 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();
} }
+6 -2
View File
@@ -3,13 +3,17 @@
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <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> /// </summary>
public interface IBankingService public interface IBankingService
{ {
/// <summary>Abbreviation for a debit/credit mark.</summary> /// <summary>Abbreviation for a debit/credit mark.</summary>
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark); 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); DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
} }
-18
View File
@@ -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();
}
+2 -2
View File
@@ -13,8 +13,8 @@ public interface IInvoiceService
/// <summary>Loads an existing invoice by ID.</summary> /// <summary>Loads an existing invoice by ID.</summary>
Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec); Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Registers (creates or updates) an invoice from form data.</summary> /// <summary>Registers (creates or updates) an invoice from a parsed data object.</summary>
Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId, Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec); string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for an invoice.</summary> /// <summary>Generates a PDF document for an invoice.</summary>
+2 -2
View File
@@ -13,8 +13,8 @@ public interface IReminderService
/// <summary>Loads an existing reminder by ID.</summary> /// <summary>Loads an existing reminder by ID.</summary>
Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec); Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Registers (creates) a reminder from form data.</summary> /// <summary>Registers (creates) a reminder from a parsed data object.</summary>
Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId, Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec); string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for a reminder.</summary> /// <summary>Generates a PDF document for a reminder.</summary>
+12 -6
View File
@@ -1,15 +1,21 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OCORE.security; using OCORE.security;
using OCORE.SQL;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Abstraction for report processing. /// Abstraction for SQL-driven report processing (HTML page / fragment / PNG chart).
/// </summary> /// </summary>
public interface IReportService public interface IReportService
{ {
/// <summary>Processes a report request.</summary> /// <summary>
Task<IActionResult> ProcessRequestAsync(string action, string id, /// Processes a report request.
string userAccountId, DatabaseSecurity dbSec); /// </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);
} }
+109 -20
View File
@@ -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.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using OCORE.security; using OCORE.security;
using Newtonsoft.Json;
using OCORE.SQL; using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql; using static OCORE.SQL.sql;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Invoice service implementation. Extracts DB operations from <c>FdsInvoiceData</c> /// Invoice service — load, register, render and store invoices.
/// and PDF generation into a proper DI service. /// Replaces the controller-coupled, sync-over-async logic that previously lived
/// inside <see cref="FdsInvoiceData"/>.
/// </summary> /// </summary>
public class InvoiceService : IInvoiceService public class InvoiceService : IInvoiceService
{ {
private readonly Fuchs_intranet _intranet; private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdfService; private readonly IPdfService _pdf;
private readonly ILogger<InvoiceService> _logger; 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; _intranet = intranet;
_pdfService = pdfService; _pdf = pdf;
_logger = logger; _logger = logger;
} }
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec) public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{ {
// TODO: Complete after FdsInvoiceData is refactored to remove IntranetController dependency _logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
throw new NotImplementedException("InvoiceService.LoadInvoiceAsync pending FdsInvoiceData refactor."); 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) 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) 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) public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{ => Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
throw new NotImplementedException("InvoiceService.RenderInvoicePdfBytesAsync pending FdsInvoiceData refactor.");
}
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft, public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
string userAccountId, DatabaseSecurity dbSec) 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, public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
fds.IFdsMfr mfr)
{ {
if (invoice.InvoiceRegistration?.getItem("IsFinal", false) is true) if (invoice.InvoiceRegistration?.getItem("IsFinal", false) is true)
{ {
+8 -2
View File
@@ -1,22 +1,28 @@
using Microsoft.Extensions.Logging; using Fuchs.Observability;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Factory implementation for <see cref="fds.FdsMfrClient"/>. /// 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> /// </summary>
public class MfrClientFactory : IMfrClientFactory public class MfrClientFactory : IMfrClientFactory
{ {
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<MfrClientFactory> _logger;
public MfrClientFactory(ILoggerFactory loggerFactory) public MfrClientFactory(ILoggerFactory loggerFactory)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
} }
public fds.FdsMfrClient Create() public fds.FdsMfrClient Create()
{ {
FuchsTelemetry.MfrCalls.Add(1);
_logger.LogDebug("Creating new FdsMfrClient instance.");
return new fds.FdsMfrClient(_loggerFactory); return new fds.FdsMfrClient(_loggerFactory);
} }
} }
+59 -3
View File
@@ -1,6 +1,8 @@
using System.Net.Http.Headers; using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text; using System.Text;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -43,9 +45,13 @@ public class ProcessWebComService : IComService
public async Task<bool> SendEmailAsync(string reference, string subject, string html, public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null) 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)) if (!IsValidEmail(email))
{ {
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference); _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; return false;
} }
@@ -57,23 +63,41 @@ public class ProcessWebComService : IComService
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}", "[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email); reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]); await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
return false; return false;
} }
bool success = false; bool success = false;
string messageId = ""; string messageId = "";
var errors = new List<string>(); var errors = new List<string>();
var sw = Stopwatch.StartNew();
try 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 var payload = new
{ {
comType = "email", comType = "email",
recipient = email, recipient = email,
subject, subject,
body body,
attachments = attachmentPayload
}; };
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
reference, email, attachmentPayload.Length);
var (ok, responseBody) = await PostToApiAsync("push_com", payload); var (ok, responseBody) = await PostToApiAsync("push_com", payload);
if (ok) if (ok)
{ {
@@ -89,9 +113,18 @@ public class ProcessWebComService : IComService
catch (Exception ex) catch (Exception ex)
{ {
errors.Add("Beim Versenden ist ein Fehler aufgetreten."); errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference); _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); await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
return success; return success;
} }
@@ -122,7 +155,12 @@ public class ProcessWebComService : IComService
}; };
var (ok, responseBody) = await PostToApiAsync("push_com", payload); var (ok, responseBody) = await PostToApiAsync("push_com", payload);
if (ok) return true; 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); _logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
return false; return false;
@@ -190,6 +228,24 @@ public class ProcessWebComService : IComService
return ""; 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) private static bool IsValidEmail(string email)
{ {
try try
+129 -17
View File
@@ -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.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
using OCORE.security; using OCORE.security;
using OCORE.SQL; using OCORE.SQL;
using static OCORE.commons; using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql; using static OCORE.SQL.sql;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Reminder service implementation. Extracts DB operations from <c>FdsReminderData</c> /// Reminder service — load, register, render and store reminders.
/// into a proper DI service. /// Replaces the controller-coupled, sync-over-async logic that previously lived
/// inside <see cref="FdsReminderData"/>.
/// </summary> /// </summary>
public class ReminderService : IReminderService public class ReminderService : IReminderService
{ {
private readonly Fuchs_intranet _intranet; private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdfService; private readonly IPdfService _pdf;
private readonly ILogger<ReminderService> _logger; 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; _intranet = intranet;
_pdfService = pdfService; _pdf = pdf;
_logger = logger; _logger = logger;
} }
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec) 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) 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) 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) public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData reminder, bool draft)
{ => Task.FromResult(_pdf.DocToPdfBytes(GenerateReminderPdf(reminder, draft)));
throw new NotImplementedException("ReminderService.RenderReminderPdfBytesAsync pending FdsReminderData refactor.");
}
public async Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData reminder, bool draft, public async Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData reminder, bool draft,
string userAccountId, DatabaseSecurity dbSec) 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, public async Task<byte[]> GetReminderFileAsync(FdsReminderData reminder, bool draft,
fds.IFdsMfr mfr, string userAccountId, DatabaseSecurity dbSec) 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, public async Task<(FileInfo? file, byte[]? content)> GetStoredFileAsync(string reminderId,
string userAccountId, DatabaseSecurity dbSec) 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);
} }
} }
+4
View File
@@ -38,6 +38,10 @@
"AccountId": "", "AccountId": "",
"Token": "MANAGED_BY_KEYVAULT", "Token": "MANAGED_BY_KEYVAULT",
"Enabled": false "Enabled": false
},
"Telemetry": {
"Enabled": true,
"OtlpEndpoint": ""
} }
} }
} }
-125
View File
@@ -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
View File
@@ -1,10 +1,6 @@
using System.Data; using System.Globalization;
using Fuchs.Controllers;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using OCORE.SQL;
using static OCORE.OCORE_dictionaries; using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql; using static OCORE.SQL.sql;
using static OCORE.commons; using static OCORE.commons;
@@ -12,25 +8,26 @@ using static OCORE.commons;
namespace Fuchs.intranet; namespace Fuchs.intranet;
/// <summary> /// <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> /// </summary>
public class FdsInvoiceData public class FdsInvoiceData
{ {
private readonly JObject? _base; private readonly JObject? _base;
private Document? _letter;
public GenericObjectDictionary? Admin { get; private set; } public GenericObjectDictionary? Admin { get; private set; }
public GenericObjectDictionary? NewValues { get; private set; } public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Sms { get; private set; } public GenericObjectDictionary? Sms { get; private set; }
public List<Dictionary<string, object>>? Req { get; private set; } public List<Dictionary<string, object>>? Req { get; private set; }
public GenericObjectDictionary? InvoiceRegistration { get; private set; } public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
public bool IsDraft { get; private set; } = true; public bool IsDraft { get; internal set; } = true;
public string Id => InvoiceRegistration?.getString("Id") ?? ""; public string Id => InvoiceRegistration?.getString("Id") ?? "";
public string PaymentTerms => InvoiceRegistration?.getString("PaymentTerm") ?? ""; public string PaymentTerms => InvoiceRegistration?.getString("PaymentTerm") ?? "";
// -- PDF-facing properties (used by FuchsPdf.ApplyInvoice) ---------------- // -- PDF-facing properties (used by IPdfService.ApplyInvoice) -------------
public string InvoiceType => public string InvoiceType =>
InvoiceRegistration?.getString("InvoiceType").Substr(0, 1) ?? "R"; InvoiceRegistration?.getString("InvoiceType").Substr(0, 1) ?? "R";
public string InvoiceId => public string InvoiceId =>
@@ -56,7 +53,7 @@ public class FdsInvoiceData
{ {
IEnumerable<Dictionary<string, object?>>? itms = IEnumerable<Dictionary<string, object?>>? itms =
itmsObj as IEnumerable<Dictionary<string, object?>> itmsObj as IEnumerable<Dictionary<string, object?>>
?? (itmsObj is Newtonsoft.Json.Linq.JArray ja ?? (itmsObj is JArray ja
? ja.ToObject<List<Dictionary<string, object?>>>() ? ja.ToObject<List<Dictionary<string, object?>>>()
: null); : null);
if (itms != null) result.AddRange(itms); if (itms != null) result.AddRange(itms);
@@ -73,33 +70,33 @@ public class FdsInvoiceData
{ {
var result = new Dictionary<string, Dictionary<string, object?>>(); var result = new Dictionary<string, Dictionary<string, object?>>();
if (InvoiceRegistration == null) return result; if (InvoiceRegistration == null) return result;
// Primary VAT slot
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_1"), out decimal ust1) if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_1"), out decimal ust1)
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net1"), out decimal net1) && FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net1"), out decimal net1)
&& !(ust1 == 0 && net1 == 0)) && !(ust1 == 0 && net1 == 0))
result[ust1.ToString("0.##")] = new Dictionary<string, object?> result[ust1.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net1 };
{ ["vat_amount"] = net1 };
// Secondary VAT slot
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_2"), out decimal ust2) if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_2"), out decimal ust2)
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net2"), out decimal net2) && FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net2"), out decimal net2)
&& !(ust2 == 0 && net2 == 0)) && !(ust2 == 0 && net2 == 0))
result[ust2.ToString("0.##")] = new Dictionary<string, object?> result[ust2.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net2 };
{ ["vat_amount"] = net2 };
return result; return result;
} }
} }
// -- Raw properties -------------------------------------------------------- // -- Raw form properties (used by parameter mapping) -----------------------
private string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? ""; internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
private string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? ""; internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
private string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? ""; internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
private string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? ""; internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
private string[] RawProvisionLocation => internal string[] RawProvisionLocation =>
NewValues?.nz("provisionlocation") is { Length: > 0 } s NewValues?.nz("provisionlocation") is { Length: > 0 } s
? s.Replace("\r\n", "\n").Split('\n').Select(t => t.Trim()).Where(t => t != "").ToArray() ? s.Replace("\r\n", "\n").Split('\n').Select(t => t.Trim()).Where(t => t != "").ToArray()
: Array.Empty<string>(); : Array.Empty<string>();
// -- Ctors ----------------------------------------------------------------- // -- 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) public FdsInvoiceData(object ctd)
{ {
_base = ctd as JObject; _base = ctd as JObject;
@@ -113,157 +110,14 @@ public class FdsInvoiceData
} }
} }
public FdsInvoiceData(string id, IntranetController ctrl) => RegisterInvoice(id, ctrl); // -- Parameter mapping (consumed by InvoiceService.RegisterInvoiceAsync) ---
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
// -- PDF -------------------------------------------------------------------
public Document InvoicePDF(IntranetController ctrl)
{ {
if (_letter != null) return _letter; _ = change; _ = invId;
if (InvoiceRegistration == null) RegisterInvoice(Id, ctrl); // VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
var tb = new FuchsPdf.FdsTextBlocks // matching the legacy contract. Item-level VAT strings are German-formatted and
{ // single-rate procs only store one rate, so the highest rate wins.
AdminRef = InvoiceRegistration?.getString("Id") ?? "", var (vatRate, vatNet) = HighestVat(Sms);
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";
return new List<SqlParameter> return new List<SqlParameter>
{ {
@@ -271,8 +125,8 @@ public class FdsInvoiceData
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""), SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"), SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"), SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"), SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
SQL_VarChar("@InvoiceVAT_1", vathigh), SQL_VarChar("@InvoiceVAT_1", vatRate),
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true), SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""), SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
SQL_VarChar("@SendToAddress", RawInvoiceAddress), SQL_VarChar("@SendToAddress", RawInvoiceAddress),
@@ -281,8 +135,64 @@ public class FdsInvoiceData
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true), SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"), SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"), SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
SQL_VarChar("@InvoiceOptions", SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
}; };
} }
/// <summary>
/// Builds the InvoiceOptions CSV from the posted admin flags.
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&lt;mode&gt;</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
View File
@@ -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 Newtonsoft.Json.Linq;
using OCORE.SQL;
using static OCORE.OCORE_dictionaries; using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
using static OCORE.commons; using static OCORE.commons;
namespace Fuchs.intranet; namespace Fuchs.intranet;
/// <summary> /// <summary>
/// Encapsulates a reminder (Zahlungserinnerung) data object. /// Reminder (Zahlungserinnerung) data holder. Converted from VB fds__reminder_data.
/// Converted from VB fds__reminder_data class. /// Pure data — persistence and PDF generation live in
/// <see cref="Fuchs.Services.IReminderService"/> (no controller coupling).
/// </summary> /// </summary>
public class FdsReminderData public class FdsReminderData
{ {
private readonly JObject? _base; private readonly JObject? _base;
private Document? _letter;
public GenericObjectDictionary? NewValues { get; private set; } public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Rem { get; private set; } public GenericObjectDictionary? Rem { get; private set; }
public GenericObjectDictionary? ReminderRegistration { get; private set; } public GenericObjectDictionary? ReminderRegistration { get; internal set; }
public bool IsDraft { get; private set; } = true; public bool IsDraft { get; internal set; } = true;
public string Id => ReminderRegistration?.getString("Id") ?? ""; public string Id => ReminderRegistration?.getString("Id") ?? "";
// -- Raw props from form data --------------------------------------------- // -- 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") ? s.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n")
.Replace("\r\n", "\n").Replace("\n\n", "\n").Split('\n') .Replace("\r\n", "\n").Replace("\n\n", "\n").Split('\n')
.Select(t => System.Web.HttpUtility.HtmlDecode(t.Trim())).Where(t => t != "").ToArray() .Select(t => System.Web.HttpUtility.HtmlDecode(t.Trim())).Where(t => t != "").ToArray()
: Array.Empty<string>(); : Array.Empty<string>();
public string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? ""; internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
public string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? ""; internal string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
public string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? ""; internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
// -- Computed props from registration ------------------------------------- // -- Computed props from registration -------------------------------------
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null; public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
@@ -59,11 +52,10 @@ public class FdsReminderData
get get
{ {
if (ReminderRegistration == null) return new List<Dictionary<string, object?>>(); 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"); var raw = ReminderRegistration.getItem("invoices");
try try
{ {
if (raw is Newtonsoft.Json.Linq.JArray ja) if (raw is JArray ja)
return ja.ToObject<List<Dictionary<string, object?>>>() ?? new(); return ja.ToObject<List<Dictionary<string, object?>>>() ?? new();
if (raw is string s && s.StartsWith("[")) if (raw is string s && s.StartsWith("["))
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object?>>>(s) ?? new(); return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object?>>>(s) ?? new();
@@ -74,6 +66,10 @@ public class FdsReminderData
} }
// -------------------------------- Ctors ---------------------------------- // -------------------------------- 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) public FdsReminderData(object ctd)
{ {
if (ctd is JObject jo) { _base = jo; } 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>>()!); 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;
}
} }
+162 -21
View File
@@ -473,25 +473,26 @@ public static class FuchsPdf
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left; 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; 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(); var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto; row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base"; row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
var titleCell = row.Cells[1].AddParagraph(); var titleCell = row.Cells[1].AddParagraph();
titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title); titleCell.Style = "TblCell_RTitle";
if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"<div>{desc}</div>"); if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
row.Cells[2].AddParagraph(qty).Style = "TblCell_Base"; else titleCell.AddText(line.Title);
row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base"; if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum"; 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[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right; row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].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); ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum"); TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
// Payment terms note
string terms = TranslatePaymentTerm(inv.PaymentTerms); // ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
string ibanLine = p13b var reg = inv.InvoiceRegistration;
? "Die Steuerschuldnerschaft geht auf den Leistungsempf\u00e4nger \u00fcber (§ 13b UStG)." void Note(string text, string style = "InvoiceNotes")
: $"Bitte \u00fcberweisen Sie den Rechnungsbetrag innerhalb von {terms} auf unser Konto:\n" +
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)";
{ {
var p = sec.AddParagraph(); p.Style = "InvoiceNotes"; if (string.IsNullOrEmpty(text)) return;
p.AddText(ibanLine); 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 ───────────────────────────────────────────────────────── // ── 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" + $"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)"); "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 // Greeting
var greet = sec.AddParagraph(); greet.Style = "BodyText"; var greet = sec.AddParagraph(); greet.Style = "BodyText";
greet.Format.SpaceBefore = cm(0.8); 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) => private static string MigraDocFilenameFromByteArray(byte[] image) =>
"base64:" + Convert.ToBase64String(image); "base64:" + Convert.ToBase64String(image);
-20
View File
@@ -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();
}
}
+559
View File
@@ -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; }
}
}
-190
View File
@@ -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();
}
+183
View File
@@ -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
View File
@@ -147,7 +147,7 @@ gulp.task("copy", async () => {
const jobs = []; const jobs = [];
for (const cpy of copyconfig) { for (const cpy of copyconfig) {
jobs.push( 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); await Promise.all(jobs);
+83 -6
View File
@@ -96,6 +96,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('p13b') === true) { if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b }); 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) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); 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.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -403,7 +406,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -646,12 +649,19 @@ $inv.invSumUpdate = function () {
}; };
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
bds.each((bi, bdy) => { 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); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co); let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx); //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 (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso; rrx.SortOrder = iso;
@@ -663,7 +673,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet // 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)); 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; let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2); bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -838,6 +848,53 @@ $inv.sp13b = () => {
} }
tbl.trigger('fds.inv'); 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 = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
$ocms.dlgform(flds, { $ocms.dlgform(flds, {
@@ -857,12 +914,32 @@ $inv.sctp = () => {
}, typedvalues: true }, 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 = () => { $inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv(); $inv.t_fds_inv();
l.aC('freeze'); l.aC('freeze');
$ocms.postXT({ $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 }); $inv.cntInv({ id: response.id });
}, error: () => { }, error: () => {
alert($ict.eis); alert($ict.eis);
@@ -884,7 +961,7 @@ $inv.sprev = (change) => {
} }
} }
$ocms.postXT({ $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'); l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) { if (invtp > 10) {
@@ -40,6 +40,12 @@
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
iss: 'Zwischenstand speichern.', iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b', 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', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
+1 -1
View File
@@ -224,7 +224,7 @@ function getMonday(d) {
$.fn.rwText = function (text, addtitle, options) { $.fn.rwText = function (text, addtitle, options) {
var tgt = $(this).empty(); var tgt = $(this).empty();
options = $.extend({ wrap: true }, options); 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) { $.each(sa, function (ti, tx) {
if ((tx || '') !== '') { if ((tx || '') !== '') {
if (ti > 0) { if (ti > 0) {
+89 -6
View File
@@ -146,6 +146,12 @@ let $ict = {
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
iss: 'Zwischenstand speichern.', iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b', 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', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', 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) { if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b }); 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) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); 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.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -944,7 +953,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -1187,12 +1196,19 @@ $inv.invSumUpdate = function () {
}; };
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
bds.each((bi, bdy) => { 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); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co); let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx); //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 (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso; rrx.SortOrder = iso;
@@ -1204,7 +1220,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet // 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)); 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; let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2); bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -1379,6 +1395,53 @@ $inv.sp13b = () => {
} }
tbl.trigger('fds.inv'); 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 = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
$ocms.dlgform(flds, { $ocms.dlgform(flds, {
@@ -1398,12 +1461,32 @@ $inv.sctp = () => {
}, typedvalues: true }, 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 = () => { $inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv(); $inv.t_fds_inv();
l.aC('freeze'); l.aC('freeze');
$ocms.postXT({ $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 }); $inv.cntInv({ id: response.id });
}, error: () => { }, error: () => {
alert($ict.eis); alert($ict.eis);
@@ -1425,7 +1508,7 @@ $inv.sprev = (change) => {
} }
} }
$ocms.postXT({ $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'); l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) { if (invtp > 10) {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1317,7 +1317,7 @@ function getMonday(d) {
$.fn.rwText = function (text, addtitle, options) { $.fn.rwText = function (text, addtitle, options) {
var tgt = $(this).empty(); var tgt = $(this).empty();
options = $.extend({ wrap: true }, options); 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) { $.each(sa, function (ti, tx) {
if ((tx || '') !== '') { if ((tx || '') !== '') {
if (ti > 0) { if (ti > 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+89 -6
View File
@@ -146,6 +146,12 @@ let $ict = {
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
iss: 'Zwischenstand speichern.', iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b', 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', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', 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) { if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b }); 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) { if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload }); 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.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -925,7 +934,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort); rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */ rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp'); $inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => { }, complete: () => {
o.c.trigger('modal_close'); o.c.trigger('modal_close');
} }
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
}; };
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
bds.each((bi, bdy) => { 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); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co); let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx); //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 (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso; rrx.SortOrder = iso;
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet // 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)); 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; let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2); bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
} }
tbl.trigger('fds.inv'); 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 = () => { $inv.sctp = () => {
let flds = $invcol.ctp; let flds = $invcol.ctp;
$ocms.dlgform(flds, { $ocms.dlgform(flds, {
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
}, typedvalues: true }, 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 = () => { $inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data(); var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv(); $inv.t_fds_inv();
l.aC('freeze'); l.aC('freeze');
$ocms.postXT({ $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 }); $inv.cntInv({ id: response.id });
}, error: () => { }, error: () => {
alert($ict.eis); alert($ict.eis);
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
} }
} }
$ocms.postXT({ $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'); l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) { if (invtp > 10) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -23,9 +23,9 @@ public class FdsService : ServiceControl
new PeriodicJobDefinition("MfrSync", interval, async ct => new PeriodicJobDefinition("MfrSync", interval, async ct =>
{ {
bool debug = FdsConfig.DebugDetails; bool debug = FdsConfig.DebugDetails;
await mfr.UpdateIfNecessary_async(debug); await mfr.UpdateIfNecessary_async(debug, ct);
await mfr.UpdateRequested_async(debug); await mfr.UpdateRequested_async(debug, ct);
await mfr.GetInvoiceFiles_async(debug); await mfr.GetInvoiceFiles_async(debug, ct);
}) })
}; };
+32 -17
View File
@@ -43,15 +43,19 @@ public class FdsMfr : IFdsMfr
None = 0 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); using var mfr = new FdsMfrClient(_loggerFactory);
try try
{ {
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename: "DebugDetail.txt"); 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"); if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - completed", filename: "DebugDetail.txt");
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
FdsDebug.DebugLog("UpdateIfNecessary_async - main unn", exc: 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); using var mfr = new FdsMfrClient(_loggerFactory);
try try
{ {
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - start awaited", filename: "DebugDetail.txt"); 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"); if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - completed", filename: "DebugDetail.txt");
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
FdsDebug.DebugLog("UpdateRequested_async - main unn", exc: 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); using var mfr = new FdsMfrClient(_loggerFactory);
try try
@@ -86,35 +91,45 @@ public class FdsMfr : IFdsMfr
FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions()); FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions());
if (dtbl.Count > 0) if (dtbl.Count > 0)
{ {
foreach (DataRow ivrw in dtbl.DataTable.Rows) var rows = dtbl.DataTable.Rows.Cast<DataRow>()
{ .Select(r => (id: r.nz("id"), docName: r.nz("DocumentName"), url: r.nz("URI")))
string id = ivrw.nz("id"), docName = ivrw.nz("DocumentName"), fileurl = ivrw.nz("URI"); .Where(r => !string.IsNullOrEmpty(r.id) && !string.IsNullOrEmpty(r.docName)
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(docName) && !string.IsNullOrEmpty(fileurl) && docName.EndsWith("pdf")) && !string.IsNullOrEmpty(r.url) && r.docName.EndsWith("pdf"))
{ .ToList();
var fl = mfr.GetFile(fileurl);
if (fl != null && fl.Length > 0) 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) =>
{ {
try try
{
var fl = await mfr.GetFileAsync(r.url, throwErrorIfNotOk: false, cancellationToken: ct);
if (fl is { Length: > 0 })
{ {
await setSQLValue_async( await setSQLValue_async(
"EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;", "EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;",
FdsShared.FDSConnectionString(), FdsShared.FDSConnectionString(),
SqlParameterList: new ParamList( SqlParameterList: new ParamList(
SQL_VarChar("@Id", id), SQL_VarChar("@Id", r.id),
SQL_VarChar("@filename", docName), SQL_VarChar("@filename", r.docName),
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }), new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
options: new FdsSqlOptions()); options: new FdsSqlOptions());
Interlocked.Increment(ref downloaded);
} }
}
catch (OperationCanceledException) { throw; }
catch (Exception fsex) catch (Exception fsex)
{ {
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: 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"); if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - completed", filename: "DebugDetail.txt");
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
FdsDebug.DebugLog("GetInvoiceFiles_async - main unn", exc: ex); FdsDebug.DebugLog("GetInvoiceFiles_async - main unn", exc: ex);
+8 -2
View File
@@ -42,6 +42,9 @@ public class FdsMfrClient : IDisposable
public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) => public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) =>
_mfrClient.GetFile(address, throwErrorIfNotOk); _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) => public async Task<ODataEnvelope> ReadOData(string address, bool throwErrorIfNotOk = true) =>
await _mfrClient.ReadOData(address, throwErrorIfNotOk); await _mfrClient.ReadOData(address, throwErrorIfNotOk);
@@ -374,7 +377,8 @@ public class FdsMfrClient : IDisposable
private static string NewDatatableSql(string tablename) => private static string NewDatatableSql(string tablename) =>
$"Select TOP(0) [setid] = CAST('' as varchar(50)), * FROM [dbo].[{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) => 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")) foreach (DataRow rw in updateableTables.Select("updateneed > 0", "updateneed DESC"))
{ {
cancellationToken.ThrowIfCancellationRequested();
string etname = rw.nz("entity_name", ""); string etname = rw.nz("entity_name", "");
try try
{ {
@@ -424,7 +429,7 @@ public class FdsMfrClient : IDisposable
catch (Exception exa) { dlg("outer frame", "", "", exa); } 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) => 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")) foreach (DataRow rw in updateableRequests.Select("", "order"))
{ {
cancellationToken.ThrowIfCancellationRequested();
string etname = rw.nz("entity_name", ""); string etname = rw.nz("entity_name", "");
long tgtid = rw.nint64("Id", -1); long tgtid = rw.nint64("Id", -1);
if (tgtid > -1 && !string.IsNullOrWhiteSpace(etname)) if (tgtid > -1 && !string.IsNullOrWhiteSpace(etname))
+5
View File
@@ -26,6 +26,11 @@ public static class FdsConfig
.Build(); .Build();
} }
public static void Initialize(IConfiguration configuration)
{
_config = configuration;
}
// -- Connection strings --------------------------------------------------- // -- Connection strings ---------------------------------------------------
internal static string SQLConnectionString() => internal static string SQLConnectionString() =>
Current.GetConnectionString("fuchs_ConnectionString") Current.GetConnectionString("fuchs_ConnectionString")
+3
View File
@@ -4,6 +4,9 @@
<name>Fuchs_DataService</name> <name>Fuchs_DataService</name>
</assembly> </assembly>
<members> <members>
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
<summary>Max parallel invoice-file downloads (independent per file).</summary>
</member>
<member name="T:fds.FdsConfig"> <member name="T:fds.FdsConfig">
<summary> <summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> built from appsettings.json. Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> built from appsettings.json.
+3 -3
View File
@@ -4,9 +4,9 @@ namespace fds;
public interface IFdsMfr public interface IFdsMfr
{ {
Task UpdateIfNecessary_async(bool debugDetails = false); Task UpdateIfNecessary_async(bool debugDetails = false, CancellationToken cancellationToken = default);
Task UpdateRequested_async(bool debugDetails = false); Task UpdateRequested_async(bool debugDetails = false, CancellationToken cancellationToken = default);
Task GetInvoiceFiles_async(bool debugDetails = false); Task GetInvoiceFiles_async(bool debugDetails = false, CancellationToken cancellationToken = default);
FileInfo? GetReportDoc(ref byte[]? file, string reportid, bool debugDetails = false); FileInfo? GetReportDoc(ref byte[]? file, string reportid, bool debugDetails = false);
FileInfo? GetFdsDoc(ref byte[]? file, string reportid, string type); FileInfo? GetFdsDoc(ref byte[]? file, string reportid, string type);
FileInfo? GetDatevZip(ref Stream? stream, DateTime tgtdate, string mode, string authUser, bool includeFiles, bool debugDetails = false); FileInfo? GetDatevZip(ref Stream? stream, DateTime tgtdate, string mode, string authUser, bool includeFiles, bool debugDetails = false);
+6
View File
@@ -0,0 +1,6 @@
<Solution>
<Project Path="FuchsDatabase.sqlproj" Id="c062672e-866d-4c74-b6de-8d660a42e885">
<Build />
<Deploy />
</Project>
</Solution>
+424
View File
@@ -0,0 +1,424 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<Name>FuchsDatabase</Name>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>4.1</ProjectVersion>
<ProjectGuid>{c062672e-866d-4c74-b6de-8d660a42e885}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.Sql170DatabaseSchemaProvider</DSP>
<OutputType>Database</OutputType>
<RootPath>
</RootPath>
<RootNamespace>FuchsDatabase</RootNamespace>
<AssemblyName>FuchsDatabase</AssemblyName>
<ModelCollation>1033, CI</ModelCollation>
<DefaultFileStructure>BySchemaAndSchemaType</DefaultFileStructure>
<DeployToDatabase>True</DeployToDatabase>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<TargetLanguage>CS</TargetLanguage>
<AppDesignerFolder>Properties</AppDesignerFolder>
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<OutputPath>bin\Debug\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
<!-- Default to the v11.0 targets path if the targets file for the current VS version is not found -->
<SSDTExists Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets')">True</SSDTExists>
<VisualStudioVersion Condition="'$(SSDTExists)' == ''">11.0</VisualStudioVersion>
</PropertyGroup>
<Import Condition="'$(SQLDBExtensionsRefPath)' != ''" Project="$(SQLDBExtensionsRefPath)\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<Import Condition="'$(SQLDBExtensionsRefPath)' == ''" Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<ItemGroup>
<Folder Include="Properties" />
<Folder Include="dbo\" />
<Folder Include="dbo\Tables\" />
<Folder Include="dbo\Views\" />
<Folder Include="Security\" />
<Folder Include="dbo\Functions\" />
<Folder Include="dbo\Stored Procedures\" />
<Folder Include="dbo\User Defined Types\" />
</ItemGroup>
<ItemGroup>
<Build Include="dbo\Tables\tmp__fds__invoice_servicerequests.sql" />
<Build Include="dbo\Tables\tmp__fds__bankingtransactions.sql" />
<Build Include="dbo\Tables\mfr__xl__servicerequests.sql" />
<Build Include="dbo\Tables\mfr__users.sql" />
<Build Include="dbo\Tables\mfr__timeevents.sql" />
<Build Include="dbo\Tables\mfr__tags.sql" />
<Build Include="dbo\Tables\mfr__stockmovements.sql" />
<Build Include="dbo\Tables\mfr__steps.sql" />
<Build Include="dbo\Tables\mfr__steplisttemplates.sql" />
<Build Include="dbo\Tables\mfr__steplisttemplateinstances.sql" />
<Build Include="dbo\Tables\mfr__servicerequests.sql" />
<Build Include="dbo\Tables\mfr__serviceobjects.sql" />
<Build Include="dbo\Tables\mfr__reports.sql" />
<Build Include="dbo\Tables\mfr__qualifications.sql" />
<Build Include="dbo\Tables\mfr__products.sql" />
<Build Include="dbo\Tables\mfr__itemunits.sql" />
<Build Include="dbo\Tables\mfr__itemtypes.sql" />
<Build Include="dbo\Tables\mfr__items.sql" />
<Build Include="dbo\Tables\mfr__invoices.sql" />
<Build Include="dbo\Tables\mfr__documents.sql" />
<Build Include="dbo\Tables\mfr__d_timeevents.sql" />
<Build Include="dbo\Tables\mfr__d_stockmovements.sql" />
<Build Include="dbo\Tables\mfr__d_steps.sql" />
<Build Include="dbo\Tables\mfr__d_items.sql" />
<Build Include="dbo\Tables\mfr__d_comments.sql" />
<Build Include="dbo\Tables\mfr__d_appointments.sql" />
<Build Include="dbo\Tables\mfr__d__PartnerSet.sql" />
<Build Include="dbo\Tables\mfr__d_#locations.sql" />
<Build Include="dbo\Tables\mfr__d_#customvalues.sql" />
<Build Include="dbo\Tables\mfr__costcenters.sql" />
<Build Include="dbo\Tables\mfr__contacts.sql" />
<Build Include="dbo\Tables\mfr__companies.sql" />
<Build Include="dbo\Tables\mfr__comments.sql" />
<Build Include="dbo\Tables\mfr__attachments.sql" />
<Build Include="dbo\Tables\mfr__appointments.sql" />
<Build Include="dbo\Tables\mfr___PartnerSet.sql" />
<Build Include="dbo\Tables\mfr__#locations.sql" />
<Build Include="dbo\Tables\mfr__#customvalues.sql" />
<Build Include="dbo\Tables\fuchs_planner_traffic.sql" />
<Build Include="dbo\Tables\fuchs_planner_options.sql" />
<Build Include="dbo\Tables\fuchs_planner_groups.sql" />
<Build Include="dbo\Tables\fuchs_planner.sql" />
<Build Include="dbo\Tables\fuchs_debug.sql" />
<Build Include="dbo\Tables\fis_widgets_cache.sql" />
<Build Include="dbo\Tables\fis_widgets.sql" />
<Build Include="dbo\Tables\fis_widget_subscriptions.sql" />
<Build Include="dbo\Tables\fis_usergroups_members.sql" />
<Build Include="dbo\Tables\fis_usergroups.sql" />
<Build Include="dbo\Tables\fis_useraccounts_settings.sql" />
<Build Include="dbo\Tables\fis_useraccounts.sql" />
<Build Include="dbo\Tables\fis_module_auth.sql" />
<Build Include="dbo\Tables\fis_admin_debuglog.sql" />
<Build Include="dbo\Tables\fds__status.sql" />
<Build Include="dbo\Tables\fds__removed.sql" />
<Build Include="dbo\Tables\fds__reminder.sql" />
<Build Include="dbo\Tables\fds__payments.sql" />
<Build Include="dbo\Tables\fds__mfr_updaterequests.sql" />
<Build Include="dbo\Tables\fds__mfr_invoicefiles.sql" />
<Build Include="dbo\Tables\fds__log.sql" />
<Build Include="dbo\Tables\fds__invoices.sql" />
<Build Include="dbo\Tables\fds__invoice_servicerequests.sql" />
<Build Include="dbo\Tables\fds__invoice_items.sql" />
<Build Include="dbo\Tables\fds__invoice_details.sql" />
<Build Include="dbo\Tables\fds__emaillog.sql" />
<Build Include="dbo\Tables\fds__debuglog.sql" />
<Build Include="dbo\Tables\fds__custom_vat.sql" />
<Build Include="dbo\Tables\fds__custom_servicerequest.sql" />
<Build Include="dbo\Tables\fds__custom_invoiceinfo.sql" />
<Build Include="dbo\Tables\fds__bankingtransactions_settings.sql" />
<Build Include="dbo\Tables\fds__bankingtransactions_assigns.sql" />
<Build Include="dbo\Tables\fds__bankingtransactions.sql" />
<Build Include="dbo\Tables\fds__admin_settings.sql" />
<Build Include="dbo\Tables\fds__admin_reportcatalog.sql" />
<Build Include="dbo\Tables\fds__admin_dberrors.sql" />
<Build Include="dbo\Tables\fds__admin_activity.sql" />
<Build Include="dbo\Views\ocms_fn_rand.sql" />
<Build Include="Security\fuchs_rwe.sql" />
<Build Include="Security\fds_rwe.sql" />
<Build Include="Security\fuchs_web.sql" />
<Build Include="Security\fuchs_fds.sql" />
<Build Include="dbo\Functions\vat_val.sql" />
<Build Include="dbo\Functions\strings_replaceSpecialCharsInHTML.sql" />
<Build Include="dbo\Functions\strings_removeLeading0_255.sql" />
<Build Include="dbo\Functions\strings_removeLeading0.sql" />
<Build Include="dbo\Functions\strings_encodeHTML.sql" />
<Build Include="dbo\Functions\ott_remove_csv.sql" />
<Build Include="dbo\Functions\ott_random_int.sql" />
<Build Include="dbo\Functions\ott_quote_255.sql" />
<Build Include="dbo\Functions\ott_quote.sql" />
<Build Include="dbo\Functions\ott_min_float.sql" />
<Build Include="dbo\Functions\ott_min_date.sql" />
<Build Include="dbo\Functions\ott_min.sql" />
<Build Include="dbo\Functions\ott_merge_csv.sql" />
<Build Include="dbo\Functions\ott_max_float.sql" />
<Build Include="dbo\Functions\ott_max.sql" />
<Build Include="dbo\Functions\ott_jcsv_containskey.sql" />
<Build Include="dbo\Functions\ott_jcsv_contains.sql" />
<Build Include="dbo\Functions\ott_jcontent.sql" />
<Build Include="dbo\Functions\ott_csv_contains.sql" />
<Build Include="dbo\Functions\ott_b26.sql" />
<Build Include="dbo\Functions\ott_b10.sql" />
<Build Include="dbo\Functions\net_val.sql" />
<Build Include="dbo\Functions\ne.sql" />
<Build Include="dbo\Functions\mfr_fn_dateSent_fromNotes.sql" />
<Build Include="dbo\Functions\mfr__schema.sql" />
<Build Include="dbo\Functions\fis_getModuleAuth.sql" />
<Build Include="dbo\Functions\fis_fn_useraccount_id.sql" />
<Build Include="dbo\Functions\fis_fn_id.sql" />
<Build Include="dbo\Functions\fis_admin_testGroupMembership.sql" />
<Build Include="dbo\Functions\fis_admin_getUserSetting.sql" />
<Build Include="dbo\Functions\fis_admin_getUserName_rev_byID.sql" />
<Build Include="dbo\Functions\fis_admin_getUserName_rev.sql" />
<Build Include="dbo\Functions\fis_admin_getUserName_byID.sql" />
<Build Include="dbo\Functions\fis_admin_getUserName.sql" />
<Build Include="dbo\Functions\fis_admin_getUserEmail_byID.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAuth.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAddressForm.sql" />
<Build Include="dbo\Functions\fis_admin_checkUserExists.sql" />
<Build Include="dbo\Functions\fds__updateNeed.sql" />
<Build Include="dbo\Functions\fds__r_getBalanceThisMonth.sql" />
<Build Include="dbo\Functions\fds__newInvoiceId.sql" />
<Build Include="dbo\Functions\fds__getCompanyNameAddress.sql" />
<Build Include="dbo\Functions\fds__getCompanyName.sql" />
<Build Include="dbo\Functions\fds__getCompanyEmail.sql" />
<Build Include="dbo\Functions\fds__getCompanyAddress.sql" />
<Build Include="dbo\Functions\fds__fn_vatfrombalances.sql" />
<Build Include="dbo\Functions\fds__fn_ReminderDocumentName.sql" />
<Build Include="dbo\Functions\fds__fn_reminder_id.sql" />
<Build Include="dbo\Functions\fds__fn_isInvoiceCancelled.sql" />
<Build Include="dbo\Functions\fds__fn_invoice-srq_id.sql" />
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount_full.sql" />
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" />
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_id.sql" />
<Build Include="dbo\Functions\fds__fn_invoice_customerid.sql" />
<Build Include="dbo\Functions\fds__fn_IntermediateIsAllocatedToOther.sql" />
<Build Include="dbo\Functions\fds__fn_DocumentName.sql" />
<Build Include="dbo\Functions\fds__fn_creanupTransactionInfo.sql" />
<Build Include="dbo\Functions\fds__fn_combineAddress.sql" />
<Build Include="dbo\Functions\fds__fn_bankingtransaction_id.sql" />
<Build Include="dbo\Functions\date_weekfirst.sql" />
<Build Include="dbo\Functions\date_weekend.sql" />
<Build Include="dbo\Functions\date_nextFirst.sql" />
<Build Include="dbo\Functions\date_monthfirst.sql" />
<Build Include="dbo\Functions\date_monthend.sql" />
<Build Include="dbo\Functions\date_addcustom.sql" />
<Build Include="dbo\Functions\date_add.sql" />
<Build Include="dbo\Functions\bo_val.sql" />
<Build Include="dbo\Functions\backup__fds__r_getBalanceThisMonth.sql" />
<Build Include="dbo\Functions\backup__fds__fn_InvoiceIdByName.sql" />
<Build Include="dbo\Functions\backup__fds__fn_invoice_customerid.sql" />
<Build Include="dbo\Functions\AddBusinessDays.sql" />
<Build Include="dbo\Functions\string_SplitString_ordered.sql" />
<Build Include="dbo\Functions\ott_20_randomStrings.sql" />
<Build Include="dbo\Functions\json_diffObjectArrays.sql" />
<Build Include="dbo\Functions\json_compareObjectArrays.sql" />
<Build Include="dbo\Functions\fis_getONEPersonWidgets.sql" />
<Build Include="dbo\Functions\fis_admin_getUserGroupList.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAccountList.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAccount_Namelist.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAccount_byemail.sql" />
<Build Include="dbo\Functions\fis_admin_getUserAccount.sql" />
<Build Include="dbo\Functions\fis_admin_authenticate_byID.sql" />
<Build Include="dbo\Functions\fis_admin_authenticate.sql" />
<Build Include="dbo\Functions\fds__getUpdateableTables.sql" />
<Build Include="dbo\Functions\fds__getUpdateableRequests.sql" />
<Build Include="dbo\Functions\fds__fn_bankingtransactions_perInvoice.sql" />
<Build Include="dbo\Functions\fds__fn_bankingtransactions.sql" />
<Build Include="dbo\Functions\backup__fds__fn_bankingtransactions.sql" />
<Build Include="dbo\Functions\fis_getModuleAuthList.sql" />
<Build Include="dbo\Functions\fds__getRequestTreeIds.sql" />
<Build Include="dbo\Functions\fds__getInvoiceTreeIds.sql" />
<Build Include="dbo\Functions\fds__getInvoiceCredits.sql" />
<Build Include="dbo\Functions\fds__getCompanysContacts.sql" />
<Build Include="dbo\Functions\fds__getCompanyContacts.sql" />
<Build Include="dbo\Functions\fds__fn_unpaidInvoices.sql" />
<Build Include="dbo\Functions\fds__fn_requestsforinvoice.sql" />
<Build Include="dbo\Functions\fds__admin_reminderSettings.sql" />
<Build Include="dbo\Functions\backup__fds__getInvoiceTreeIds.sql" />
<Build Include="dbo\Functions\backup__fds__fn_unpaidInvoices.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__users.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__timeevents.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__tags.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__stockmovements.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__steps.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__steplisttemplates.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__steplisttemplateinstances.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__servicerequests.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__serviceobjects.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__reports.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__qualifications.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__products.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__itemunits.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__itemtypes.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__items.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__invoices.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__documents.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__costcenters.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__contacts.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__companies.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__comments.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__attachments.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__appointments.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt___PartnerSet.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__#locations.sql" />
<Build Include="dbo\Stored Procedures\mfr__updt__#customvalues.sql" />
<Build Include="dbo\Stored Procedures\mfr__getSchema.sql" />
<Build Include="dbo\Stored Procedures\fuchs_planner_submit.sql" />
<Build Include="dbo\Stored Procedures\fuchs_planner_getSummary.sql" />
<Build Include="dbo\Stored Procedures\fuchs_planner_getPrev.sql" />
<Build Include="dbo\Stored Procedures\fuchs_planner_getNext.sql" />
<Build Include="dbo\Stored Procedures\fis_widgets_setCache.sql" />
<Build Include="dbo\Stored Procedures\fis_widgets_getCache.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_setUserGroup.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_setUserAccount_activeStatus.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_setUserAccount.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_setNewPassword.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_resetUserPassword.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_remGroupMember.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_delUserGroup.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_delUserAccount.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_debug.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_createUserAccount.sql" />
<Build Include="dbo\Stored Procedures\fis_admin_addGroupMember.sql" />
<Build Include="dbo\Stored Procedures\fds__toggleRequestHidden.sql" />
<Build Include="dbo\Stored Procedures\fds__setStatus.sql" />
<Build Include="dbo\Stored Procedures\fds__setReportVAT.sql" />
<Build Include="dbo\Stored Procedures\fds__setReminderSent.sql" />
<Build Include="dbo\Stored Procedures\fds__setReminderFinal.sql" />
<Build Include="dbo\Stored Procedures\fds__setReminderFile.sql" />
<Build Include="dbo\Stored Procedures\fds__setMFRInvoiceFile.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoiceUNPayed.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoiceSent.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoicePaymentStatus_auto_single.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoicePaymentStatus_auto.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoicePayed.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoiceFinal.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoiceFile.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoiceCancelled__deprecated.sql" />
<Build Include="dbo\Stored Procedures\fds__setInvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__setBankingtransaction_done.sql" />
<Build Include="dbo\Stored Procedures\fds__setBankingtransaction_autoAssigns.sql" />
<Build Include="dbo\Stored Procedures\fds__setBankingtransaction_assignToIvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__remInvoice_ServiceRequests.sql" />
<Build Include="dbo\Stored Procedures\fds__remInvoice_Items.sql" />
<Build Include="dbo\Stored Procedures\fds__remInvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__r_getBalanceTrendByYear.sql" />
<Build Include="dbo\Stored Procedures\fds__r_getBalanceTrendByMonth.sql" />
<Build Include="dbo\Stored Procedures\fds__r_getBalanceByYearTopMaterial.sql" />
<Build Include="dbo\Stored Procedures\fds__r_getBalanceByYearTopCustomer.sql" />
<Build Include="dbo\Stored Procedures\fds__r_getBalanceByMonth.sql" />
<Build Include="dbo\Stored Procedures\fds__prepStorno_recreate.sql" />
<Build Include="dbo\Stored Procedures\fds__prepReminder.sql" />
<Build Include="dbo\Stored Procedures\fds__prepInvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__merge_bankingtransactions.sql" />
<Build Include="dbo\Stored Procedures\fds__maint__updateCancelledStatus.sql" />
<Build Include="dbo\Stored Procedures\fds__lookupReminders.sql" />
<Build Include="dbo\Stored Procedures\fds__logEmail.sql" />
<Build Include="dbo\Stored Procedures\fds__getRequests_list2.sql" />
<Build Include="dbo\Stored Procedures\fds__getRequests_list.sql" />
<Build Include="dbo\Stored Procedures\fds__getRequest_details.sql" />
<Build Include="dbo\Stored Procedures\fds__getReportDocument.sql" />
<Build Include="dbo\Stored Procedures\fds__getReminder.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvRequestItems.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvPayments.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvoices_list2.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvoices_list_vario.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvoices_list.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvoiceReminder.sql" />
<Build Include="dbo\Stored Procedures\fds__getInvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__getFDSDocument.sql" />
<Build Include="dbo\Stored Procedures\fds__getDatevExports.sql" />
<Build Include="dbo\Stored Procedures\fds__getBankingtransfers_questionable.sql" />
<Build Include="dbo\Stored Procedures\fds__getBankingtransfers_list2.sql" />
<Build Include="dbo\Stored Procedures\fds__getBankingtransfers_list.sql" />
<Build Include="dbo\Stored Procedures\fds__fn_getMFRInvoicesWithoutfiles.sql" />
<Build Include="dbo\Stored Procedures\fds__createStorno_simple.sql" />
<Build Include="dbo\Stored Procedures\fds__createStorno_copy.sql" />
<Build Include="dbo\Stored Procedures\fds__createReminder.sql" />
<Build Include="dbo\Stored Procedures\fds__createInvoice_ServiceRequest.sql" />
<Build Include="dbo\Stored Procedures\fds__createInvoice_Details.sql" />
<Build Include="dbo\Stored Procedures\fds__createInvoice.sql" />
<Build Include="dbo\Stored Procedures\fds__createCredit_simple.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_updateconflicttables.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_removeconflicttables.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_logdebug.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_getReportCatalog.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__setInvoicePayed.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__setBankingtransaction_autoAssigns.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__r_getBalanceTrendByYear.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__r_getBalanceTrendByMonth.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__r_getBalanceByYearTopMaterial.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__r_getBalanceByYearTopCustomer.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__r_getBalanceByMonth.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__prepStorno_recreate.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__prepReminder.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__lookupReminders.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getRequests_list2.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getRequests_list.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getRequest_details.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getReportDocument.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getReminder.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getInvRequestItems.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getInvoices_list2.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getInvoices_list_vario.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getInvoiceReminder.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getDatevExports.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getBankingtransfers_questionable.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__getBankingtransfers.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__createStorno_simple.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__createReminder.sql" />
<Build Include="dbo\Stored Procedures\backup__fds__createCredit_simple.sql" />
<Build Include="dbo\Stored Procedures\_BackupAndClearInvoiceFile.sql" />
<Build Include="dbo\User Defined Types\json_data_maxU.sql" />
<Build Include="dbo\User Defined Types\hash_256.sql" />
<Build Include="dbo\User Defined Types\datetime_utc.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__users.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__timeevents.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__tags.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__stockmovements.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__steps.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__steplisttemplates.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__steplisttemplateinstances.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__servicerequests.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__serviceobjects.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__reports.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__qualifications.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__products.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__itemunits.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__itemtypes.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__items.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__invoices.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__documents.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__costcenters.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__contacts.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__companies.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__comments.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__attachments.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__appointments.sql" />
<Build Include="dbo\User Defined Types\mfr__tt___PartnerSet.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__#locations.sql" />
<Build Include="dbo\User Defined Types\mfr__tt__#customvalues.sql" />
<Build Include="dbo\User Defined Types\fds__tt__reminder_core.sql" />
<Build Include="dbo\User Defined Types\fds__tt__mfr_steps.sql" />
<Build Include="dbo\User Defined Types\fds__tt__mfr_items.sql" />
<Build Include="dbo\User Defined Types\fds__tt__invoice_servicerequests.sql" />
<Build Include="dbo\User Defined Types\fds__tt__invoice_items.sql" />
<Build Include="dbo\User Defined Types\fds__tt__invoice_core.sql" />
<Build Include="dbo\User Defined Types\fds__tt__invoice_base.sql" />
<Build Include="dbo\User Defined Types\fds__tt__idlist_vchar.sql" />
<Build Include="dbo\User Defined Types\fds__tt__idlist.sql" />
<Build Include="dbo\User Defined Types\fds__tt__bankingtransactions.sql" />
<Build Include="dbo\User Defined Types\fds__tt__admin_ReportAdminTable.sql" />
<Build Include="Security\fuchs_enc_1.sql" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
CREATE ROLE [fds_rwe]
AUTHORIZATION [dbo];
GO
ALTER ROLE [fds_rwe] ADD MEMBER [fuchs_fds];
+15
View File
@@ -0,0 +1,15 @@
CREATE SYMMETRIC KEY [fuchs_enc_1]
AUTHORIZATION [dbo]
WITH ALGORITHM = AES_256
ENCRYPTION BY PASSWORD = N'lwppdri?q{jdzus&bkuaheklmsFT7_&#$!~<dhle,zJ,q3th';
GO
GRANT CONTROL
ON SYMMETRIC KEY::[fuchs_enc_1] TO [fds_rwe];
GO
GRANT CONTROL
ON SYMMETRIC KEY::[fuchs_enc_1] TO [fuchs_rwe];
+2
View File
@@ -0,0 +1,2 @@
CREATE USER [fuchs_fds] FOR LOGIN [fuchs_fds];
+7
View File
@@ -0,0 +1,7 @@
CREATE ROLE [fuchs_rwe]
AUTHORIZATION [dbo];
GO
ALTER ROLE [fuchs_rwe] ADD MEMBER [fuchs_web];
+2
View File
@@ -0,0 +1,2 @@
CREATE USER [fuchs_web] FOR LOGIN [fuchs_web];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
CREATE FUNCTION[dbo].[AddBusinessDays](@Date DATE,@n INT)
RETURNS DATE AS
BEGIN
DECLARE @d INT,@f INT,@DW INT;
SET @f=CAST(abs(1^SIGN(DATEPART(DW, @Date)-(7-@@DATEFIRST))) AS BIT)
SET @DW=DATEPART(DW,@Date)-(7-@@DATEFIRST)*(@f^1)+@@DATEFIRST*(@f&1)
SET @d=4-SIGN(@n)*(4-@DW);
RETURN DATEADD(D,@n+((ABS(@n)+(@d%(8+SIGN(@n)))-2)/5)*2*SIGN(@n)-@d/7,@Date);
END
@@ -0,0 +1,27 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__fn_InvoiceIdByName]
(
@nme varchar(255)
)
RETURNS varchar(20)
AS
BEGIN
DECLARE @Id varchar(20);
SET @nme = TRIM((SELECT TOP(1) [value] FROM string_split(@nme, '(')));
SET @Id = ISNULL(
(SELECT TOP(1) [Id] FROM (
SELECT TOP(1) [Id], [fds] = 1 FROM [dbo].[fds__invoices] WHERE [InvoiceId] = @nme
UNION
SELECT TOP(1) try_Cast([Id] as varchar(20)), [fds] = 0 FROM [dbo].[mfr__invoices] WHERE [InvoiceId] = @nme
)z ORDER BY [fds] DESC
),'');
RETURN @id;
END
@@ -0,0 +1,83 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__fn_bankingtransactions]
(
@startdate date = NULL
,@enddate date = NULL
,@invoice_startdate date = NULL
,@invoice_enddate date = NULL
,@invoice_idlist [dbo].[fds__tt__idlist_vchar] READONLY
)
RETURNS @bankingtransactions TABLE
(
[uid] bigint
,[AccountIdentification] varchar(50)
,[ValueDate] date
,[Amount] numeric(9,2)
,[AccountNumberOfPayer] varchar(30)
,[NameOfPayer] nvarchar(60)
,[SepaRemittanceInformation] varchar(150)
,[EndToEndReference] varchar(50)
,[manu] bit
,[InvId] varchar(15)
,[done_manually] bit
,[fds] bit
)
AS
BEGIN
-- Fill the table variable with the rows for your result set
DECLARE @firstever date = '1900-01-01', @tomorrow date = DATEADD(dAy, 1, GETDATE());
SELECT @invoice_startdate = ISNULL(@invoice_startdate, @firstever), @invoice_enddate = ISNULL(@invoice_enddate, @tomorrow);
DECLARE @idlistempty bit = IIF(EXISTS (SELECT 0 FROM @invoice_idlist) ,0,1);
with bt as (
SELECT [uid],[AccountIdentification],[ValueDate],[Amount],AccountNumberOfPayer,NameOfPayer, SepaRemittanceInformation, EndToEndReference
FROM [dbo].[fds__bankingtransactions] as bt_
where (@startdate is null OR bt_.ValueDate >= @startdate)
AND (@enddate is null or bt_.ValueDate <= @enddate)
), bs0manu as (
SELECT bs.[banking_uid], [invid] = spl.[value], [manu] = cast(1 as bit)
FROM [dbo].[fds__bankingtransactions_settings] as bs
CROSS APPLY STRING_SPLIT(bs.assigned_invoice_id, ',') as spl
JOIN bt on bt.[uid] = bs.[banking_uid]
WHERE bs.assigned_invoice_id is not null
), bs1 as (
SELECT * FROM bs0manu
UNION
SELECT bs.[banking_uid], [invid] = spl.[value], [manu] = cast(0 as bit)
FROM [dbo].[fds__bankingtransactions_settings] as bs
CROSS APPLY STRING_SPLIT(bs.auto_invoice_id, ',') as spl
JOIN bt on bt.[uid] = bs.[banking_uid]
WHERE bs.auto_invoice_id is not null
and bt.[uid] NOT IN (SELECT [banking_uid] FROM bs0manu)
), bs as (
SELECT banking_uid, [invid], manu = CAST(MAX(CAST([manu] as int)) as bit)
from bs1
WHERE @idlistempty = 1 OR bs1.[invid] in (SELECT [id] FROM @invoice_idlist)
GROUP BY banking_uid, [invid]
)
INSERT INTO @bankingtransactions
SELECT
bt.[uid]
,[AccountIdentification]
, bt.[ValueDate]
, [Amount]
,bt.AccountNumberOfPayer
,bt.NameOfPayer
,bt.SepaRemittanceInformation
,bt.EndToEndReference
,[manu]
,[invid]
,[done_manually] = IIF(ISNULL(bs2.[done_manually],'') <> '', 1,0)
,[fds] = IIF(mi.id is null, 1, 0)
FROM bt
JOIN bs ON bt.[uid] = bs.[banking_uid]
LEFT JOIN [dbo].[fds__bankingtransactions_settings] as bs2 ON bt.[uid] = bs2.[banking_uid]
LEFT JOIN [dbo].[fds__invoices] as fi on bs.[invid] = fi.[id] AND fi.[DateFinalized] BETWEEN @invoice_startdate AND @invoice_enddate
LEFT JOIN [dbo].[mfr__invoices] as mi on try_cast(bs.[invid] as bigint) = mi.[id] AND mi.[DateOfCreation] BETWEEN @invoice_startdate AND @invoice_enddate;
RETURN
END
@@ -0,0 +1,26 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__fn_invoice_customerid]
(
@InvId varchar(15)
)
RETURNS bigint
AS
BEGIN
DECLARE @ret bigint;
SELECT TOP(1) @ret = [customerid] FROM dbo.[fds__invoices] WHERE [id] = @InvId;
IF @ret is null
SELECT TOP(1) @ret = [customerid] FROM (SELECT TOP(1) [invid] = p.[PartnerId], s.[CustomerId], cy.[supportmail], cy.[Name]
FROM dbo.[mfr__*PartnerSet] as p
JOIN [dbo].[mfr__servicerequests] as s on p.[EntityId] = s.[Id] AND p.[Property] = 'ServiceRequest:Invoices'
JOIN [dbo].[mfr__companies] as cy on s.[CustomerId] = cy.id
WHERE p.PartnerId = TRY_CAST(@invId as bigint))z;
RETURN @ret;
END
@@ -0,0 +1,48 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__fn_unpaidInvoices]
(
)
RETURNS TABLE
AS
RETURN
(
WITH inv as (
SELECT [Id] = cast([id] as varchar(15))
,[Invoiceid]
,[DateFinalized]
,[InvoiceBalance]
,[DocumentName]
,[file]
,[InvoiceTitle]
,[customerid]
,[dateSent]
,[fds] = CAST(1 as bit)
FROM [dbo].[fds__invoices] as iv
WHERE [isfinal] = 1 and CAST(CASE WHEN ISNULL(iv.[IsCanceled],0) = 1 THEN 1 ELSE ISNULL([IsPayed],0) END as bit) = 0
and InvoiceBalance > 0
UNION
SELECT [Id] = cast([id] as varchar(15))
,[Invoiceid]
,[DateFinalized] = [DateOfCreation]
,[InvoiceBalance]
,[DocumentName]
,[file] = NULL
,[InvoiceTitle] = ''
,[customerid] = [dbo].[fds__fn_invoice_customerid](mfri.[id])
,[datesent] = NULL
,[fds] = CAST(0 as bit)
FROM [dbo].[mfr__invoices] as mfri
LEFT JOIN [dbo].[fds__custom_invoiceinfo] as ivi on mfri.[id] = ivi.[invid]
WHERE [invoicestate] in ('eIsSent','eIsOpen')
AND ISNULL(ivi.isPayed,0) = 0
AND not [invoiceId] like 'preview'
and InvoiceBalance > 0
)
SELECT * /*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */
FROM inv
)
@@ -0,0 +1,33 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__getInvoiceTreeIds]
(
@invid varchar(20)
)
RETURNS TABLE
AS
RETURN
(
with mfr_inv as (
SELECT [type] = 'invoice', [id] = [id] FROM [dbo].[mfr__invoices] as _mfri where _mfri.[id] = TRY_CAST(@invid as bigint)
), mfr_srq as (
SELECT DISTINCT [type] = 'servicerequest', [id] = mfrs.[id] FROM mfr_inv
JOIN [dbo].[mfr__*PartnerSet] as ps on Property = 'Invoice:SourceServiceRequest' and ps.EntityId = mfr_inv.[id]
JOIN [dbo].[mfr__servicerequests] as mfrs on ps.[PartnerId] = mfrs.id
), fds_srq as (
SELECT distinct [type] = 'servicerequest', [id] = mfrs.[id]
FROM [dbo].[fds__invoices] as _fi
JOIN [dbo].[fds__invoice_servicerequests] as _fs on _fi.Id = _fs.[InvId] and _fs.[mfr__servicerequest] is not null
JOIN [dbo].[mfr__servicerequests] as mfrs on _fs.[mfr__servicerequest] = mfrs.id
WHERE _fi.[Id] = @invid
)
SELECT * FROM mfr_inv
union
SELECT * FROM mfr_srq
union
SELECT * FROM fds_srq
)
@@ -0,0 +1,42 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[backup__fds__r_getBalanceThisMonth](
@authuser varchar(100)
)
RETURNS varchar(25)
BEGIN
DECLARE @today date = GETDATE();
DECLARE @tomorrow date = DATEADD(DAY,1,@today);
DECLARE @thismonthfirst date = [dbo].[date_monthfirst](@today);
DECLARE @ret varchar(25) = ''
IF [dbo].[fis_getModuleAuth]('fds_reports', @authuser) < 2
SET @ret = '';
ELSE
BEGIN
WITH inv as (
SELECT
i.[Dateofcreation]
,i.[InvoiceBalance]
,i.[InvoiceBalanceNetto]
FROM [dbo].[mfr__invoices] as i
where --i.[invoicestate] in('eIsSent','eIsPaid','eIsOpen') and
i.[FileType] in( 'PdfInvoice','PdfCancelInvoice','PdfPartialInvoice') and ISNULL(InvoiceId,'') not in ('Preview','')
AND [dbo].[date_monthfirst](i.[Dateofcreation]) = @thismonthfirst
UNION
SELECT
[Dateofcreation] = i.[DateFinalized]
,i.[InvoiceBalance]
,[InvoiceBalanceNetto] = i.[InvoiceBalance_net]
FROM [dbo].[fds__invoices] as i
WHERE i.[isFinal] = 1 AND [dbo].[date_monthfirst](i.[DateFinalized]) = @thismonthfirst
)
SELECT TOP(1) @ret = FORMAT( SUM( ISNULL(inv.[InvoiceBalanceNetto],0) ) * 0.001, '#,0 k€', 'de') FROM inv;
END
RETURN @ret;
END

Some files were not shown because too many files have changed in this diff Show More