Compare commits

..

14 Commits

Author SHA1 Message Date
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
428 changed files with 28091 additions and 1280 deletions
+36
View File
@@ -1,5 +1,13 @@
# Copilot Instructions
> ## ⚠️ Instruction Sync
> This file (`.github/copilot-instructions.md`) and the Claude Code instructions
> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**.
> Whenever you change one, make the equivalent change in the other in the same
> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared
> project facts (architecture, coding standards, configuration, libraries,
> secrets, observability) must match.
## Project Overview
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- 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.
- For builds failing due to SixLabors.ImageSharp requiring a license (v4.0.0+), check copilot-instructions.md for the SixLabors license key/handling info before downgrading ImageSharp.
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
## MFR ERP integration
- `MFR_RESTClient` talks to the **mfr (Mobile Field Report)** ERP over REST/OData. Its contract (base URLs, auth, OData conventions, pagination, error/retry, deep-create + document-upload) is documented in **`MFR_RESTClient/Docs/mfr_interface_description.md`** — **read it before changing the client**.
- The client uses HTTP Basic auth, a configurable timeout, and retries idempotent GETs on transient errors (429/5xx, network/timeout) with backoff. Create clients via `IMfrClientFactory` (don't `new` them). The legacy VB project files have been removed; the active project is `MFR_RESTClient.csproj`.
## Database
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
## Bank statement parsing (MT940 + CAMT)
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
- `BankingService.ParseToDatatable` **auto-detects** the format (XML → CAMT, else MT940) and maps both into the `fds__tt__bankingtransactions` schema. The `bam/up` handler and the frontend file picker accept both (`.sta/.mt940/.txt` and `.xml/.camt`).
- `CAMTParser` is namespace-agnostic (matches elements by local name) so it handles every camt schema version. Keep both parsers' column mappings aligned when changing the banking schema.
## Observability
- Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`).
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages.
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
## Testing
- xUnit in `Fuchs.Tests`. For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`). DB-bound paths that can't be unit-tested should at least have their pure logic covered.
## Azure Key Vault — Secret Naming
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
+7
View File
@@ -7,6 +7,13 @@
bin/
obj/
# SSDT / SQL database project caches (regenerated)
*.dbmdl
*.jfm
# Generated XML documentation output (regenerated on build)
MFR_RESTClient/MFR_RESTClient.xml
# NuGet
packages/
*.nupkg
+12
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.
+19 -14
View File
@@ -1,17 +1,20 @@
using System.Data;
using System.Data;
using System.IO;
using System.Text;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using programmersdigest.MT940Parser;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Banking helper robustness tests.
/// Banking service robustness tests.
/// </summary>
public class BankingDebitCreditMarkTests
{
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
[Theory]
[InlineData(DebitCreditMark.Credit, "C")]
[InlineData(DebitCreditMark.Debit, "D")]
@@ -19,18 +22,20 @@ public class BankingDebitCreditMarkTests
[InlineData(DebitCreditMark.ReverseDebit, "RD")]
public void DebitCreditMarkAbb_ReturnsExpected(DebitCreditMark mark, string expected)
{
Assert.Equal(expected, Banking.DebitCreditMarkAbb(mark));
Assert.Equal(expected, Svc.DebitCreditMarkAbb(mark));
}
[Fact]
public void DebitCreditMarkAbb_UndefinedValue_ReturnsEmpty()
{
Assert.Equal("", Banking.DebitCreditMarkAbb((DebitCreditMark)999));
Assert.Equal("", Svc.DebitCreditMarkAbb((DebitCreditMark)999));
}
}
public class BankingParseToDatatableTests
{
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
private static readonly string MinimalMT940 =
"\r\n:20:STARTUMSE\r\n" +
":25:DE12345678901234567890\r\n" +
@@ -48,7 +53,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_ReturnsOneRow()
{
using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.Equal(1, table.Rows.Count);
}
@@ -56,7 +61,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasAccountColumn()
{
using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.True(table.Columns.Contains("AccountIdentification"));
Assert.Equal("DE12345678901234567890", table.Rows[0]["AccountIdentification"]);
}
@@ -65,7 +70,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasAmountColumn()
{
using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.True(table.Columns.Contains("Amount"));
Assert.Equal(500m, table.Rows[0]["Amount"]);
}
@@ -74,7 +79,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_ValidMT940_HasDebitCreditMark()
{
using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.Equal("C", table.Rows[0]["DebitCreditMark"]);
}
@@ -82,7 +87,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_EmptyStream_ReturnsEmptyTable()
{
using var stream = ToStream("");
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.Equal(0, table.Rows.Count);
}
@@ -90,7 +95,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_EmptyStream_HasDefaultSchema()
{
using var stream = ToStream("");
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.True(table.Columns.Contains("AccountIdentification"));
Assert.True(table.Columns.Contains("Amount"));
Assert.True(table.Columns.Contains("DebitCreditMark"));
@@ -104,7 +109,7 @@ public class BankingParseToDatatableTests
schema.Columns.Add("Amount", typeof(decimal));
using var stream = ToStream(MinimalMT940);
var table = Banking.ParseToDatatable(stream, schemaDatatable: schema);
var table = Svc.ParseToDatatable(stream, schemaDatatable: schema);
Assert.Equal(2, table.Columns.Count);
}
@@ -113,7 +118,7 @@ public class BankingParseToDatatableTests
{
var multi = MinimalMT940 + "\n" + MinimalMT940;
using var stream = ToStream(multi);
var table = Banking.ParseToDatatable(stream);
var table = Svc.ParseToDatatable(stream);
Assert.Equal(2, table.Rows.Count);
}
@@ -121,7 +126,7 @@ public class BankingParseToDatatableTests
public void ParseToDatatable_MalformedContent_DoesNotThrow()
{
using var stream = ToStream("This is not MT940 data at all");
var ex = Record.Exception(() => Banking.ParseToDatatable(stream));
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
Assert.Null(ex);
}
}
+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"));
}
}
+1
View File
@@ -27,6 +27,7 @@
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</ItemGroup>
</Project>
+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)
{
_logger.LogDebug("Do_Process_Bankings action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "auth":
return await JSONAsync(new { manage = 1 });
case "up":
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
Request.Form.Files.Count, UserAccountID);
foreach (var fle in Request.Form.Files)
{
using var stream = fle.OpenReadStream();
@@ -29,7 +32,7 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString,
Security: DbSec, options: SqlOpt(fn, id, code))).DataTable;
var tbl = Banking.ParseToDatatable(stream, schemaDt);
var tbl = _banking.ParseToDatatable(stream, schemaDt);
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
@@ -50,6 +53,8 @@ public partial class IntranetController
dtwa.OnCommandAfterError += (_, exc) =>
_intranet.debug_log("IntranetController.bam.up - command-after exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
tbl.Rows.Count, UserAccountID);
dtwa.DoSubmit();
}
return Ok();
@@ -76,11 +81,13 @@ public partial class IntranetController
string mode = Form("mode").ToLower();
if (mode == "s" && Form("tgt").Contains(':'))
{
// Search mode: @tgtdate is unused by the proc but required by its signature.
var pl = StdParamlist(
SQL_VarChar("@mode", Form("mode")),
SQL_VarChar("@search", Form("tgt")));
SQL_Date("@tgtdate", DBNull.Value),
SQL_VarChar("@mode", Form("mode").ne("m")),
SQL_VarChar("@search", Form("tgt")));
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getBankingtransactions_list2] @mode, @search, @authuser;",
"EXECUTE [dbo].[fds__getBankingtransfers_list2] @tgtdate, @mode, @search, @authuser;",
_intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "bank" },
Security: DbSec, options: SqlOpt(fn, id, code));
@@ -146,7 +146,7 @@ public partial class IntranetController
return BadRequest400();
}
_logger.LogInformation("mfrrel: resetting MFR relation for invoice {InvoiceId}, user={User}", relId, UserAccountID);
using (var mfr = new fds.FdsMfrClient())
using (var mfr = _mfrFactory.Create())
await mfr.Update__entitytable(EntityTypes.Invoice,
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
return Ok();
@@ -22,7 +22,7 @@ public partial class IntranetController
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = new fds.FdsMfrClient())
using (var mfr = _mfrFactory.Create())
{
_logger.LogDebug("HandleInvoicePget resetting invoice entity tgtid={TgtId}", tgtid);
await mfr.Update__entitytable(EntityTypes.Invoice,
@@ -50,7 +50,7 @@ public partial class IntranetController
}
}
_logger.LogDebug("HandleInvoicePget resetting {InvCount} invoices and {SrqCount} service requests", invIds.Count, srqIds.Count);
using var mfr2 = new fds.FdsMfrClient();
using var mfr2 = _mfrFactory.Create();
foreach (var iid in invIds)
await mfr2.Update__entitytable(EntityTypes.Invoice, fds.FdsMfr.UpdateNeed.Reset, new[] { iid });
foreach (var iid in srqIds)
@@ -413,9 +413,9 @@ public partial class IntranetController
return ldic;
}
private static async Task<string[]> BuildPdfImageArray(byte[] content)
private async Task<string[]> BuildPdfImageArray(byte[] content)
{
var imgcol = await FuchsPdf.BytesToImageCollection(content);
var imgcol = await _pdf.BytesToImageCollectionAsync(content);
return imgcol.ImgB64Array;
}
}
@@ -17,6 +17,7 @@ public partial class IntranetController
{
private async Task<IActionResult> Do_Process_Reminder(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Reminder action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "get":
@@ -38,11 +39,11 @@ public partial class IntranetController
{
if (!HasForm("remc")) return BadRequest400();
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
var fdRem = new FdsReminderData(ctd);
fdRem.RegisterReminder(this, change: false, remId: "");
var fdRem = await _reminders.RegisterReminderAsync(
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdRem.Id))
{
var imgcol = await FuchsPdf.DocToImageCollection(fdRem.ReminderPDF(this));
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" });
@@ -64,12 +65,11 @@ public partial class IntranetController
case "rdoc":
{
if (!HasForm("id")) return BadRequest400();
byte[]? fc = null;
var file = FdsReminderData.GetStoredFile(ref fc, Form("id"), this);
if (file == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
return Form("typ") != "img"
? await FileContentResultAsync(fc!, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc!) });
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
}
case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -107,8 +107,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
string remId = frdic["Id"]?.ToString() ?? "";
var fdRem = new FdsReminderData(remId, this);
byte[] filebyte = await fdRem.StoreReminderDocumentFile(this);
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
byte[] filebyte = await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec);
string email = frdic.nz("SendToEmail", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{
@@ -118,6 +118,8 @@ public partial class IntranetController
frdic.no("InvoiceFile", null!) is byte[] 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(
$"inv_{remId}",
$"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)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
var fdRem = new FdsReminderData(Form("id"), this);
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdRem.Id)) return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" });
string filename = fdRem.ReminderRegistration.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
if (Form("typ") != "img")
{
byte[] ct = Form("create", "0") != "1"
? (await fdRem.GetReminderFile(this)) is { Length: > 0 } f1 ? f1 : await fdRem.StoreReminderDocumentFile(this)
: FuchsPdf.DocToPdfBytes(fdRem.ReminderPDF(this));
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
}
var imgcol = await FuchsPdf.DocToImageCollection(fdRem.ReminderPDF(this));
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
@@ -13,6 +13,7 @@ public partial class IntranetController
{
private async Task<IActionResult> Do_Process_Reports(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Reports action={Action} code={Code} user={User}", id, code, UserAccountID);
switch (id.ToLower())
{
case "auth":
@@ -46,7 +47,7 @@ public partial class IntranetController
}
default:
return await FuchsReports.ProcessFdsRequest(this, id.ToLower(), code);
return await _reports.ProcessRequestAsync(id.ToLower(), code, UserAccountID, DbSec, RequestParamsDict());
}
}
}
@@ -19,6 +19,7 @@ public partial class IntranetController
{
private async Task<IActionResult> Do_Process_Requests(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Requests action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "auth":
@@ -45,8 +46,9 @@ public partial class IntranetController
case "save":
{
if (!HasForm("invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
fdInv.RegisterInvoice(this, change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"));
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
return !string.IsNullOrEmpty(fdInv.Id)
? await JSONAsync(new { id = fdInv.Id })
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" });
@@ -55,11 +57,12 @@ public partial class IntranetController
case "sprep":
{
if (!HasForm("invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
fdInv.RegisterInvoice(this, change: false, invId: "");
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: false, invId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
{
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
@@ -68,11 +71,12 @@ public partial class IntranetController
case "sedit":
{
if (!HasForm("id", "invc")) return BadRequest400();
var fdInv = new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!);
fdInv.RegisterInvoice(this, change: true, invId: Form("id"));
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: true, invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
{
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
@@ -171,7 +175,7 @@ public partial class IntranetController
[EntityHelper.EntityName(EntityTypes.ServiceRequest)] =
new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest)
};
using var mfr = new fds.FdsMfrClient();
using var mfr = _mfrFactory.Create();
await mfr.Update__entitytable(EntityTypes.ServiceRequest,
fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic);
return Ok();
@@ -258,8 +262,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
string invId = frdic["Id"]?.ToString() ?? "";
var fdInv = new FdsInvoiceData(invId, this);
byte[] filebyte = await fdInv.StoreInvoiceDocumentFile(this);
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
byte[] filebyte = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
var dtset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
@@ -274,6 +278,8 @@ public partial class IntranetController
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
string body = BuildInvoiceBody(bal, terms);
_logger.LogInformation("Request sconf: emailing finalized invoice {InvId} to {Email} user={User}",
invId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
body, email.Trim(), "", inv);
@@ -293,19 +299,19 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
var fdInv = new FdsInvoiceData(Form("id"), this);
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdInv.Id)) return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" });
string filename = fdInv.InvoiceRegistration.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
if (Form("typ") != "img")
{
byte[]? ct = Form("create", "0") != "1"
? await fdInv.GetInvoiceFile(this) is { Length: > 0 } f1 ? f1 : await fdInv.StoreInvoiceDocumentFile(this)
: FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this));
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return ct != null
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
}
var imgcol = await FuchsPdf.DocToImageCollection(fdInv.InvoicePDF(this));
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
@@ -322,8 +328,8 @@ public partial class IntranetController
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
string invId = frdic["Id"]?.ToString() ?? "";
var fdInv = new FdsInvoiceData(invId, this);
byte[] filebyte = FuchsPdf.DocToPdfBytes(fdInv.InvoicePDF(this));
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
string email = frdic.nz("SendToEmail", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{
+76 -6
View File
@@ -26,6 +26,13 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
internal readonly fds.IFdsMfr _mfr;
private readonly ILogger<IntranetController> _logger;
private readonly IComService _comService;
private readonly IBankingService _banking;
private readonly IPdfService _pdf;
private readonly IMfrClientFactory _mfrFactory;
private readonly IWidgetService _widgets;
private readonly IReportService _reports;
private readonly IInvoiceService _invoices;
private readonly IReminderService _reminders;
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new()
{
@@ -41,12 +48,40 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
public string UserAccountID => UserIdent.UserAccountId;
public string AuthAccount => UserIdent.Email;
public IntranetController(Fuchs_intranet intranet, fds.IFdsMfr mfr, ILogger<IntranetController> logger, 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;
_mfr = mfr;
_logger = logger;
_comService = comService;
_banking = banking;
_pdf = pdf;
_mfrFactory = mfrFactory;
_widgets = widgets;
_reports = reports;
_invoices = invoices;
_reminders = reminders;
}
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
internal Dictionary<string, string> RequestParamsDict()
{
var prms = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in Request.Query) prms[kv.Key] = kv.Value.ToString();
if (Request.HasFormContentType)
foreach (var kv in Request.Form) prms[kv.Key] = kv.Value.ToString();
return prms;
}
// ── Standard param list (pre-populates @authuser) ────────────────────────
@@ -106,7 +141,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IActionResult? result = fn.ToLower() switch
{
"ping" => Ok(),
"wdg" => await FuchsWidgets.IntranetWdg(this, id),
"wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request),
"todos" => new PhysicalFileResult(
Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"),
"text/html"),
@@ -317,11 +352,38 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_logger.LogWarning("HandleAccount changepassword: missing required fields for user={User}", UserAccountID);
return BadRequest400();
}
if (npw != npwc)
{
_logger.LogWarning("HandleAccount changepassword: password confirmation mismatch for user={User}", UserAccountID);
return StatusCode(409, new { error = "match" });
}
if (!npw.ValidatePassword(minLength: 6, numSpecial: 0))
{
_logger.LogWarning("HandleAccount changepassword: new password does not meet requirements for user={User}", UserAccountID);
return StatusCode(409, new { error = "requirements" });
}
if (!OCORE.security.TFA.validateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR", totpCode).isVerifiedInTime)
{
_logger.LogWarning("HandleAccount changepassword: TOTP verification failed for user={User}", UserAccountID);
return StatusCode(409, new { error = "sms" });
}
// Verify the supplied current password actually belongs to this user before changing it
string oldPw = Request.Form["opw"].ToString();
var authDt = await getSQLDatatable_async(
"SELECT TOP(1) * FROM [dbo].[fis_admin_authenticate_byID](@useraccount_id, @password);",
_intranet.Intranet__SQLConnectionString,
new List<SqlParameter>
{
SQL_VarChar("@useraccount_id", UserAccountID),
SQL_VarChar("@password", oldPw)
},
Security: DbSec, options: SqlOpt(fn, id, code));
var authRow = authDt.FirstRow.toObjectDictionary();
if (!string.IsNullOrEmpty(UserAccountID) && authRow.nz("useraccount_id") != UserAccountID)
{
_logger.LogWarning("HandleAccount changepassword: current password verification failed for user={User}", UserAccountID);
return StatusCode(409, new { error = "valid" });
}
_logger.LogInformation("Changing password for user={User}", UserAccountID);
await setSQLValue_async(
"EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;",
@@ -329,7 +391,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
new List<SqlParameter>
{
SQL_VarChar("@useraccount_id", UserAccountID),
SQL_VarChar("@oldpassword", Request.Form["opw"].ToString()),
SQL_VarChar("@oldpassword", oldPw),
SQL_VarChar("@newpassword", npw)
},
Security: DbSec, options: SqlOpt(fn, id, code));
@@ -344,11 +406,19 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogDebug("HandleMfr id={Id} code={Code} user={User} auth={Auth}",
id, code, UserAccountID, UserIdent.Authorization);
if (string.IsNullOrEmpty(id))
{
// Empty id → return the OData EDMX schema ($metadata), matching legacy fds.getSchema()
using var mfrSchema = _mfrFactory.Create();
string schema = await mfrSchema.ReadAnything(
mfrSchema.ClientConfig.BaseUrl + "$metadata", throwErrorIfNotOk: false);
return Content(schema, "text/xml", System.Text.Encoding.UTF8);
}
if (!string.IsNullOrEmpty(UserAccountID) && UserIdent.Authorization > 3)
{
string path = id + (!string.IsNullOrEmpty(code) ? "/" + code : HttpUtility.UrlDecode(Request.QueryString.Value ?? ""));
_logger.LogDebug("HandleMfr reading OData path={Path} user={User}", path, UserAccountID);
using var mfrRead = new fds.FdsMfrClient();
using var mfrRead = _mfrFactory.Create();
var result = await mfrRead.ReadOData(path, throwErrorIfNotOk: false);
_logger.LogDebug("HandleMfr OData read complete for path={Path} user={User}", path, UserAccountID);
return Content(JsonConvert.SerializeObject(result), "application/json");
@@ -367,7 +437,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
if (et != EntityTypes.none && string.IsNullOrEmpty(Request.Form["need"]))
{
_logger.LogInformation("MfrUpdate entity={EntityType} need=Short user={User}", et, UserAccountID);
using var mfrSingle = new fds.FdsMfrClient();
using var mfrSingle = _mfrFactory.Create();
await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short);
_logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et);
return Ok();
@@ -376,7 +446,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
var need = fds.FdsMfr.UpdateNeedValue(needParam);
_logger.LogInformation("MfrUpdate entity={EntityType} need={Need} user={User}", et, need, UserAccountID);
using var mfr = new fds.FdsMfrClient();
using var mfr = _mfrFactory.Create();
await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false);
_logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need);
return Ok();
+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_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system |
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
| **OCORE_web** | Class Library (shared) | Web utilities: MVC helpers, middleware, auth, captcha |
| **OCORE_web_pdf** | Class Library (shared) | PDF generation (MigraDoc/PDFsharp, HTML→PDF) |
| **OCORE_Charting** | Class Library (shared) | Data visualization / charting (ported System.Windows.Forms.DataVisualization) |
| **MT940Parser** | Class Library (shared) | SWIFT MT940/MT942 bank statement parser |
| **MT940Parser** | Class Library (external) | SWIFT MT940/MT942 bank statement parser |
| **CAMTParser** | Class Library (in-repo) | ISO 20022 CAMT (camt.052/053/054) bank statement parser |
**All projects target `net10.0`.**
@@ -163,8 +165,12 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
### 4.2 Singleton Configuration Object
`Fuchs_intranet` is a manually-managed singleton (via `FuchsOcmsIntranet`) initialized at startup with `IConfiguration`. It holds connection strings, app settings, auth helpers, and DB connection factory methods.
### 4.3 Static Business Logic Classes
Most business logic lives in **static classes** (`FuchsPdf`, `FuchsWidgets`, `FuchsReports`, `FuchsFdsEmail`, `Banking`) that receive the controller instance or `Fuchs_intranet` as a parameter. This is a legacy pattern from the VB.NET conversion.
### 4.3 Service Layer (Dependency Injection)
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
@@ -177,9 +183,17 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
---
## 5. DI Service Extraction Candidates
## 5. Service Layer (implemented)
The following static classes and tightly-coupled code sections are strong candidates for refactoring into proper DI-registered services. This improves testability, decouples dependencies, and aligns with ASP.NET Core best practices.
> **Status: DONE.** The services below are implemented and DI-registered in
> `Program.cs`. The original extraction rationale is retained for reference /
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
> (backed by the ported `FuchsVisualization` engine), `FdsMfrClient` →
> `IMfrClientFactory`.
---
@@ -391,8 +405,35 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
## 7. Additional Observations
1. **`System.Configuration.ConfigurationManager` usage** in `FuchsFdsEmail.cs` directly violates the project's coding standards (`appsettings.json` only).
2. **No dependency injection in `FdsInvoiceData`/`FdsReminderData`** — these classes receive the entire controller, creating circular-style dependencies.
3. **`FdsMfrClient` is `new`-ed directly** in controller partials (e.g., `IntranetController.Invoices2.cs`) instead of being injected.
4. **`OCORE_Charting`** is in the solution but not directly referenced by any project — verify if it's still needed.
5. **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
1. **Resolved** — email/SMS moved off `ConfigurationManager` into `IComService` (ProcessWeb Mailer API).
2. **Resolved**`FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
3. **Resolved**`FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
4. **Resolved**`OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
5.**Open** **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
---
## 8. Observability (OpenTelemetry)
- Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs`: one `ActivitySource` and one `Meter` (`Fuchs.Intranet`).
- **Metrics** — counters (`fuchs.invoices.rendered`, `fuchs.reminders.rendered`, `fuchs.reports.rendered`, `fuchs.emails.sent`/`.failed`, `fuchs.sms.sent`, `fuchs.banking.mt940.rows`, `fuchs.mfr.calls`) and duration histograms (`fuchs.pdf.render.duration`, `fuchs.report.render.duration`, `fuchs.email.send.duration`).
- **Tracing** — ASP.NET Core, HttpClient and SqlClient instrumentation plus the app `ActivitySource`; services start spans for their key operations.
- Configured in `Program.cs`. Always collected in-process; **OTLP export is opt-in** via `Fuchs:Telemetry:OtlpEndpoint` (and can be disabled with `Fuchs:Telemetry:Enabled=false`), so a missing collector never affects the app.
- All services + handlers log entry/result/timing/errors via `ILogger<T>` with structured placeholders.
## 9. Bank Statement Parsing (MT940 + CAMT)
- `BankingService` (`IBankingService`) accepts **both** MT940 (SWIFT text, via the external `MT940Parser`) and **CAMT** (ISO 20022 camt.052/053/054 XML, via the in-repo `CAMTParser`).
- `ParseToDatatable` **auto-detects** the format from content (XML → CAMT, else MT940) and maps either into the `fds__tt__bankingtransactions` schema; the `bam/up` handler and the frontend upload accept both.
- `CAMTParser` matches elements by **local name** (namespace-agnostic) so it works across every camt schema version. When the banking schema changes, keep the MT940 and CAMT column mappings in `BankingService` aligned.
## 10. MFR ERP Integration
- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its integration contract — base URLs, auth, OData conventions, pagination, error/retry semantics, deep-create and document-upload endpoints — is documented in **`MFR_RESTClient/Docs/mfr_interface_description.md`**. Consult that file before changing the client.
- The client uses HTTP Basic auth, a configurable timeout/user-agent, and **retries idempotent GETs** on transient failures (HTTP 429/5xx, network/timeout) with exponential backoff + jitter (honouring `Retry-After`). `ReadODataAllPages` follows `@odata.nextLink` pagination.
- `Fuchs_DataService` (Topshelf worker) polls MFR on a timer and syncs entities + invoice files; the web app creates clients via `IMfrClientFactory`.
## 11. Database
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
+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>
<NoWarn>CA1416</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Expose internal members (e.g. FdsInvoiceData.BuildInvoiceParams) to the test project -->
<InternalsVisibleTo Include="Fuchs.Tests" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
@@ -17,6 +21,7 @@
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
@@ -29,6 +34,12 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
+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);
}
+45
View File
@@ -1,5 +1,6 @@
using Fuchs.intranet;
using Fuchs.Logging;
using Fuchs.Observability;
using OCORE_web.Secrets;
using Fuchs.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using OCORE.security;
using OCORE.web;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Fuchs;
@@ -75,6 +79,47 @@ public class Program
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
builder.Services.AddHttpClient("ProcessWebMailer");
builder.Services.AddScoped<IComService, ProcessWebComService>();
// Business services (DI migration — replaces the static helper / Active-Record pattern)
builder.Services.AddSingleton<IBankingService, BankingService>(); // stateless parser
builder.Services.AddSingleton<IPdfService, FuchsPdfService>(); // stateless renderer (sets license)
builder.Services.AddSingleton<IMfrClientFactory, MfrClientFactory>();
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IReminderService, ReminderService>();
// ── OpenTelemetry: tracing + metrics ─────────────────────────────────
// Instrumentation is always collected; OTLP export is enabled only when
// an endpoint is configured (Fuchs:Telemetry:OtlpEndpoint), so a missing
// collector never impacts the app. Disable entirely with Enabled=false.
bool telemetryEnabled = builder.Configuration.GetValue("Fuchs:Telemetry:Enabled", true);
string? otlpEndpoint = builder.Configuration["Fuchs:Telemetry:OtlpEndpoint"];
if (telemetryEnabled)
{
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
serviceName: FuchsTelemetry.ServiceName,
serviceVersion: FuchsTelemetry.ServiceVersion))
.WithTracing(t =>
{
t.AddSource(FuchsTelemetry.ServiceName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation();
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
t.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
})
.WithMetrics(m =>
{
m.AddMeter(FuchsTelemetry.ServiceName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
m.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
});
}
}
private static void ConfigureApp(WebApplication app)
+150 -69
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 programmersdigest.MT940Parser;
namespace Fuchs.Services;
/// <summary>
/// MT940 bank statement parsing service. Replaces the static <c>Banking</c> class.
/// Bank statement parsing service. Accepts both MT940 (SWIFT text) and
/// CAMT (ISO 20022 camt.052/053/054 XML) and maps either into the same
/// banking-transactions DataTable. The format is auto-detected from content.
/// </summary>
public class BankingService : IBankingService
{
@@ -18,21 +23,56 @@ public class BankingService : IBankingService
public string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
{
DebitCreditMark.Credit => "C",
DebitCreditMark.Debit => "D",
DebitCreditMark.Credit => "C",
DebitCreditMark.Debit => "D",
DebitCreditMark.ReverseCredit => "RC",
DebitCreditMark.ReverseDebit => "RD",
_ => ""
DebitCreditMark.ReverseDebit => "RD",
_ => ""
};
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
{
using var act = FuchsTelemetry.StartActivity("banking.parse");
var sw = Stopwatch.StartNew();
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
// Buffer once so we can sniff the format and (re)parse from the bytes.
byte[] bytes;
using (var ms = new MemoryStream())
{
stream.CopyTo(ms);
bytes = ms.ToArray();
}
string format;
if (CamtParser.LooksLikeXml(bytes))
{
format = "camt";
FillFromCamt(tbl, bytes);
}
else
{
format = "mt940";
using var msMt = new MemoryStream(bytes);
FillFromMt940(tbl, msMt);
}
tbl.AcceptChanges();
sw.Stop();
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format));
act?.SetTag("fuchs.banking.format", format);
act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
_logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms",
format, tbl.Rows.Count, sw.ElapsedMilliseconds);
return tbl;
}
// ── MT940 ─────────────────────────────────────────────────────────────────
private void FillFromMt940(DataTable tbl, Stream stream)
{
void SetNfo(DataRow nr, string key, object? value)
{
if (tbl.Columns.Contains(key) && value != null)
nr[key] = value;
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
using var ps = new Parser(stream: stream);
@@ -47,89 +87,130 @@ public class BankingService : IBankingService
{
var nr = tbl.NewRow();
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
SetNfo(nr, "BankReference", line.BankReference);
var info = line.InformationToOwner;
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
SetNfo(nr, "CreditorReference", info.CreditorReference);
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
SetNfo(nr, "CustomerReference", info.CustomerReference);
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
SetNfo(nr, "JournalNumber", info.JournalNumber);
SetNfo(nr, "MandateReference", info.MandateReference);
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
SetNfo(nr, "PostingText", info.PostingText);
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
SetNfo(nr, "CreditorReference", info.CreditorReference);
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
SetNfo(nr, "CustomerReference", info.CustomerReference);
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
SetNfo(nr, "JournalNumber", info.JournalNumber);
SetNfo(nr, "MandateReference", info.MandateReference);
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
SetNfo(nr, "PostingText", info.PostingText);
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
SetNfo(nr, "TransactionCode", info.TransactionCode);
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
SetNfo(nr, "UnstructuredRemittanceInformation", info.UnstructuredRemittanceInformation);
SetNfo(nr, "TransactionCode", info.TransactionCode);
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails);
SetNfo(nr, "TransactionTypeIdCode", line.TransactionTypeIdCode);
SetNfo(nr, "ValueDate", line.ValueDate);
SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
SetNfo(nr, "ValueDate", line.ValueDate);
tbl.Rows.Add(nr);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
}
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
}
}
}
catch (Exception ex)
catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
}
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
private void FillFromCamt(DataTable tbl, byte[] bytes)
{
void SetNfo(DataRow nr, string key, object? value)
{
_logger.LogError(ex, "MT940 statement parse failed.");
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
tbl.AcceptChanges();
return tbl;
try
{
var statements = new CamtParser().Parse(bytes);
foreach (var stmt in statements)
{
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
foreach (var e in stmt.Entries)
{
try
{
var nr = tbl.NewRow();
SetNfo(nr, "AccountIdentification", stmt.AccountIdentification);
if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate);
SetNfo(nr, "FundsCode", e.Currency);
SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
SetNfo(nr, "BankReference", e.BankReference);
SetNfo(nr, "EndToEndReference", e.EndToEndReference);
SetNfo(nr, "MandateReference", e.MandateReference);
SetNfo(nr, "CustomerReference", e.CustomerReference);
SetNfo(nr, "CreditorReference", e.CreditorReference);
SetNfo(nr, "AccountNumberOfPayer", e.CounterpartyIban);
SetNfo(nr, "BankCodeOfPayer", e.CounterpartyBic);
SetNfo(nr, "NameOfPayer", e.CounterpartyName);
SetNfo(nr, "PostingText", e.AdditionalInfo);
SetNfo(nr, "TransactionTypeIdCode",e.BankTransactionCode);
SetNfo(nr, "SepaRemittanceInformation",
string.IsNullOrEmpty(e.RemittanceUnstructured) ? e.RemittanceStructured : e.RemittanceUnstructured);
SetNfo(nr, "UnstructuredRemittanceInformation", e.RemittanceUnstructured);
SetNfo(nr, "UnstructuredData", e.RemittanceUnstructured);
SetNfo(nr, "IsUnstructuredData", e.IsUnstructuredData);
tbl.Rows.Add(nr);
}
catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); }
}
}
}
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
}
private static DataTable BuildDefaultSchema()
{
var t = new DataTable();
var cols = t.Columns;
cols.Add("AccountIdentification", typeof(string));
cols.Add("Amount", typeof(decimal));
cols.Add("BankReference", typeof(string));
cols.Add("EntryDate", typeof(DateTime));
cols.Add("FundsCode", typeof(string));
cols.Add("AccountNumberOfPayer", typeof(string));
cols.Add("BankCodeOfPayer", typeof(string));
cols.Add("CompensationAmount", typeof(string));
cols.Add("CreditorReference", typeof(string));
cols.Add("CreditorsReferenceParty", typeof(string));
cols.Add("CustomerReference", typeof(string));
cols.Add("EndToEndReference", typeof(string));
cols.Add("JournalNumber", typeof(string));
cols.Add("MandateReference", typeof(string));
cols.Add("NameOfPayer", typeof(string));
cols.Add("OriginalAmount", typeof(string));
cols.Add("OriginatorsIdentificationCode", typeof(string));
cols.Add("PayersReferenceParty", typeof(string));
cols.Add("PostingText", typeof(string));
cols.Add("SepaRemittanceInformation", typeof(string));
cols.Add("TextKeyAddition", typeof(int));
cols.Add("TransactionCode", typeof(int));
cols.Add("IsUnstructuredData", typeof(bool));
cols.Add("UnstructuredData", typeof(string));
cols.Add("AccountIdentification", typeof(string));
cols.Add("Amount", typeof(decimal));
cols.Add("BankReference", typeof(string));
cols.Add("EntryDate", typeof(DateTime));
cols.Add("FundsCode", typeof(string));
cols.Add("AccountNumberOfPayer", typeof(string));
cols.Add("BankCodeOfPayer", typeof(string));
cols.Add("CompensationAmount", typeof(string));
cols.Add("CreditorReference", typeof(string));
cols.Add("CreditorsReferenceParty", typeof(string));
cols.Add("CustomerReference", typeof(string));
cols.Add("EndToEndReference", typeof(string));
cols.Add("JournalNumber", typeof(string));
cols.Add("MandateReference", typeof(string));
cols.Add("NameOfPayer", typeof(string));
cols.Add("OriginalAmount", typeof(string));
cols.Add("OriginatorsIdentificationCode", typeof(string));
cols.Add("PayersReferenceParty", typeof(string));
cols.Add("PostingText", typeof(string));
cols.Add("SepaRemittanceInformation", typeof(string));
cols.Add("TextKeyAddition", typeof(int));
cols.Add("TransactionCode", typeof(int));
cols.Add("IsUnstructuredData", typeof(bool));
cols.Add("UnstructuredData", typeof(string));
cols.Add("UnstructuredRemittanceInformation", typeof(string));
cols.Add("DebitCreditMark", typeof(string));
cols.Add("SupplementaryDetails", typeof(string));
cols.Add("TransactionTypeIdCode", typeof(string));
cols.Add("ValueDate", typeof(DateTime));
cols.Add("DebitCreditMark", typeof(string));
cols.Add("SupplementaryDetails", typeof(string));
cols.Add("TransactionTypeIdCode", typeof(string));
cols.Add("ValueDate", typeof(DateTime));
return t;
}
}
+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 MigraDoc.DocumentObjectModel;
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
/// <summary>
/// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods
/// while providing a DI-friendly injectable wrapper.
/// while providing a DI-friendly injectable wrapper, structured logging and
/// render-duration metrics.
/// </summary>
public class FuchsPdfService : IPdfService
{
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
{
_logger = logger;
FuchsPdf.SetLicense();
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
}
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
{
_logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
}
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false)
{
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
}
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsReminderData reminder, bool draft = false)
{
_logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, draft);
}
public byte[] DocToPdfBytes(Document doc) => FuchsPdf.DocToPdfBytes(doc);
public byte[] DocToPdfBytes(Document doc)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.render");
try
{
byte[] bytes = FuchsPdf.DocToPdfBytes(doc);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "pdf"));
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
_logger.LogDebug("DocToPdfBytes rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
return bytes;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "DocToPdfBytes failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
public Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc) =>
FuchsPdf.DocToImageCollection(doc);
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.render.images");
try
{
var col = await FuchsPdf.DocToImageCollection(doc);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "images"));
act?.SetTag("fuchs.pdf.pages", col.TotalPages);
_logger.LogDebug("DocToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
return col;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "DocToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
public Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes) =>
FuchsPdf.BytesToImageCollection(pdfBytes);
public async Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.bytes.images");
try
{
var col = await FuchsPdf.BytesToImageCollection(pdfBytes);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "bytes-images"));
_logger.LogDebug("BytesToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
return col;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "BytesToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
}
+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 OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Report service implementation. Replaces the static <c>FuchsReports</c> class.
/// Report processing for the Fuchs intranet — SQL-driven reports from the
/// fds__ report catalog, rendered as HTML pages, HTML fragments, or PNG charts
/// via <see cref="FuchsVisualization"/>. Replaces the static <c>FuchsReports</c>.
/// </summary>
public class FuchsReportService : IReportService
{
private const int DefaultReloadSeconds = 60 * 10;
private readonly Fuchs_intranet _intranet;
private readonly ILogger<FuchsReportService> _logger;
public FuchsReportService(ILogger<FuchsReportService> logger)
public FuchsReportService(Fuchs_intranet intranet, ILogger<FuchsReportService> logger)
{
_intranet = intranet;
_logger = logger;
}
public Task<IActionResult> ProcessRequestAsync(string action, string id,
string userAccountId, DatabaseSecurity dbSec)
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters)
{
// Specific report actions are dispatched here.
// Extend with additional cases as needed.
return Task.FromResult<IActionResult>(new OkResult());
var prms = new Dictionary<string, string>(parameters, StringComparer.OrdinalIgnoreCase)
{
["@authuser"] = userAccountId
};
string tgt = (string.IsNullOrEmpty(fnc)
? (prms.TryGetValue("fnc", out var f) ? f : fnc)
: fnc).Replace("gct", "generic_content");
string report = prms.TryGetValue("report", out var r) && !string.IsNullOrEmpty(r)
? r
: (!string.IsNullOrEmpty(reportId) ? reportId : "");
string templatePath = Path.Combine(AppContext.BaseDirectory, "Content", "FDS_Template.html");
// Report configuration (refresh interval + cache flag) from the catalog.
int ciRefresh = -2;
bool ciCache = false;
try
{
var catalog = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
Conn,
new List<SqlParameter> { SQL_VarChar("@report_name", report), SQL_VarChar("@authuser", userAccountId) },
Security: dbSec, options: new FIS_SQLOptions());
var cfg = catalog.FirstRow.toObjectDictionary();
if (cfg.TryGetValue("refresh", out var rf) && rf is not null && rf is not DBNull &&
int.TryParse(rf.ToString(), out var rfi)) ciRefresh = rfi;
if (cfg.TryGetValue("functions", out var fn) && fn is not null)
ciCache = (fn.ToString() ?? "").Split(',').Contains("cache");
}
catch (Exception cex)
{
_logger.LogError(cex, "Report catalog read failed for report {Report}", report);
}
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0";
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("report.process");
act?.SetTag("fuchs.report.fn", tgt);
act?.SetTag("fuchs.report.id", report);
_logger.LogInformation("Report request fnc={Fnc} report={Report} tgt={Tgt} cache={Cache} user={User}",
fnc, report, tgt, ciCache, userAccountId);
try
{
switch (tgt)
{
case "generic_content":
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
return new ContentResult
{
Content = await FuchsVisualization.RenderContentAsync(
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms),
ContentType = "text/html"
};
case "generic":
{
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
var page = await FuchsVisualization.RenderPageAsync(
Conn, dbSec, userAccountId, report, report, FdsQueryType.generic, prms,
FdsDestination.web, templatePath, allowcache: ciCache, forceReload: ciForce);
ApplyReload(page, prms, ciRefresh);
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
}
case "chart":
byte[]? png = await FuchsVisualization.RenderQueryAsChartAsync(
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms);
if (png is null) return new StatusCodeResult(500);
return new FileContentResult(png, "image/png")
{
FileDownloadName = $"{report.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd_HHmm}.png"
};
case "xls":
return new StatusCodeResult(501); // not implemented (matches legacy NotImplementedException)
default:
if (Enum.TryParse<FdsQueryType>(fnc, ignoreCase: true, out var qt))
{
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
var page = await FuchsVisualization.RenderPageAsync(
Conn, dbSec, userAccountId, report, report, qt, prms,
FdsDestination.web, templatePath);
ApplyReload(page, prms, -2);
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
}
return new StatusCodeResult(300);
}
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt);
return new StatusCodeResult(500);
}
finally
{
sw.Stop();
FuchsTelemetry.ReportRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("fn", tgt));
FuchsTelemetry.ReportsRendered.Add(1, new KeyValuePair<string, object?>("fn", tgt));
_logger.LogDebug("Report request completed tgt={Tgt} report={Report} in {Ms} ms", tgt, report, sw.ElapsedMilliseconds);
}
}
private static void ApplyReload(FuchsHtmlPage page, IDictionary<string, string> prms, int ciRefresh)
{
if (prms.TryGetValue("reload", out var rl) && int.TryParse(rl, out var rs)) page.ReloadSeconds = rs;
else if (ciRefresh > -2) page.ReloadSeconds = ciRefresh;
else if (DefaultReloadSeconds > 0) page.ReloadSeconds = DefaultReloadSeconds;
if (page.QueryDuration > 180 && page.ReloadSeconds is > 0 and < 3600) page.ReloadSeconds = 1200;
else if (page.QueryDuration > 60 && page.ReloadSeconds is > 0 and < 1200) page.ReloadSeconds = 1200;
}
}
+121 -45
View File
@@ -1,7 +1,7 @@
using Fuchs.intranet;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using OCORE.security;
using OCORE.SQL;
@@ -12,8 +12,9 @@ using static OCORE.web.mvc_helper_async;
namespace Fuchs.Services;
/// <summary>
/// Widget service implementation. Replaces the static <c>FuchsWidgets</c> class.
/// No longer depends on <c>IntranetController</c>.
/// Widget service for the Fuchs intranet dashboard. Port of fuchs_fds_widgets.vb —
/// SQL-driven widget cases. Replaces the static <c>FuchsWidgets</c> class
/// (no longer depends on <c>IntranetController</c>).
/// </summary>
public class FuchsWidgetService : IWidgetService
{
@@ -26,39 +27,48 @@ public class FuchsWidgetService : IWidgetService
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId,
DatabaseSecurity dbSec, HttpRequest request)
{
using var act = FuchsTelemetry.StartActivity("widget.get");
act?.SetTag("fuchs.widget.id", widgetId);
_logger.LogDebug("GetWidgetAsync widget={WidgetId} user={User}", widgetId, userAccountId);
try
{
return widgetId.ToLower() switch
var result = widgetId.ToLower() switch
{
"my" => await HandleWidgetMy(userAccountId, dbSec),
"my" => await HandleWidgetMy(userAccountId, dbSec),
"one" => await HandleWidgetOne(userAccountId, dbSec, request),
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
};
_logger.LogDebug("GetWidgetAsync widget={WidgetId} result={Result} user={User}",
widgetId, result.GetType().Name, userAccountId);
return result;
}
catch (Exception ex)
{
act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
return new StatusCodeResult(500);
}
}
private List<SqlParameter> MakeParams(string userAccountId, params SqlParameter[] extra)
private static List<Microsoft.Data.SqlClient.SqlParameter> Params(string userAccountId,
params Microsoft.Data.SqlClient.SqlParameter[] extra)
{
var list = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
var list = new List<Microsoft.Data.SqlClient.SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
list.AddRange(extra);
return list;
}
// ── "my" — list of widget short-names for the current user ───────────────
private async Task<IActionResult> HandleWidgetMy(string userAccountId, DatabaseSecurity dbSec)
{
var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
_intranet.Intranet__SQLConnectionString,
MakeParams(userAccountId, SQL_VarChar("@account", "fis")),
Security: dbSec);
Conn, Params(userAccountId, SQL_VarChar("@account", "fis")), Security: dbSec);
var names = dt.DataTable.Rows
.Cast<System.Data.DataRow>()
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
@@ -67,6 +77,7 @@ public class FuchsWidgetService : IWidgetService
return await JSONAsync(names);
}
// ── "one" — full widget data for a single widget ──────────────────────────
private async Task<IActionResult> HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec,
HttpRequest request)
{
@@ -75,55 +86,120 @@ public class FuchsWidgetService : IWidgetService
var dt = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
_intranet.Intranet__SQLConnectionString,
MakeParams(userAccountId,
Conn,
Params(userAccountId,
SQL_VarChar("@shortname", shortName),
SQL_VarChar("@account", "fis")),
SQL_VarChar("@account", "fis")),
Security: dbSec);
if (dt.Count != 1) return new StatusCodeResult(404);
var wdg = dt.FirstRow.toObjectDictionary();
return await BuildWidgetResponse(userAccountId, dbSec, wdg);
return await BuildWidgetResponse(userAccountId, dbSec, shortName, wdg);
}
private async Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
// ── Unknown widget id ──────────────────────────────────────────────────────
// The dashboard only requests "my" and "one"; there is no generic widget
// source in the schema (the legacy code had no such procedure either).
private Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
DatabaseSecurity dbSec)
{
var pl = MakeParams(userAccountId, SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true));
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;",
_intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "data" },
Security: dbSec);
return await JSONAsync(new
{
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
data = dset.Tables("data").toArrayofObjectDictionaries()
});
_ = dbSec;
_logger.LogWarning("GetWidgetAsync: unknown widget id '{WidgetId}' requested by user={User}", widgetId, userAccountId);
return Task.FromResult<IActionResult>(new NotFoundResult());
}
private async Task<IActionResult> BuildWidgetResponse(string userAccountId,
DatabaseSecurity dbSec, Dictionary<string, object?> wdg)
// ── Widget renderer dispatcher ────────────────────────────────────────────
private async Task<IActionResult> BuildWidgetResponse(string userAccountId, DatabaseSecurity dbSec,
string shortName, Dictionary<string, object?> wdg)
{
string type = (wdg.nz("type", "") ?? "").ToLower();
string sql = wdg.nz("sql", "") ?? "";
string dbType = (wdg.nz("type", "") ?? "").ToLower();
string sql = wdg.nz("sql", "") ?? "";
var ropts = ParseRenderingOptions(wdg.nz("rendering_options", "") ?? "");
string name = wdg.nz("name", "") ?? "";
string descr = wdg.nz("description", "") ?? "";
if (type.StartsWith("sql") && !string.IsNullOrEmpty(sql))
object widgetData;
switch (dbType)
{
var dt = await getSQLDatatable_async(sql,
_intranet.Intranet__SQLConnectionString,
MakeParams(userAccountId), Security: dbSec);
return await JSONAsync(new
case "sql_table":
{
name = wdg.nz("name", ""),
type,
rows = dt.DataTable.Rows
.Cast<System.Data.DataRow>()
.Select(r => r.toObjectDictionary())
.ToArray()
});
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
widgetData = new
{
name,
description = descr,
type = "table",
rendering_options = ropts,
columns = dt.DataTable.Columns.Cast<System.Data.DataColumn>()
.Select(c => c.ColumnName).ToArray(),
data = dt.DataTable.Rows.Cast<System.Data.DataRow>()
.Select(r => r.toObjectDictionary()).ToArray()
};
break;
}
case "sql_indicator":
{
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
var firstRow = dt.DataTable.Rows.Count > 0
? dt.DataTable.Rows[0].toObjectDictionary()
: new Dictionary<string, object?>();
widgetData = new
{
name,
description = descr,
type = "ind",
rendering_options = ropts,
data = new
{
status = firstRow.nz("status", "") ?? "",
value = firstRow.nz("value", "") ?? "",
label = firstRow.nz("label", "") ?? ""
}
};
break;
}
case "html":
widgetData = new
{
name,
description = descr,
type = "html",
rendering_options = ropts,
html = wdg.nz("html", "") ?? ""
};
break;
default:
widgetData = new
{
name,
description = descr,
type = dbType,
rendering_options = ropts,
html = wdg.nz("html", "") ?? "",
url = wdg.nz("url", "") ?? "",
image = wdg.nz("image", "") ?? "",
data = (object)new
{
status = wdg.nz("status", "") ?? "",
value = wdg.nz("value", "") ?? "",
label = wdg.nz("label", "") ?? ""
}
};
break;
}
return await JSONAsync(wdg);
// Wrap under short_name key so the front-end can do response[wi]
return await JSONAsync(new Dictionary<string, object> { [shortName] = widgetData });
}
private static string[] ParseRenderingOptions(string raw) =>
string.IsNullOrWhiteSpace(raw)
? Array.Empty<string>()
: raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.ToArray();
}
+6 -2
View File
@@ -3,13 +3,17 @@
namespace Fuchs.Services;
/// <summary>
/// Abstraction for MT940 bank statement parsing.
/// Abstraction for bank statement parsing. Supports both MT940 (SWIFT text)
/// and CAMT (ISO 20022 camt.052/053/054 XML); the format is auto-detected.
/// </summary>
public interface IBankingService
{
/// <summary>Abbreviation for a debit/credit mark.</summary>
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
/// <summary>Parses an MT940 stream into a DataTable.</summary>
/// <summary>
/// Parses a bank statement stream (MT940 or CAMT, auto-detected) into a DataTable
/// matching the banking-transactions schema.
/// </summary>
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
}
-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>
Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Registers (creates or updates) an invoice from form data.</summary>
Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
/// <summary>Registers (creates or updates) an invoice from a parsed data object.</summary>
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for an invoice.</summary>
+2 -2
View File
@@ -13,8 +13,8 @@ public interface IReminderService
/// <summary>Loads an existing reminder by ID.</summary>
Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Registers (creates) a reminder from form data.</summary>
Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId,
/// <summary>Registers (creates) a reminder from a parsed data object.</summary>
Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for a reminder.</summary>
+12 -6
View File
@@ -1,15 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using OCORE.security;
using OCORE.SQL;
namespace Fuchs.Services;
/// <summary>
/// Abstraction for report processing.
/// Abstraction for SQL-driven report processing (HTML page / fragment / PNG chart).
/// </summary>
public interface IReportService
{
/// <summary>Processes a report request.</summary>
Task<IActionResult> ProcessRequestAsync(string action, string id,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>
/// Processes a report request.
/// </summary>
/// <param name="fnc">Target function: "generic", "generic_content"/"gct", "chart", or a query-type name.</param>
/// <param name="reportId">Report name/id (the URL <c>code</c> segment).</param>
/// <param name="userAccountId">Authenticated user id.</param>
/// <param name="dbSec">Database security context.</param>
/// <param name="parameters">Merged query-string + form parameters.</param>
Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters);
}
+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.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using OCORE.security;
using Newtonsoft.Json;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Invoice service implementation. Extracts DB operations from <c>FdsInvoiceData</c>
/// and PDF generation into a proper DI service.
/// Invoice service — load, register, render and store invoices.
/// Replaces the controller-coupled, sync-over-async logic that previously lived
/// inside <see cref="FdsInvoiceData"/>.
/// </summary>
public class InvoiceService : IInvoiceService
{
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdfService;
private readonly IPdfService _pdf;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdfService, ILogger<InvoiceService> logger)
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdfService = pdfService;
_pdf = pdf;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{
// TODO: Complete after FdsInvoiceData is refactored to remove IntranetController dependency
throw new NotImplementedException("InvoiceService.LoadInvoiceAsync pending FdsInvoiceData refactor.");
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
var inv = new FdsInvoiceData();
if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadInvoiceAsync called with empty id (user={User})", userAccountId); return inv; }
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@Id", id),
SQL_Bit("@includefile", false)
};
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("LoadInvoiceAsync sql exception for {Id}: {Ex}", id, dset.Exception);
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
return inv;
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("InvoiceService.RegisterInvoiceAsync pending FdsInvoiceData refactor.");
_logger.LogInformation("RegisterInvoiceAsync change={Change} invId={InvId} user={User}", change, invId, userAccountId);
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
{
pl.Add(SQL_NVarChar("@ProvisionLocation",
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
}
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
Conn, pl, tablenames: new[] { "inv", "det", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception))
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
return invoice;
}
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
{
throw new NotImplementedException("InvoiceService.GenerateInvoicePdf pending FdsInvoiceData refactor.");
using var act = FuchsTelemetry.StartActivity("invoice.render");
act?.SetTag("fuchs.invoice.id", invoice.Id);
act?.SetTag("fuchs.invoice.draft", draft);
_logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = reg?.getString("Id") ?? "",
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
: Array.Empty<string>(),
AdminUser = reg?.getString("UserNameFinalized") ?? "",
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
};
// WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core).
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
_pdf.ApplyInvoice(doc, tb, invoice, draft);
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc;
}
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{
throw new NotImplementedException("InvoiceService.RenderInvoicePdfBytesAsync pending FdsInvoiceData refactor.");
}
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("InvoiceService.StoreInvoiceDocumentFileAsync pending FdsInvoiceData refactor.");
byte[] ba;
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); }
if (ba.Length == 0) return Array.Empty<byte>();
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@Id", invoice.Id),
new("@file", SqlDbType.VarBinary) { Value = ba }
};
bool r = await setSQLValue_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
}
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft,
fds.IFdsMfr mfr)
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
{
if (invoice.InvoiceRegistration?.getItem("IsFinal", false) is true)
{
+8 -2
View File
@@ -1,22 +1,28 @@
using Microsoft.Extensions.Logging;
using Fuchs.Observability;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
/// <summary>
/// Factory implementation for <see cref="fds.FdsMfrClient"/>.
/// Centralizes MFR client creation and supplies logger from DI.
/// Centralizes MFR client creation, supplies the logger from DI, and counts
/// client instantiations as a proxy for MFR ERP interactions.
/// </summary>
public class MfrClientFactory : IMfrClientFactory
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<MfrClientFactory> _logger;
public MfrClientFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
}
public fds.FdsMfrClient Create()
{
FuchsTelemetry.MfrCalls.Add(1);
_logger.LogDebug("Creating new FdsMfrClient instance.");
return new fds.FdsMfrClient(_loggerFactory);
}
}
+61 -5
View File
@@ -1,6 +1,8 @@
using System.Net.Http.Headers;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -43,9 +45,13 @@ public class ProcessWebComService : IComService
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null)
{
using var act = FuchsTelemetry.StartActivity("email.send");
act?.SetTag("fuchs.email.ref", reference);
if (!IsValidEmail(email))
{
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
return false;
}
@@ -57,23 +63,41 @@ public class ProcessWebComService : IComService
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
return false;
}
bool success = false;
string messageId = "";
var errors = new List<string>();
var sw = Stopwatch.StartNew();
try
{
// Attachments are transmitted inline as base64 in the same push_com POST.
// Each entry: { filename, mimeType, contentBase64 }.
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
.Where(kv => kv.Value is { Length: > 0 })
.Select(kv => new
{
filename = kv.Key,
mimeType = GuessMimeType(kv.Key),
contentBase64 = Convert.ToBase64String(kv.Value)
})
.ToArray();
var payload = new
{
comType = "email",
recipient = email,
comType = "email",
recipient = email,
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);
if (ok)
{
@@ -89,9 +113,18 @@ public class ProcessWebComService : IComService
catch (Exception ex)
{
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
}
sw.Stop();
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("success", success));
if (success) FuchsTelemetry.EmailsSent.Add(1);
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
reference, success, sw.ElapsedMilliseconds);
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
return success;
}
@@ -122,7 +155,12 @@ public class ProcessWebComService : IComService
};
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);
return false;
@@ -190,6 +228,24 @@ public class ProcessWebComService : IComService
return "";
}
private static string GuessMimeType(string filename) =>
Path.GetExtension(filename).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".txt" => "text/plain",
".csv" => "text/csv",
".xml" => "application/xml",
".zip" => "application/zip",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream"
};
private static bool IsValidEmail(string email)
{
try
+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.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Reminder service implementation. Extracts DB operations from <c>FdsReminderData</c>
/// into a proper DI service.
/// Reminder service — load, register, render and store reminders.
/// Replaces the controller-coupled, sync-over-async logic that previously lived
/// inside <see cref="FdsReminderData"/>.
/// </summary>
public class ReminderService : IReminderService
{
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdfService;
private readonly IPdfService _pdf;
private readonly ILogger<ReminderService> _logger;
public ReminderService(Fuchs_intranet intranet, IPdfService pdfService, ILogger<ReminderService> logger)
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, ILogger<ReminderService> logger)
{
_intranet = intranet;
_pdfService = pdfService;
_pdf = pdf;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("ReminderService.LoadReminderAsync pending FdsReminderData refactor.");
_logger.LogDebug("LoadReminderAsync id={Id} user={User}", id, userAccountId);
var rem = new FdsReminderData();
if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadReminderAsync called with empty id (user={User})", userAccountId); return rem; }
var pl = new List<SqlParameter>
{
SQL_VarChar("Id", id),
SQL_VarChar("@authuser", userAccountId),
SQL_Bit("@includefile", false)
};
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
Conn, pl, tablenames: new[] { "admin", "rem" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("LoadReminderAsync sql exception for {Id}: {Ex}", id, dset.Exception);
rem.ReminderRegistration = new GenericObjectDictionary(dset.Table("rem").FirstRow.toObjectDictionary());
rem.IsDraft = rem.ReminderRegistration.getItem("IsFinal") is not true;
return rem;
}
public async Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId,
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("ReminderService.RegisterReminderAsync pending FdsReminderData refactor.");
_logger.LogInformation("RegisterReminderAsync change={Change} remId={RemId} user={User}", change, remId, userAccountId);
if (reminder.Rem == null || reminder.Rem.Count == 0) { _logger.LogWarning("RegisterReminderAsync: no form values supplied (user={User})", userAccountId); return reminder; }
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("InvId", reminder.RawInvId),
SQL_Char("type", reminder.Rem["type"]?.ToString() ?? ""),
SQL_Float("amount", stringvalue: reminder.NewValues?.nz("amount") ?? ""),
SQL_Float("amount_payed", stringvalue: reminder.NewValues?.nz("amount_payed") ?? ""),
SQL_VarChar("SendToAddress", string.Join("\n", reminder.RawInvoiceAddress)),
SQL_NVarChar("SendToEmail", reminder.RawInvoiceEmail),
SQL_NVarChar("subject", reminder.NewValues?.getString("subject") ?? "", dbNull_IfEmpty: true),
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
};
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change || string.IsNullOrEmpty(remId))
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
else
pl.Add(SQL_VarChar("RemId", remId));
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
Conn, pl, tablenames: new[] { "rem" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception))
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
return reminder;
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft)
{
throw new NotImplementedException("ReminderService.GenerateReminderPdf pending FdsReminderData refactor.");
using var act = FuchsTelemetry.StartActivity("reminder.render");
act?.SetTag("fuchs.reminder.id", reminder.Id);
act?.SetTag("fuchs.reminder.draft", draft);
_logger.LogDebug("GenerateReminderPdf id={Id} draft={Draft}", reminder.Id, draft);
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = "",
Address = reminder.InvoiceAddress,
AdminUser = reminder.UserNameFinalized,
AdminUserEmail = reminder.UserEmailFinalized,
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
};
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
{
var o = new GenericObjectDictionary(reminder.RawCustomValues);
string oEmail = (string?)o.getItem("contactEmail") ?? "";
string oName = (string?)o.getItem("contactName") ?? "";
if (!string.IsNullOrEmpty(oEmail) || !string.IsNullOrEmpty(oName))
{
tb.AdminUser = oName;
tb.AdminUserEmail = oEmail;
}
}
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reminder.ReminderTitle;
_pdf.ApplyReminder(doc, tb, reminder, draft);
FuchsTelemetry.RemindersRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc;
}
public async Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData reminder, bool draft)
{
throw new NotImplementedException("ReminderService.RenderReminderPdfBytesAsync pending FdsReminderData refactor.");
}
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData reminder, bool draft)
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateReminderPdf(reminder, draft)));
public async Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData reminder, bool draft,
string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("ReminderService.StoreReminderDocumentFileAsync pending FdsReminderData refactor.");
byte[] ba;
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); }
if (ba.Length == 0) return Array.Empty<byte>();
var pl = new List<SqlParameter>
{
SQL_VarChar("Id", reminder.Id),
SQL_VarChar("@authuser", userAccountId),
new("@file", SqlDbType.VarBinary) { Value = ba }
};
bool r = await setSQLValue_async(
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
}
public async Task<byte[]> GetReminderFileAsync(FdsReminderData reminder, bool draft,
fds.IFdsMfr mfr, string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("ReminderService.GetReminderFileAsync pending FdsReminderData refactor.");
if (reminder.ReminderRegistration?.getItem("IsFinal", false) is true)
{
if (!reminder.ReminderRegistration.ContainsKey("hasFile") ||
reminder.ReminderRegistration.getItem("hasFile", false) is not true)
await StoreReminderDocumentFileAsync(reminder, draft, userAccountId, dbSec);
byte[]? ba = null;
mfr.GetFdsDoc(ref ba, reminder.Id, "reminder");
return ba ?? Array.Empty<byte>();
}
return await RenderReminderPdfBytesAsync(reminder, draft);
}
public async Task<(FileInfo? file, byte[]? content)> GetStoredFileAsync(string reminderId,
string userAccountId, DatabaseSecurity dbSec)
{
throw new NotImplementedException("ReminderService.GetStoredFileAsync pending FdsReminderData refactor.");
var dset = await getSQLDataSet_async(
"SELECT TOP(1) * FROM [dbo].[fds__reminder] WHERE [Id] = @Id AND [file] is not null;",
Conn,
new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", reminderId) },
Security: dbSec, options: new FIS_SQLOptions());
var row = dset.FirstTable().FirstRow.toObjectDictionary();
if (row.Count > 0 && !string.IsNullOrEmpty(row.nz("DocumentName")) && row.no("file", null!) is byte[] b)
return (new FileInfo(row.nz("DocumentName")), b);
return (null, null);
}
}
+4
View File
@@ -38,6 +38,10 @@
"AccountId": "",
"Token": "MANAGED_BY_KEYVAULT",
"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 Fuchs.Controllers;
using System.Globalization;
using Microsoft.Data.SqlClient;
using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Newtonsoft.Json.Linq;
using OCORE.SQL;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
using static OCORE.commons;
@@ -12,25 +8,26 @@ using static OCORE.commons;
namespace Fuchs.intranet;
/// <summary>
/// Encapsulates invoice (Rechnung) data. Converted from VB fds__invoice_data class.
/// Invoice (Rechnung) data holder. Converted from VB fds__invoice_data.
/// Pure data + parameter mapping — all persistence and PDF generation now live
/// in <see cref="Fuchs.Services.IInvoiceService"/> (no controller coupling).
/// </summary>
public class FdsInvoiceData
{
private readonly JObject? _base;
private Document? _letter;
public GenericObjectDictionary? Admin { get; private set; }
public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Sms { get; private set; }
public List<Dictionary<string, object>>? Req { get; private set; }
public GenericObjectDictionary? InvoiceRegistration { get; private set; }
public bool IsDraft { get; private set; } = true;
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
public bool IsDraft { get; internal set; } = true;
public string Id => InvoiceRegistration?.getString("Id") ?? "";
public string PaymentTerms => InvoiceRegistration?.getString("PaymentTerm") ?? "";
// -- PDF-facing properties (used by FuchsPdf.ApplyInvoice) ----------------
// -- PDF-facing properties (used by IPdfService.ApplyInvoice) -------------
public string InvoiceType =>
InvoiceRegistration?.getString("InvoiceType").Substr(0, 1) ?? "R";
public string InvoiceId =>
@@ -56,7 +53,7 @@ public class FdsInvoiceData
{
IEnumerable<Dictionary<string, object?>>? itms =
itmsObj as IEnumerable<Dictionary<string, object?>>
?? (itmsObj is Newtonsoft.Json.Linq.JArray ja
?? (itmsObj is JArray ja
? ja.ToObject<List<Dictionary<string, object?>>>()
: null);
if (itms != null) result.AddRange(itms);
@@ -73,33 +70,33 @@ public class FdsInvoiceData
{
var result = new Dictionary<string, Dictionary<string, object?>>();
if (InvoiceRegistration == null) return result;
// Primary VAT slot
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_1"), out decimal ust1)
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net1"), out decimal net1)
&& !(ust1 == 0 && net1 == 0))
result[ust1.ToString("0.##")] = new Dictionary<string, object?>
{ ["vat_amount"] = net1 };
// Secondary VAT slot
result[ust1.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net1 };
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_2"), out decimal ust2)
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net2"), out decimal net2)
&& !(ust2 == 0 && net2 == 0))
result[ust2.ToString("0.##")] = new Dictionary<string, object?>
{ ["vat_amount"] = net2 };
result[ust2.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net2 };
return result;
}
}
// -- Raw properties --------------------------------------------------------
private string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
private string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
private string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
private string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
private string[] RawProvisionLocation =>
// -- Raw form properties (used by parameter mapping) -----------------------
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
internal string[] RawProvisionLocation =>
NewValues?.nz("provisionlocation") is { Length: > 0 } s
? s.Replace("\r\n", "\n").Split('\n').Select(t => t.Trim()).Where(t => t != "").ToArray()
: Array.Empty<string>();
// -- Ctors -----------------------------------------------------------------
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
public FdsInvoiceData() { }
/// <summary>Parses form data (admin/new/sms/req) into the data object.</summary>
public FdsInvoiceData(object ctd)
{
_base = ctd as JObject;
@@ -113,157 +110,14 @@ public class FdsInvoiceData
}
}
public FdsInvoiceData(string id, IntranetController ctrl) => RegisterInvoice(id, ctrl);
// -- PDF -------------------------------------------------------------------
public Document InvoicePDF(IntranetController ctrl)
// -- Parameter mapping (consumed by InvoiceService.RegisterInvoiceAsync) ---
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
{
if (_letter != null) return _letter;
if (InvoiceRegistration == null) RegisterInvoice(Id, ctrl);
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = InvoiceRegistration?.getString("Id") ?? "",
Address = InvoiceRegistration?.getString("SendToAddress") is { Length: > 0 } sa
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
: Array.Empty<string>(),
AdminUser = InvoiceRegistration?.getString("UserNameFinalized") ?? "",
AdminUserEmail = InvoiceRegistration?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = InvoiceRegistration?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
};
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
_letter.Info.Title = InvoiceRegistration?.getString("InvoiceTitle") ?? "";
FuchsPdf.ApplyInvoice(_letter, tb, this, draft: IsDraft);
return _letter;
}
// -- File operations -------------------------------------------------------
public async Task<byte[]?> GetInvoiceFile(IntranetController ctrl)
{
if (InvoiceRegistration?.getItem("IsFinal", false) is true)
{
byte[]? ba = null;
ctrl._mfr.GetFdsDoc(ref ba, Id, "invoice");
return ba;
}
return await RenderToPdfBytes(ctrl);
}
public async Task<byte[]> StoreInvoiceDocumentFile(IntranetController ctrl)
{
byte[] ba;
try { ba = await RenderToPdfBytes(ctrl); }
catch { ba = Array.Empty<byte>(); }
if (ba.Length == 0) return Array.Empty<byte>();
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", Id));
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
bool r = await setSQLValue_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
ctrl._intranet.Intranet__SQLConnectionString, pl,
Security: ctrl.DbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
}
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
{
var pdfrend = new PdfDocumentRenderer() { Document = InvoicePDF(ctrl) };
pdfrend.RenderDocument();
using var ms = new MemoryStream();
pdfrend.PdfDocument.Save(ms, false);
ms.Position = 0;
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
}
// -- Registration ----------------------------------------------------------
public void RegisterInvoice(IntranetController ctrl, bool change, string invId)
{
if (NewValues == null) return;
Task.Run(async () =>
{
try
{
var pl = ctrl.StdParamlist();
pl.AddRange(BuildInvoiceParams(change, invId));
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (RawProvisionLocation.Length > 0)
{
pl.Add(SQL_NVarChar("@ProvisionLocation",
string.Join("\n", RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
}
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
ctrl._intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "inv", "det", "req", "itm" },
Security: ctrl.DbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception))
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice - sql exception",
data: new { exception = invdset.Exception });
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
}
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice", ex: ex); }
}).Wait();
}
public void RegisterInvoice(string id, IntranetController ctrl)
{
if (string.IsNullOrEmpty(id)) return;
Task.Run(async () =>
{
try
{
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", id));
pl.Add(SQL_Bit("@includefile", false));
var invdset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
ctrl._intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "admin", "inv", "req", "itm" },
Security: ctrl.DbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception))
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id) - sql exception",
data: new { exception = invdset.Exception });
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
IsDraft = InvoiceRegistration.getItem("IsFinal", false) is not true;
}
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id)", ex: ex); }
}).Wait();
}
// -- Param builder ---------------------------------------------------------
private List<SqlParameter> BuildInvoiceParams(bool change, string invId)
{
var vatsDic = new Dictionary<string, string>();
if (Req != null)
{
foreach (var rq in Req)
{
if (rq.TryGetValue("items", out var itmsObj) &&
itmsObj is List<object> itms)
{
foreach (var itm in itms.OfType<Dictionary<string, object?>>())
{
string vatKey = itm.nz("vat", "").Replace("%", "").Trim();
if (!string.IsNullOrEmpty(vatKey) && vatKey != "0")
vatsDic.TryAdd(vatKey, vatsDic.TryGetValue(vatKey, out var ve) ? ve : "0");
}
}
}
}
string vathigh = vatsDic.Keys.OrderByDescending(k => double.TryParse(k, out var d) ? d : 0).FirstOrDefault() ?? "19";
_ = change; _ = invId;
// VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
// matching the legacy contract. Item-level VAT strings are German-formatted and
// single-rate procs only store one rate, so the highest rate wins.
var (vatRate, vatNet) = HighestVat(Sms);
return new List<SqlParameter>
{
@@ -271,8 +125,8 @@ public class FdsInvoiceData
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"),
SQL_VarChar("@InvoiceVAT_1", vathigh),
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
SQL_VarChar("@InvoiceVAT_1", vatRate),
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
@@ -281,8 +135,64 @@ public class FdsInvoiceData
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
SQL_VarChar("@InvoiceOptions",
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
};
}
/// <summary>
/// Builds the InvoiceOptions CSV from the posted admin flags.
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&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 OCORE.SQL;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
using static OCORE.commons;
namespace Fuchs.intranet;
/// <summary>
/// Encapsulates a reminder (Zahlungserinnerung) data object.
/// Converted from VB fds__reminder_data class.
/// Reminder (Zahlungserinnerung) data holder. Converted from VB fds__reminder_data.
/// Pure data — persistence and PDF generation live in
/// <see cref="Fuchs.Services.IReminderService"/> (no controller coupling).
/// </summary>
public class FdsReminderData
{
private readonly JObject? _base;
private Document? _letter;
public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Rem { get; private set; }
public GenericObjectDictionary? ReminderRegistration { get; private set; }
public bool IsDraft { get; private set; } = true;
public GenericObjectDictionary? ReminderRegistration { get; internal set; }
public bool IsDraft { get; internal set; } = true;
public string Id => ReminderRegistration?.getString("Id") ?? "";
// -- Raw props from form data ---------------------------------------------
public string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
internal string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
? s.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n")
.Replace("\r\n", "\n").Replace("\n\n", "\n").Split('\n')
.Select(t => System.Web.HttpUtility.HtmlDecode(t.Trim())).Where(t => t != "").ToArray()
: Array.Empty<string>();
public string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
public string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
public string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
internal string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
// -- Computed props from registration -------------------------------------
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
@@ -59,11 +52,10 @@ public class FdsReminderData
get
{
if (ReminderRegistration == null) return new List<Dictionary<string, object?>>();
// Items are stored as nested JSON under "invoices" key in the registration
var raw = ReminderRegistration.getItem("invoices");
try
{
if (raw is Newtonsoft.Json.Linq.JArray ja)
if (raw is JArray ja)
return ja.ToObject<List<Dictionary<string, object?>>>() ?? new();
if (raw is string s && s.StartsWith("["))
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object?>>>(s) ?? new();
@@ -74,6 +66,10 @@ public class FdsReminderData
}
// -------------------------------- Ctors ----------------------------------
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
public FdsReminderData() { }
/// <summary>Parses form data (new/rem) into the data object.</summary>
public FdsReminderData(object ctd)
{
if (ctd is JObject jo) { _base = jo; }
@@ -84,153 +80,4 @@ public class FdsReminderData
if (_base.ContainsKey("rem")) Rem = new GenericObjectDictionary(_base["rem"]!.ToObject<Dictionary<string, object>>()!);
}
}
public FdsReminderData(string id, IntranetController ctrl) => RegisterReminder(id, ctrl);
// -------------------------------- PDF ------------------------------------
public Document ReminderPDF(IntranetController ctrl)
{
if (_letter != null) return _letter;
if (ReminderRegistration == null) RegisterReminder(Id, ctrl);
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = "",
Address = InvoiceAddress,
AdminUser = UserNameFinalized,
AdminUserEmail = UserEmailFinalized,
AdminDatumValue = DateCreated ?? DateTime.Now
};
if (!string.IsNullOrEmpty(RawCustomValues))
{
var o = new GenericObjectDictionary(RawCustomValues);
string oEmail = (string?)o.getItem("contactEmail") ?? "";
string oName = (string?)o.getItem("contactName") ?? "";
if (!string.IsNullOrEmpty(oEmail) || !string.IsNullOrEmpty(oName))
{
tb.AdminUser = oName;
tb.AdminUserEmail = oEmail;
}
}
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
_letter.Info.Title = ReminderTitle;
FuchsPdf.ApplyReminder(_letter, tb, this, draft: IsDraft);
return _letter;
}
// --------------------------- File operations -----------------------------
public async Task<byte[]> GetReminderFile(IntranetController ctrl)
{
if (ReminderRegistration?.getItem("IsFinal", false) is true)
{
if (!ReminderRegistration.ContainsKey("hasFile") || ReminderRegistration.getItem("hasFile", false) is not true)
await StoreReminderDocumentFile(ctrl);
byte[]? ba = null;
ctrl._mfr.GetFdsDoc(ref ba, Id, "reminder");
return ba ?? Array.Empty<byte>();
}
return await RenderToPdfBytes(ctrl);
}
public async Task<byte[]> StoreReminderDocumentFile(IntranetController ctrl)
{
byte[] ba;
try { ba = await RenderToPdfBytes(ctrl); }
catch { ba = Array.Empty<byte>(); }
if (ba.Length == 0) return Array.Empty<byte>();
var pl = ctrl.StdParamlist("Id", Id);
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
bool r = await setSQLValue_async("EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
ctrl._intranet.Intranet__SQLConnectionString, pl,
Security: ctrl.DbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
}
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
{
var pdfrend = new PdfDocumentRenderer() { Document = ReminderPDF(ctrl) };
pdfrend.RenderDocument();
using var ms = new MemoryStream();
pdfrend.PdfDocument.Save(ms, false);
ms.Position = 0;
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
}
// ---------------------------- Registration -------------------------------
public void RegisterReminder(IntranetController ctrl, bool change, string remId)
{
if (Rem == null || Rem.Count == 0) return;
Task.Run(async () =>
{
try
{
var pl = ctrl.StdParamlist();
pl.Add(SQL_VarChar("InvId", RawInvId));
pl.Add(SQL_Char("type", Rem["type"]?.ToString() ?? ""));
pl.Add(SQL_Float("amount", stringvalue: NewValues?.nz("amount") ?? ""));
pl.Add(SQL_Float("amount_payed", stringvalue: NewValues?.nz("amount_payed") ?? ""));
pl.Add(SQL_VarChar("SendToAddress", string.Join("\n", RawInvoiceAddress)));
pl.Add(SQL_NVarChar("SendToEmail", RawInvoiceEmail));
pl.Add(SQL_NVarChar("subject", NewValues?.getString("subject") ?? "", dbNull_IfEmpty: true));
pl.Add(SQL_NVarChar("text", NewValues?.nz("text") ?? "", dbNull_IfEmpty: true));
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change || string.IsNullOrEmpty(remId))
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
else
pl.Add(SQL_VarChar("RemId", remId));
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
ctrl._intranet.Intranet__SQLConnectionString, pl,
Security: ctrl.DbSec,
tablenames: new[] { "rem" }, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception))
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder - sql exception",
data: new { exception = remdset.Exception });
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
}
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder", ex: ex); }
}).Wait();
}
public void RegisterReminder(string id, IntranetController ctrl)
{
if (string.IsNullOrEmpty(id)) return;
Task.Run(async () =>
{
try
{
var pl = ctrl.StdParamlist("Id", id);
pl.Add(SQL_Bit("@includefile", false));
var remdset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
ctrl._intranet.Intranet__SQLConnectionString, pl,
Security: ctrl.DbSec,
tablenames: new[] { "admin", "rem" }, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception))
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id) - sql exception",
data: new { exception = remdset.Exception });
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
IsDraft = !(ReminderRegistration.getItem("IsFinal") is true);
}
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id)", ex: ex); }
}).Wait();
}
public static FileInfo? GetStoredFile(ref byte[]? file, string reminderId, IntranetController ctrl)
{
var sqlrw = Task.Run(async () =>
(await getSQLDataSet_async(
"SELECT TOP(1) * FROM [dbo].[fds__reminder] WHERE [Id] = @Id AND [file] is not null;",
ctrl._intranet.Intranet__SQLConnectionString,
ctrl.StdParamlist(SQL_VarChar("@Id", reminderId)),
Security: ctrl.DbSec))
.FirstTable().FirstRow.toObjectDictionary()).Result;
if (sqlrw.Count > 0 && !string.IsNullOrEmpty(sqlrw.nz("DocumentName")) && sqlrw.no("file", null!) is byte[] b)
{
file = b;
return new FileInfo(sqlrw.nz("DocumentName"));
}
return null;
}
}
+162 -21
View File
@@ -473,25 +473,26 @@ public static class FuchsPdf
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
}
// Data rows — from InvoiceItems
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
// For invoices without sets this passes items through unchanged; for sets it
// emits set header + members per the chosen mode, blanking price cells where
// a line should show no price. Totals come from the registration balance, so
// the mode is purely presentational.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 1;
foreach (var itm in inv.InvoiceItems)
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
{
string title = itm.nz("title", "");
string desc = itm.nz("desc", "");
string qty = itm.nz("qty", "1");
ParseDec(itm.no("price_net", 0), out decimal priceNet);
ParseDec(itm.no("total_net", 0), out decimal totalNet);
var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
var titleCell = row.Cells[1].AddParagraph();
titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title);
if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"<div>{desc}</div>");
row.Cells[2].AddParagraph(qty).Style = "TblCell_Base";
row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base";
row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum";
titleCell.Style = "TblCell_RTitle";
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
else titleCell.AddText(line.Title);
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
@@ -523,16 +524,95 @@ public static class FuchsPdf
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
// Payment terms note
string terms = TranslatePaymentTerm(inv.PaymentTerms);
string ibanLine = p13b
? "Die Steuerschuldnerschaft geht auf den Leistungsempf\u00e4nger \u00fcber (§ 13b UStG)."
: $"Bitte \u00fcberweisen Sie den Rechnungsbetrag innerhalb von {terms} auf unser Konto:\n" +
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)";
// ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
var reg = inv.InvoiceRegistration;
void Note(string text, string style = "InvoiceNotes")
{
var p = sec.AddParagraph(); p.Style = "InvoiceNotes";
p.AddText(ibanLine);
if (string.IsNullOrEmpty(text)) return;
var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
}
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
string serviceGross = Currency(reg?.getItem("InvoiceService"));
if (p13b)
{
string ustString = ParseDec(reg?.getItem("InvoiceVAT_1"), out decimal ust)
? $" mit einem Steuersatz von {ust.ToString("0.##", DeCulture)}%" : "";
Note("Gem. §13b Umsatzsteuergesetz unterliegen Sie der Steuerschuldnerschaft des " +
$"Leistungsempfängers zur Umsatzsteuer aus dieser Rechnung{ustString}.");
}
if (inv.InvoiceType == "i")
{
Note("Für bereits erbrachte Arbeiten, Dienstleistungen, Materiallieferungen und getätigte " +
"Bestellvorgänge zum oben genannten Bauvorhaben, die sich aus dem mit Ihnen geschlossenen " +
"Vertrag ergeben, stellen wir Ihnen vertragsgemäß unsere Akontozahlung in Rechnung. " +
"Eine Endabrechnung erhalten Sie als Schlussrechnung nach Abschluss des gesamten Bauvorhabens. " +
"Das Ausführungsdatum entnehmen Sie bitte dem Schlusstext dieser Rechnung. Wir danken Ihnen " +
"herzlich für das entgegengebrachte Vertrauen und bitten Sie um kurzfristigen Ausgleich der Akontorechnung.");
}
else if (invService > 0 && !p13b && serviceGross != "?")
{
Note($"Im Bruttobetrag sind {serviceGross} Lohnkosten enthalten " +
$"(netto {Currency(reg?.getItem("InvoiceService_net"))}). " +
$"Die darin enthaltene Mehrwertsteuer beträgt {Currency(reg?.getItem("InvoiceService_VAT"))}.");
}
Note("Bitte beachten Sie, nach §14 Abs. 1 Umsatzsteuergesetz ist diese Rechnung ein Zahlungsbeleg " +
"oder eine andere beweiskräftige Unterlage für 2 Jahre nach Ablauf des Kalenderjahres der " +
"Ausstellung dieser Rechnung aufzubewahren, soweit nicht aufgrund anderer gesetzlicher Regelungen " +
"andere ggf. längere Aufbewahrungsfristen gelten.");
if (inv.InvoiceType != "i" && invService > 0 && !p13b)
{
decimal refundRate = ParseDec(reg?.getItem("tax_servicerefund"), out decimal rr) ? rr : 0.2m;
Note(($"Privathaushalten erstattet das Finanzamt bis zu {Currency(invService * refundRate)} " +
"des Arbeitslohns mit der nächsten Steuererklärung.").ToUpper(), "InvoiceNotes_ucb");
}
Note("Unsere Allgemeinen und ihnen bekannten Geschäftsbedingungen gelten für alle unsere Angebote. " +
"Wir liefern oder leisten ausschließlich zu diesen Bedingungen. Andere Bedingungen werden nicht " +
"Vertragsinhalt, auch wenn wir diesen nicht ausdrücklich widersprochen haben. Ergänzend zu diesen " +
"Bedingungen gelten unsere Zusatzbedingungen für allgemeine Dienstverträge, Handwerksleitungen und " +
"Wartungsverträge. Spätestens mit der Entgegennahme der entsprechenden Lieferung und/oder Leistung " +
"gelten unsere Bedingungen als angenommen. Sie gelten auch für künftige Geschäftsbeziehungen, auch " +
"wenn sie nicht nochmals ausdrücklich vereinbart werden. Insbesondere auch was die Datenverarbeitung " +
"nach Datenschutz-Grundverordnung (DSGVO) Artikel 5 anbelangt.");
Note("Steuernummer: 106/5849/2962");
string paymentTermPhrase = (reg?.nz("PaymentTermPhrase") ?? "")
.ne($"Zahlbar innerhalb von {TranslatePaymentTerm(reg?.nz("PaymentTerm") ?? "")}.");
Note("Freistellungsbescheinigung zum Steuerabzug bei Bauleistungen gemäß § 48 Abs. 1 Satz1 des EStG " +
"liegt vor. Es gelten unsere derzeit gültigen allgemeinen Liefer- und Zahlungsbedingungen. " +
$"{paymentTermPhrase} Danach erfolgt Verzugseintritt ohne Mahnung (§ 286 Absatz II BGB).");
Note("Hinweis zu unseren Verrechnungssätzen: In den ausgewiesenen Arbeitswerten sind die Dienstleistungen " +
"als Arbeitslohn auf Basis der benötigten Zeit enthalten, inklusive der Fahrtzeit, Rüstzeit, " +
"Auftragsvorbereitung und Werkzeugen (ausgenommen Spezialwerkzeuge wie Pressen Stemmhammer, etc.) " +
"und die Verfügbarkeit von gängigen Ersatzteilen im Kundendienstfahrzeug. Alle Reparatureinsätze " +
"(ggf. nur oder einschließlich Störungsdiagnoseeinsätze) des Kundendienst werden grundsätzlich mit " +
"einem Verrechnungssatz nach Aufwand (1 Arbeitswert/Stück Zeiteinheit = 10 Minuten) abgerechnet. " +
"Der Verrechnungssatz „Servicepauschale/ Notdienst” ausschließlich ausserhalb unserer Öffnungszeiten " +
"am Samstag und Sonntag sowie feiertags.");
Note("Weitere Informationen erhalten Sie unter www.sanitaerfuchs.de");
Note("\"Ach übrigens, wenn Sie mit uns zufrieden waren, dann sagen Sie es doch bitte den anderen. " +
"Und falls Sie mal nicht so zufrieden sind, dann sagen Sie es bitte gleich uns.\" Denn schließlich " +
"ist die Zufriedenheit unserer Kunden unser wichtigstes Ziel - und ihre Weiterempfehlung unsere " +
"Beste Visitenkarte.");
Note("PLANT-MY-TREE für jedes gebaute Badezimmer und für jede gebaute Heizung spenden wir einen Baum. " +
"PLANT MY TREE führt als Unternehmen eigene Erstaufforstungsprojekte auf eigenen Flächen in " +
"Deutschland durch, die zuvor anderweitig genutzt wurden. Im Vorfeld arbeiten wir dabei eng mit den " +
"lokalen Forstbehörden zusammen. Unser Ziel ist die langfristige CO2-Kompensierung und damit der " +
"nachhaltige Umwelt- und Klimaschutz. Nach der Vermeidung des CO2-Ausstoß bzw. der Reduzierung ist " +
"die Aufforstung nicht nur unserer Meinung nach der beste und nachhaltigste Weg, das Klima und damit " +
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
// GiroCode payment QR (only on finalized invoices with a positive balance)
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
}
// ── ApplyReminder ─────────────────────────────────────────────────────────
@@ -602,6 +682,10 @@ public static class FuchsPdf
$"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)");
// GiroCode payment QR (only on finalized reminders with a positive open amount)
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
AddGirocode(sec, openTotal, $"Rechnung {rem.InvoiceId}");
// Greeting
var greet = sec.AddParagraph(); greet.Style = "BodyText";
greet.Format.SpaceBefore = cm(0.8);
@@ -711,6 +795,63 @@ public static class FuchsPdf
}
}
/// <summary>
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
/// matching the legacy fuchs_fds_pdf.vb invoice/reminder layout. No-op on failure.
/// </summary>
private static void AddGirocode(Section sec, decimal amount, string purpose)
{
byte[]? girocodeImg = null;
try
{
using var girocode = GetPaycode(
iban: "DE52301502000002091478", bic: "WELADED1KSD",
name: "Sebastian Fuchs Bad und Heizung", amount: amount, purpose: purpose);
girocodeImg = ImageToByteArray(girocode);
}
catch { girocodeImg = null; }
if (girocodeImg == null) return;
var spacer = sec.AddParagraph();
spacer.Format.SpaceBefore = cm(2);
spacer.AddText("");
var girotbl = sec.AddTable();
girotbl.AddColumn(cm(17 - 3 - 0.6));
girotbl.AddColumn(cm(3 + 0.6));
girotbl.Borders.Color = Colors.Black;
girotbl.Borders.Width = 0.5;
girotbl.Borders.Style = BorderStyle.Single;
girotbl.Borders.Distance = cm(1);
var rw = girotbl.AddRow();
rw.Cells[0].Borders.Right.Visible = false;
rw.Cells[0].Format.LeftIndent = cm(0.6);
var p = rw.Cells[0].AddParagraph();
p.Format.SpaceBefore = cm(0.3);
p.Format.SpaceAfter = cm(0.2);
p.AddText("Zahlen mit Girocode. Mit dem GiroCode bezahlen Sie Ihre Rechnungen schnell, " +
"sicher und vor allem fehlerfrei. Ihre Banking App liest aus dem Code alle " +
"relevanten Daten für Ihre Überweisung.");
p = rw.Cells[0].AddParagraph();
p.AddText("Weitere Infos finden Sie unter ");
p.AddHyperlink("http://www.girocode.de", HyperlinkType.Web).AddText("http://www.girocode.de");
p.AddText(".");
rw.Cells[1].Borders.Left.Visible = false;
var imgPara = rw.Cells[1].AddParagraph();
var img = imgPara.AddImage(MigraDocFilenameFromByteArray(girocodeImg));
img.Resolution = 300;
img.WrapFormat.Style = WrapStyle.TopBottom;
img.RelativeHorizontal = RelativeHorizontal.Column;
img.RelativeVertical = RelativeVertical.Line;
img.Width = cm(3);
img.LockAspectRatio = true;
img.Left = ShapePosition.Right;
img.Top = ShapePosition.Top;
}
private static string MigraDocFilenameFromByteArray(byte[] image) =>
"base64:" + Convert.ToBase64String(image);
-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
};
}
}
+83 -6
View File
@@ -96,6 +96,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
}
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
@@ -258,7 +261,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -403,7 +406,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -646,12 +649,19 @@ $inv.invSumUpdate = function () {
};
let bds = tbl.children('tbody');
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx);
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
Set grouping: an item of Type 'set' is a header that claims the following items in this
block as its members until the next set header (mfr__items has no explicit member link). */
let citem = $inv.itemToContract(rrx);
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
@@ -663,7 +673,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
//});
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
});
let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -838,6 +848,53 @@ $inv.sp13b = () => {
}
tbl.trigger('fds.inv');
};
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
the caller. The invoice total comes from the registration balance, so per-item
totals here are purely presentational. */
$inv.itemToContract = function (rrx) {
rrx = rrx || {};
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
if (rrx.co && rrx.co.typ === 'osum') {
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
ci.desc = rrx.co.t || '';
ci.total_net = (rrx.net_val || 0);
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
/* heading / free-text line, no price */
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
ci.total_net = '';
} else {
/* normal priced item (incl. set headers, which carry their own set price or 0) */
ci.title = rrx.NameOrNumber || '';
ci.desc = rrx.Note || '';
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
ci.price_net = (rrx.net || 0);
ci.total_net = (rrx.net_val || 0);
}
return ci;
};
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
$inv.ssetmode = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
$inv.setSetmode = (mode) => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
};
$inv.sctp = () => {
let flds = $invcol.ctp;
$ocms.dlgform(flds, {
@@ -857,12 +914,32 @@ $inv.sctp = () => {
}, typedvalues: true
});
};
/* Normalises the editor's working model into the exact field names the C# backend
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
new.paymentterm, admin.CustomerId -> admin.customerid.
Originals are kept alongside; the source objects are not mutated. */
$inv.invcPayload = function (d) {
d = d || {};
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
nw.total_net = sms.ttn || 0;
nw.total_gross = sms.ttb || 0;
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
@@ -884,7 +961,7 @@ $inv.sprev = (change) => {
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
@@ -40,6 +40,12 @@
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
setmo: {
setprice: 'Set mit Preis Positionen ohne Preis',
itemprices: 'Positionen mit Preis Set als Überschrift',
setonly: 'Nur Set mit Preis Positionen ausgeblendet'
},
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
+2 -1
View File
@@ -36,7 +36,8 @@ let $bcol = {
{ name: 'EndToEndReference', label: 'Referenz', type: 'string' }
]),
bsu: new fields_definition('Kontobericht', 'Kontoberichte', [
{ name: 'bsu', label: 'Export der Buchungen', type: 'file', required: true, prop: { multiple: true } }
// Accepts both MT940 (SWIFT text: .sta/.mt940/.txt) and CAMT (ISO 20022 XML: .xml/.camt).
{ name: 'bsu', label: 'Export der Buchungen (MT940 oder CAMT)', type: 'file', required: true, prop: { multiple: true, accept: '.sta,.mt940,.txt,.xml,.camt,application/xml,text/xml,text/plain' } }
])
};
let gi = (n, c) => $$.sc(`glyphicon glyphicon-${n}`).aC(c);
+89 -6
View File
@@ -146,6 +146,12 @@ let $ict = {
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
setmo: {
setprice: 'Set mit Preis Positionen ohne Preis',
itemprices: 'Positionen mit Preis Set als Überschrift',
setonly: 'Nur Set mit Preis Positionen ausgeblendet'
},
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -637,6 +643,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
}
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
@@ -799,7 +808,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -944,7 +953,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -1187,12 +1196,19 @@ $inv.invSumUpdate = function () {
};
let bds = tbl.children('tbody');
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx);
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
Set grouping: an item of Type 'set' is a header that claims the following items in this
block as its members until the next set header (mfr__items has no explicit member link). */
let citem = $inv.itemToContract(rrx);
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
@@ -1204,7 +1220,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
//});
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
});
let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -1379,6 +1395,53 @@ $inv.sp13b = () => {
}
tbl.trigger('fds.inv');
};
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
the caller. The invoice total comes from the registration balance, so per-item
totals here are purely presentational. */
$inv.itemToContract = function (rrx) {
rrx = rrx || {};
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
if (rrx.co && rrx.co.typ === 'osum') {
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
ci.desc = rrx.co.t || '';
ci.total_net = (rrx.net_val || 0);
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
/* heading / free-text line, no price */
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
ci.total_net = '';
} else {
/* normal priced item (incl. set headers, which carry their own set price or 0) */
ci.title = rrx.NameOrNumber || '';
ci.desc = rrx.Note || '';
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
ci.price_net = (rrx.net || 0);
ci.total_net = (rrx.net_val || 0);
}
return ci;
};
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
$inv.ssetmode = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
$inv.setSetmode = (mode) => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
};
$inv.sctp = () => {
let flds = $invcol.ctp;
$ocms.dlgform(flds, {
@@ -1398,12 +1461,32 @@ $inv.sctp = () => {
}, typedvalues: true
});
};
/* Normalises the editor's working model into the exact field names the C# backend
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
new.paymentterm, admin.CustomerId -> admin.customerid.
Originals are kept alongside; the source objects are not mutated. */
$inv.invcPayload = function (d) {
d = d || {};
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
nw.total_net = sms.ttn || 0;
nw.total_gross = sms.ttb || 0;
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
@@ -1425,7 +1508,7 @@ $inv.sprev = (change) => {
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
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.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
setmo: {
setprice: 'Set mit Preis Positionen ohne Preis',
itemprices: 'Positionen mit Preis Set als Überschrift',
setonly: 'Nur Set mit Preis Positionen ausgeblendet'
},
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -618,6 +624,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('p13b') === true) {
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
}
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
@@ -780,7 +789,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -925,7 +934,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,ctp');
$inv.eM(false, true, 'iss,p13b,setm,ctp');
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
};
let bds = tbl.children('tbody');
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx);
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
Set grouping: an item of Type 'set' is a header that claims the following items in this
block as its members until the next set header (mfr__items has no explicit member link). */
let citem = $inv.itemToContract(rrx);
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
//});
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
});
let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2);
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
}
tbl.trigger('fds.inv');
};
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
the caller. The invoice total comes from the registration balance, so per-item
totals here are purely presentational. */
$inv.itemToContract = function (rrx) {
rrx = rrx || {};
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
if (rrx.co && rrx.co.typ === 'osum') {
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
ci.desc = rrx.co.t || '';
ci.total_net = (rrx.net_val || 0);
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
/* heading / free-text line, no price */
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
ci.total_net = '';
} else {
/* normal priced item (incl. set headers, which carry their own set price or 0) */
ci.title = rrx.NameOrNumber || '';
ci.desc = rrx.Note || '';
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
ci.price_net = (rrx.net || 0);
ci.total_net = (rrx.net_val || 0);
}
return ci;
};
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
$inv.ssetmode = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
$inv.setSetmode = (mode) => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
};
$inv.sctp = () => {
let flds = $invcol.ctp;
$ocms.dlgform(flds, {
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
}, typedvalues: true
});
};
/* Normalises the editor's working model into the exact field names the C# backend
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
new.paymentterm, admin.CustomerId -> admin.customerid.
Originals are kept alongside; the source objects are not mutated. */
$inv.invcPayload = function (d) {
d = d || {};
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
nw.total_net = sms.ttn || 0;
nw.total_gross = sms.ttb || 0;
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
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 =>
{
bool debug = FdsConfig.DebugDetails;
await mfr.UpdateIfNecessary_async(debug);
await mfr.UpdateRequested_async(debug);
await mfr.GetInvoiceFiles_async(debug);
await mfr.UpdateIfNecessary_async(debug, ct);
await mfr.UpdateRequested_async(debug, ct);
await mfr.GetInvoiceFiles_async(debug, ct);
})
};
+35 -20
View File
@@ -43,15 +43,19 @@ public class FdsMfr : IFdsMfr
None = 0
}
public async Task UpdateIfNecessary_async(bool debugDetails = false)
/// <summary>Max parallel invoice-file downloads (independent per file).</summary>
private const int InvoiceFileDownloadConcurrency = 4;
public async Task UpdateIfNecessary_async(bool debugDetails = false, CancellationToken cancellationToken = default)
{
using var mfr = new FdsMfrClient(_loggerFactory);
try
{
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename: "DebugDetail.txt");
await mfr.Update__Entitytables(debugDetails);
await mfr.Update__Entitytables(debugDetails, cancellationToken: cancellationToken);
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - completed", filename: "DebugDetail.txt");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
FdsDebug.DebugLog("UpdateIfNecessary_async - main unn", exc: ex);
@@ -59,15 +63,16 @@ public class FdsMfr : IFdsMfr
}
}
public async Task UpdateRequested_async(bool debugDetails = false)
public async Task UpdateRequested_async(bool debugDetails = false, CancellationToken cancellationToken = default)
{
using var mfr = new FdsMfrClient(_loggerFactory);
try
{
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - start awaited", filename: "DebugDetail.txt");
await mfr.Update__EntityRequests(debugDetails);
await mfr.Update__EntityRequests(debugDetails, cancellationToken);
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - completed", filename: "DebugDetail.txt");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
FdsDebug.DebugLog("UpdateRequested_async - main unn", exc: ex);
@@ -75,7 +80,7 @@ public class FdsMfr : IFdsMfr
}
}
public async Task GetInvoiceFiles_async(bool debugDetails = false)
public async Task GetInvoiceFiles_async(bool debugDetails = false, CancellationToken cancellationToken = default)
{
using var mfr = new FdsMfrClient(_loggerFactory);
try
@@ -86,35 +91,45 @@ public class FdsMfr : IFdsMfr
FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions());
if (dtbl.Count > 0)
{
foreach (DataRow ivrw in dtbl.DataTable.Rows)
{
string id = ivrw.nz("id"), docName = ivrw.nz("DocumentName"), fileurl = ivrw.nz("URI");
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(docName) && !string.IsNullOrEmpty(fileurl) && docName.EndsWith("pdf"))
var rows = dtbl.DataTable.Rows.Cast<DataRow>()
.Select(r => (id: r.nz("id"), docName: r.nz("DocumentName"), url: r.nz("URI")))
.Where(r => !string.IsNullOrEmpty(r.id) && !string.IsNullOrEmpty(r.docName)
&& !string.IsNullOrEmpty(r.url) && r.docName.EndsWith("pdf"))
.ToList();
int downloaded = 0;
// Files are independent → download (and store) in parallel with bounded concurrency.
await Parallel.ForEachAsync(rows,
new ParallelOptions { MaxDegreeOfParallelism = InvoiceFileDownloadConcurrency, CancellationToken = cancellationToken },
async (r, ct) =>
{
var fl = mfr.GetFile(fileurl);
if (fl != null && fl.Length > 0)
try
{
try
var fl = await mfr.GetFileAsync(r.url, throwErrorIfNotOk: false, cancellationToken: ct);
if (fl is { Length: > 0 })
{
await setSQLValue_async(
"EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;",
FdsShared.FDSConnectionString(),
SqlParameterList: new ParamList(
SQL_VarChar("@Id", id),
SQL_VarChar("@filename", docName),
SQL_VarChar("@Id", r.id),
SQL_VarChar("@filename", r.docName),
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
options: new FdsSqlOptions());
}
catch (Exception fsex)
{
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
Interlocked.Increment(ref downloaded);
}
}
}
}
catch (OperationCanceledException) { throw; }
catch (Exception fsex)
{
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
}
});
_logger.LogInformation("GetInvoiceFiles_async stored {Downloaded}/{Total} invoice files.", downloaded, rows.Count);
}
if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - completed", filename: "DebugDetail.txt");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
FdsDebug.DebugLog("GetInvoiceFiles_async - main unn", exc: ex);
+8 -2
View File
@@ -42,6 +42,9 @@ public class FdsMfrClient : IDisposable
public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) =>
_mfrClient.GetFile(address, throwErrorIfNotOk);
public Task<byte[]?> GetFileAsync(string address, bool throwErrorIfNotOk = true, CancellationToken cancellationToken = default) =>
_mfrClient.GetFileAsync(address, throwErrorIfNotOk, cancellationToken);
public async Task<ODataEnvelope> ReadOData(string address, bool throwErrorIfNotOk = true) =>
await _mfrClient.ReadOData(address, throwErrorIfNotOk);
@@ -374,7 +377,8 @@ public class FdsMfrClient : IDisposable
private static string NewDatatableSql(string tablename) =>
$"Select TOP(0) [setid] = CAST('' as varchar(50)), * FROM [dbo].[{tablename}];";
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null)
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null,
CancellationToken cancellationToken = default)
{
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
{
@@ -398,6 +402,7 @@ public class FdsMfrClient : IDisposable
{
foreach (DataRow rw in updateableTables.Select("updateneed > 0", "updateneed DESC"))
{
cancellationToken.ThrowIfCancellationRequested();
string etname = rw.nz("entity_name", "");
try
{
@@ -424,7 +429,7 @@ public class FdsMfrClient : IDisposable
catch (Exception exa) { dlg("outer frame", "", "", exa); }
}
public async Task Update__EntityRequests(bool debugDetails = false)
public async Task Update__EntityRequests(bool debugDetails = false, CancellationToken cancellationToken = default)
{
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
{
@@ -448,6 +453,7 @@ public class FdsMfrClient : IDisposable
{
foreach (DataRow rw in updateableRequests.Select("", "order"))
{
cancellationToken.ThrowIfCancellationRequested();
string etname = rw.nz("entity_name", "");
long tgtid = rw.nint64("Id", -1);
if (tgtid > -1 && !string.IsNullOrWhiteSpace(etname))
+3
View File
@@ -4,6 +4,9 @@
<name>Fuchs_DataService</name>
</assembly>
<members>
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
<summary>Max parallel invoice-file downloads (independent per file).</summary>
</member>
<member name="T:fds.FdsConfig">
<summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> built from appsettings.json.
+3 -3
View File
@@ -4,9 +4,9 @@ namespace fds;
public interface IFdsMfr
{
Task UpdateIfNecessary_async(bool debugDetails = false);
Task UpdateRequested_async(bool debugDetails = false);
Task GetInvoiceFiles_async(bool debugDetails = false);
Task UpdateIfNecessary_async(bool debugDetails = false, CancellationToken cancellationToken = default);
Task UpdateRequested_async(bool debugDetails = false, CancellationToken cancellationToken = default);
Task GetInvoiceFiles_async(bool debugDetails = false, CancellationToken cancellationToken = default);
FileInfo? GetReportDoc(ref byte[]? file, string reportid, bool debugDetails = false);
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);
+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
+22
View File
@@ -0,0 +1,22 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[bo_val]
(
@price numeric(10,3)
,@quantityhours numeric(10,3)
,@discount numeric(10,3)
,@VAT numeric(10,3)
)
RETURNS numeric(10,2)
AS
BEGIN
DECLARE @Ret numeric(10,2)
SET @RET = [dbo].[net_val](@price, @quantityhours, @discount) * (1 + (ISNULL(@vat, 19.0) * 0.01));
RETURN @RET;
END
+58
View File
@@ -0,0 +1,58 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[date_add]
(
@date date
,@addition varchar(10)
,@shift_to_first bit = 1
)
RETURNS date
AS
BEGIN
DECLARE @Ret date = @date;
SET @addition = LOWER(ISNULL(@addition,''));
DECLARE @negative bit = 0;
IF LEFT(@addition,1) = '-'
BEGIN
SET @negative = 1;
SET @addition = REPLACE(@addition, '-', '');
END
IF @addition like '[1-9]%[dwmqy]'
BEGIN
DECLARE @num int = TRY_CONVERT(int, LEFT(@addition, patindex('%[^0-9]%', @addition) - 1)) * IIF(@negative = 1, -1, 1), @unit varchar(2) = LOWER(RIGHT(@addition, LEN(@addition) - patindex('%[^0-9]%', @addition) + 1)) ;
if @num is not null
BEGIN
SET @ret = CASE WHEN ISNULL(@shift_to_first,1) = 1 THEN
CASE
WHEN @unit = 'y' THEN DATEADD(YEAR, @num - 1, dbo.date_nextFirst(@date, 'y'))
WHEN @unit = 'q' THEN DATEADD(QUARTER, @num - 1, dbo.date_nextFirst(@date, 'q'))
WHEN @unit = 'm' THEN DATEADD(Month, @num - 1, dbo.date_nextFirst(@date, 'm'))
WHEN @unit = 'w' THEN DATEADD(WEEK, @num - 1, dbo.date_nextFirst(@date, 'w'))
WHEN @unit = 'd' THEN DATEADD(DAY, @num - 1, dbo.date_nextFirst(@date, 'd'))
WHEN @unit in ('bd','wd') THEN [dbo].[AddBusinessDays](@date, @num)
ELSE @date
END
ELSE
CASE
WHEN @unit = 'y' THEN DATEADD(YEAR, @num, @date)
WHEN @unit = 'q' THEN DATEADD(QUARTER, @num, @date)
WHEN @unit = 'm' THEN DATEADD(Month, @num, @date)
WHEN @unit = 'w' THEN DATEADD(WEEK, @num, @date)
WHEN @unit = 'd' THEN DATEADD(DAY, @num, @date)
WHEN @unit in ('bd','wd') THEN [dbo].[AddBusinessDays](@date, @num)
ELSE @date
END
END;
END
END
RETURN @ret;
END
@@ -0,0 +1,27 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[date_addcustom]
(
@date date
,@phrase varchar(10)
)
RETURNS date
AS
BEGIN
DECLARE @returndate date, @numpart integer = TRY_PARSE(left(@phrase, patindex('%[^0-9]%', @phrase + '.') - 1) as integer), @defpart varchar(10) = LOWER(RIGHT(@phrase, LEN(@phrase) - patindex('%[^0-9]%', @phrase + '.') + 1));
SET @returndate = CASE WHEN @numpart is null then null
WHEN @defpart = 'wd' THEN [dbo].[AddBusinessDays](@date, @numpart)
WHEN @defpart = 'd' THEN DATEADD(DAY, @numpart, @date)
WHEN @defpart = 'y' THEN DATEADD(YEAR, @numpart, @date)
WHEN @defpart = 'm' THEN DATEADD(Month, @numpart, @date)
WHEN @defpart = 'wk' THEN DATEADD(WEEK, @numpart, @date)
ELSE @date
END;
RETURN @returndate;
END
@@ -0,0 +1,18 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[date_monthend]
(
@date date
)
RETURNS date
AS
BEGIN
DECLARE @monthend date = CASE WHEN @date is null THEN NULL ELSE DATEADD(DAY, -1, DATEADD(MONTH, 1, DATEFROMPARTS(YEAR(@date), MONTH(@date),1))) END;
return @monthend;
END
@@ -0,0 +1,18 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[date_monthfirst]
(
@date date
)
RETURNS date
AS
BEGIN
DECLARE @monthfirst date = CASE WHEN @date is null THEN NULL ELSE DATEFROMPARTS(YEAR(@date), MONTH(@date), 1) END;
return @monthfirst;
END
@@ -0,0 +1,29 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[date_nextFirst]
(
@date date
,@unit varchar(1)
)
RETURNS date
AS
BEGIN
DECLARE @ret date;
SET @unit = LOWER(ISNULL(@unit, ''));
SET @ret = CASE
WHEN @unit = 'y' THEN DATEADD(YEAR, 1, DATEFROMPARTS(YEAR(@date), 1,1))
WHEN @unit = 'q' THEN DATEADD(MONTH, 4 - Month(@date) % 3, DATEFROMPARTS(YEAR(@date),MONTH(@date),1))
WHEN @unit = 'm' THEN DATEADD(MONTH, 1, DATEFROMPARTS(YEAR(@date), MONTH(@date),1))
WHEN @unit = 'w' THEN DATEADD(WEEK, 1, [dbo].[date_weekfirst](@date))
WHEN @unit = 'd' THEN DATEADD(DAY, 1, @date)
ELSE NULL
END;
RETURN @ret
END
@@ -0,0 +1,17 @@
-- =============================================
-- Author: Dr. Stefan Ott
-- Create date: 31.01.2013
-- Description: first day of week
-- =============================================
CREATE FUNCTION [dbo].[date_weekend]
(
@TargetDate date
)
RETURNS date
AS
BEGIN
DECLARE @Corretor smallint = -((@@DATEFIRST % 7) - 1);
DECLARE @ret date = CASE WHEN @TargetDate is null THEN null ELSE dateadd(d ,-((datepart(weekday,@TargetDate) - 1 - @Corretor)%7), @TargetDate) END;
return DATEADD(DAY, 6, @ret);
END
@@ -0,0 +1,19 @@

-- =============================================
-- Author: Dr. Stefan Ott
-- Create date: 31.01.2013
-- Description: first day of week
-- =============================================
CREATE FUNCTION [dbo].[date_weekfirst]
(
@TargetDate date
)
RETURNS date
AS
BEGIN
DECLARE @Corretor smallint = -((@@DATEFIRST % 7) - 1);
DECLARE @ret date = CASE WHEN @TargetDate is null THEN null ELSE dateadd(d ,-((datepart(weekday,@TargetDate) - 1 - @Corretor)%7), @TargetDate) END;
return @ret;
END
@@ -0,0 +1,18 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[fds__admin_reminderSettings]
(
)
RETURNS TABLE
AS
RETURN
(
WITH rs as ( SELECT * FROM [dbo].[fds__admin_settings] WHERE [type] = 'reminder' )
SELECT [stage1] = (SELECT TOP(1) [value] FROM rs WHERE [key] = 1)
, [stage2] = (SELECT TOP(1) [value] FROM rs WHERE [key] = 2)
, [stage3] = (SELECT TOP(1) [value] FROM rs WHERE [key] = 3)
)
@@ -0,0 +1,28 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_DocumentName]
(
@InvoiceId varchar(10)
, @InvoiceType char(1)
, @InvoiceTitle varchar(100)
, @DateFinalized datetime
)
RETURNS varchar(50)
AS
BEGIN
DECLARE @doc varchar(50);
SET @doc = CASE WHEN @DateFinalized is null THEN ''
WHEN ISNULL(@InvoiceTitle,'') <> '' THEN @InvoiceTitle + ' ' + @InvoiceId
WHEN @InvoiceType = 'a' THEN 'Abschlagsrechnung ' + @InvoiceId
WHEN @InvoiceType = 'c' THEN 'Stornorechnung ' + @InvoiceId
WHEN @InvoiceType = 'f' THEN 'Schlussrechnung ' + @InvoiceId
ELSE 'Rechnung ' + @InvoiceId
END;
RETURN @doc + '.pdf';
END
@@ -0,0 +1,22 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_IntermediateIsAllocatedToOther]
(
@Intermediate_InvID varchar(10)
,@Target_InvID varchar(10)
)
RETURNS bit
AS
BEGIN
DECLARE @IsA bit = CASE WHEN EXISTS (SELECT * FROM [dbo].[fds__invoices] as intermediates JOIN [dbo].[fds__invoice_details] as id on intermediates.[Id] = id.[InvId] and id.[AllocatedTo_InvId] is not null
JOIN [dbo].[fds__invoices] as non_intermediates ON id.[AllocatedTo_InvId] = non_intermediates.[Id] and id.[AllocatedTo_InvId] is not null
WHERE intermediates.[Id] = @Intermediate_InvID and (@Target_InvID is null or non_intermediates.[Id] <> @Target_InvID))
THEN 1 ELSE 0 END;
RETURN @isA;
END
@@ -0,0 +1,30 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_InvoiceIDs_mfr]
(
@invoiceid varchar(255),
@other varchar(1000)
)
RETURNS varchar(1000)
AS
BEGIN
DECLARE @RET varchar(1000) = REPLACE(LTRIM(ISNULL(@invoiceid,'')),'Preview','');
IF @RET <> ''
SET @RET = TRIM(REPLACE(@RET, ' ', ' '));
IF @RET <> ''
SET @RET = REPLACE(@RET, ' ',' (mfr)' + CHAR(10)) + ' (mfr)';
IF @RET <> '' AND ISNULL(@other, '') <> ''
SET @RET = @RET + CHAR(10);
SET @RET = @RET + ISNULL(@other, '');
RETURN @RET;
END
@@ -0,0 +1,21 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[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 [dbo].[fds__invoices] WHERE [InvoiceId] = @nme),'');
RETURN @id;
END
@@ -0,0 +1,30 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_InvoicePaymentAmount]
(
@InvID varchar(15)
)
RETURNS numeric(9,2)
AS
BEGIN
DECLARE @RES numeric(9,2);
DECLARE @invoice_idlist [dbo].[fds__tt__idlist_vchar];
INSERT INTO @invoice_idlist VALUES(@invid);
With bs as (
SELECT * FROM [dbo].[fds__fn_bankingtransactions](null,null, null, null,@invoice_idlist)
), b as (
SELECT
[InvID] = @InvID
,[amount] = SUM(ISNULL(bs.[amount], 0.0))
FROM bs
WHERE bs.[invid] = @InvID
)
SELECT TOP(1) @RES = [amount] FROM b;
RETURN ISNULL(@RES, 0.0);
END
@@ -0,0 +1,37 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_InvoicePaymentAmount_full]
(
@InvID varchar(15)
)
RETURNS numeric(9,2)
AS
BEGIN
DECLARE @RES numeric(9,2);
DECLARE @invoice_idlist [dbo].[fds__tt__idlist_vchar];
INSERT INTO @invoice_idlist VALUES(@invid);
With bs as (
SELECT * FROM [dbo].[fds__fn_bankingtransactions](null,null, null, null,@invoice_idlist)
), ci as(
SELECT [InvId] = d.[StornoTo_InvId], [amount] = ISNULL(i.[InvoiceBalance],0.0) * -1 FROM [dbo].[fds__invoices] as i JOIN [dbo].[fds__invoice_details] as d on i.[Id] = d.[InvId] where d.[StornoTo_InvId] = @InvID AND i.[IsFinal] = 1
), tp as (
SELECT [InvId], [amount] from bs where [invid] = @InvID
union
SELECT [InvId], [amount] from ci
), b as (
SELECT
[InvID] = @InvID
,[amount] = SUM(ISNULL(tp.[amount], 0.0))
FROM tp
WHERE tp.[invid] = @InvID
)
SELECT TOP(1) @RES = [amount] FROM b;
RETURN ISNULL(@RES, 0.0);
END
@@ -0,0 +1,30 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_ReminderDocumentName]
(
@ReminderType char(1)
, @ReminderTitle varchar(100)
, @DateFinalized datetime
, @InvoiceId varchar(10)
)
RETURNS varchar(50)
AS
BEGIN
DECLARE @doc varchar(50);
SET @doc = CASE WHEN @DateFinalized is null THEN ''
WHEN ISNULL(@ReminderTitle,'') <> '' THEN @ReminderTitle
WHEN @ReminderType = 'f' THEN 'Zahlungserinnerung ' + @InvoiceId
WHEN @ReminderType = 'c' THEN 'Mahnung ' + @InvoiceId
WHEN @ReminderType = 'f' THEN 'Mahnung ' + @InvoiceId
ELSE 'Zahlungserinnerung ' + @InvoiceId
END;
RETURN @doc + '.pdf';
END
@@ -0,0 +1,23 @@

CREATE FUNCTION [dbo].[fds__fn_bankingtransaction_id] (
)
RETURNS varchar(10)
AS
BEGIN
DECLARE @NewAccount varchar(7) = [dbo].[ocms_fn_generatePassword] (8,0,1,0);
WHILE EXISTS( SELECT * FROM [dbo].[fds__bankingtransactions] WITH (SERIALIZABLE) WHERE [taID] = @NewAccount)
BEGIN
SET @NewAccount = [dbo].[ocms_fn_generatePassword] (8,0,1,0);
END
RETURN @NewAccount;
END;
@@ -0,0 +1,88 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[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
(
[taID] varchar(10)
,[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 [taID],[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)
AND (bt_.[DebitCreditMark] in ('C')
AND NOT (ISNULL(bt_.[AccountNumberOfPayer],'') IN ('DE52301502000002091478') AND ISNULL(bt_.[SepaRemittanceInformation],'') like ('%umbuchung%'))
AND NOT (ISNULL(bt_.[AccountNumberOfPayer],'') IN ('DE23501108006161606386') AND ISNULL(bt_.[SepaRemittanceInformation],'') like ('%ebay%'))
AND NOT (ISNULL(bt_.[SepaRemittanceInformation],'') like ('%Umsatzsteuer%'))
AND ISNULL(bt_.AccountNumberOfPayer,'') <> ''
)
), bs0manu as (
SELECT bs.[taID], [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.[taID] = bs.[taID]
WHERE bs.assigned_invoice_id is not null
), bs1 as (
SELECT * FROM bs0manu
UNION
SELECT bs.[taID], [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.[taID] = bs.[taID]
WHERE bs.auto_invoice_id is not null
and bt.[taID] NOT IN (SELECT [taID] FROM bs0manu)
), bs as (
SELECT [taID], [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 [taID], [invid]
)
INSERT INTO @bankingtransactions
SELECT
bt.[taID]
,[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(fi.isExternal = 0, 1, 0)
FROM bt
JOIN bs ON bt.[taID] = bs.[taID]
LEFT JOIN [dbo].[fds__bankingtransactions_settings] as bs2 ON bt.[taID] = bs2.[taID]
LEFT JOIN [dbo].[fds__invoices] as fi on bs.[invid] = fi.[id] AND fi.[DateFinalized] BETWEEN @invoice_startdate AND @invoice_enddate
RETURN
END
@@ -0,0 +1,35 @@

-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date,,>
-- Description: <Description,,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_bankingtransactions_perInvoice]
(
@startdate date = NULL
,@enddate date = NULL
)
RETURNS @bankingtransactions TABLE
(
[InvId] varchar(15)
,[Amount] numeric(9,2)
,[manu] bit
)
AS
BEGIN
-- Fill the table variable with the rows for your result set
DECLARE @invoice_idlist [dbo].[fds__tt__idlist_vchar];
INSERT INTO @bankingtransactions
SELECT
[invid]
, [amount] = SUM(ISNULL([amount], 0.0))
, [manu] = CAST(MAX(ISNULL(CAST([manu] as int),0)) as bit)
FROM dbo.fds__fn_bankingtransactions(null, null, @startdate, @enddate, @invoice_idlist) -- start and enddates will filter by invoice date and not by banking transactions' value dates
GROUP BY [invid];
RETURN
END
@@ -0,0 +1,27 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_combineAddress]
(
@AddressString nvarchar(255)
,@AddressString2 nvarchar(255)
,@AddressString3 nvarchar(255)
,@Postal nvarchar(255)
,@City nvarchar(255)
,@State nvarchar(255)
,@Country nvarchar(255)
)
RETURNS nvarchar(1000)
AS
BEGIN
DECLARE @address nvarchar(1000) = STUFF( (SELECT (CHAR(10) + [t]) as [text()] FROM
(SELECT * FROM (VALUES(@AddressString), (@AddressString2), (@AddressString3), (LTRIM(RTRIM(ISNULL(@postal, '') + ' ' + ISNULL(@city, '')))), (@state), (@country)) z ([t]) WHERE ISNULL([t], '') <> '')y
FOR XML PATH
, TYPE).value('.[1]','nvarchar(1000)')
, 1, 1, '');
RETURN @address;
END
@@ -0,0 +1,19 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[fds__fn_creanupTransactionInfo]
(
@info varchar(255)
)
RETURNS varchar(255)
AS
BEGIN
DECLARE @RET varchar(255);
SET @RET = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@info, 'R ', 'R'), 'Rg', 'R'), '2 0', '20'), CHAR(10),''), CHAR(13), ''), '/', ' ')
RETURN ISNULL(@RET, '');
END
@@ -0,0 +1,22 @@

CREATE FUNCTION [dbo].[fds__fn_invoice-srq_id] (
)
RETURNS varchar(10)
AS
BEGIN
DECLARE @NewID varchar(7) = [dbo].[ocms_fn_generatePassword] (7,0,1,0);
WHILE EXISTS( SELECT * FROM [dbo].[fds__invoice_servicerequests] WITH (SERIALIZABLE) WHERE [id] = @NewID)
BEGIN
SET @NewID = [dbo].[ocms_fn_generatePassword] (7,0,1,0);
END
RETURN @NewID;
END;
@@ -0,0 +1,19 @@
-- =============================================
-- Author: <Author,,Name>
-- Create date: <Create Date, ,>
-- Description: <Description, ,>
-- =============================================
CREATE FUNCTION [dbo].[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;
RETURN @ret;
END

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