Compare commits
16 Commits
c8a4d18f1a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ecf97ed29 | |||
| ae5c90b915 | |||
| 7653b2f0b5 | |||
| bfc695ed6a | |||
| ebdb92713a | |||
| c358fdbdb2 | |||
| 2c17171e77 | |||
| 2a75664625 | |||
| 27becf7c68 | |||
| a00ec1da3b | |||
| 10ecdfa2e4 | |||
| 1376779224 | |||
| 7ee4e5302a | |||
| e04d590c3a | |||
| 8dee630abb | |||
| c81619fa53 |
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>CAMTParser</RootNamespace>
|
||||
<AssemblyName>CAMTParser</AssemblyName>
|
||||
<Description>ISO 20022 CAMT (camt.052 / camt.053 / camt.054) bank statement parser — a variant of the MT940Parser producing an equivalent statement/entry model.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace CAMTParser;
|
||||
|
||||
/// <summary>The CAMT message flavour a document was recognised as.</summary>
|
||||
public enum CamtDocumentType
|
||||
{
|
||||
Unknown = 0,
|
||||
/// <summary>camt.052 — Bank to Customer Account Report (intraday).</summary>
|
||||
Camt052,
|
||||
/// <summary>camt.053 — Bank to Customer Statement (end of day).</summary>
|
||||
Camt053,
|
||||
/// <summary>camt.054 — Bank to Customer Debit/Credit Notification.</summary>
|
||||
Camt054
|
||||
}
|
||||
|
||||
/// <summary>Debit/credit indicator, mirroring the MT940 parser's marks.</summary>
|
||||
public enum CamtDebitCreditMark
|
||||
{
|
||||
Credit,
|
||||
Debit,
|
||||
ReverseCredit,
|
||||
ReverseDebit
|
||||
}
|
||||
|
||||
/// <summary>A parsed CAMT account statement/report/notification.</summary>
|
||||
public sealed class CamtStatement
|
||||
{
|
||||
/// <summary>Account IBAN (or other account id when IBAN is absent).</summary>
|
||||
public string AccountIdentification { get; set; } = "";
|
||||
/// <summary>Statement currency (from the account or balances), when available.</summary>
|
||||
public string? Currency { get; set; }
|
||||
public CamtDocumentType DocumentType { get; set; } = CamtDocumentType.Unknown;
|
||||
public List<CamtEntry> Entries { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single booked transaction line. Field names are aligned with the
|
||||
/// banking DataTable columns so the host can map both CAMT and MT940 uniformly.
|
||||
/// </summary>
|
||||
public sealed class CamtEntry
|
||||
{
|
||||
public decimal? Amount { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public CamtDebitCreditMark Mark { get; set; }
|
||||
public DateTime? EntryDate { get; set; }
|
||||
public DateTime? ValueDate { get; set; }
|
||||
|
||||
public string? BankReference { get; set; }
|
||||
public string? EndToEndReference { get; set; }
|
||||
public string? MandateReference { get; set; }
|
||||
public string? CustomerReference { get; set; }
|
||||
public string? CreditorReference { get; set; }
|
||||
|
||||
/// <summary>Counterparty (payer for credits, payee for debits) display name.</summary>
|
||||
public string? CounterpartyName { get; set; }
|
||||
/// <summary>Counterparty IBAN, when present.</summary>
|
||||
public string? CounterpartyIban { get; set; }
|
||||
/// <summary>Counterparty agent BIC, when present.</summary>
|
||||
public string? CounterpartyBic { get; set; }
|
||||
|
||||
/// <summary>Unstructured remittance information (joined RmtInf/Ustrd).</summary>
|
||||
public string? RemittanceUnstructured { get; set; }
|
||||
/// <summary>Structured remittance information (RmtInf/Strd), flattened to text.</summary>
|
||||
public string? RemittanceStructured { get; set; }
|
||||
/// <summary>Additional entry information (AddtlNtryInf).</summary>
|
||||
public string? AdditionalInfo { get; set; }
|
||||
/// <summary>Bank transaction code (Domn/Fmly/SubFmly or Prtry), as text.</summary>
|
||||
public string? BankTransactionCode { get; set; }
|
||||
|
||||
/// <summary>True when only unstructured remittance information is present.</summary>
|
||||
public bool IsUnstructuredData =>
|
||||
!string.IsNullOrEmpty(RemittanceUnstructured) && string.IsNullOrEmpty(RemittanceStructured);
|
||||
|
||||
public string MarkAbbreviation => Mark switch
|
||||
{
|
||||
CamtDebitCreditMark.Credit => "C",
|
||||
CamtDebitCreditMark.Debit => "D",
|
||||
CamtDebitCreditMark.ReverseCredit => "RC",
|
||||
CamtDebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace CAMTParser;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for ISO 20022 CAMT bank statements (camt.052 / camt.053 / camt.054).
|
||||
/// A variant of the MT940 <c>Parser</c> producing an equivalent statement/entry
|
||||
/// model. Element lookups are namespace-agnostic (matched by local name), so the
|
||||
/// same code handles every camt schema version (…001.02, …001.08, etc.).
|
||||
/// </summary>
|
||||
public sealed class CamtParser
|
||||
{
|
||||
/// <summary>Cheap content sniff: is this payload XML (and therefore a CAMT candidate)?</summary>
|
||||
public static bool LooksLikeXml(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
int i = 0;
|
||||
// skip UTF-8 BOM
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) i = 3;
|
||||
for (; i < bytes.Length; i++)
|
||||
{
|
||||
byte b = bytes[i];
|
||||
if (b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or 0xFF or 0xFE) continue;
|
||||
return b == (byte)'<';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Heuristic: does the XML look like a CAMT document?</summary>
|
||||
public static bool LooksLikeCamt(string xml) =>
|
||||
xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
|
||||
xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public List<CamtStatement> Parse(Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
return Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
public List<CamtStatement> Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
|
||||
|
||||
public List<CamtStatement> Parse(string xml)
|
||||
{
|
||||
var result = new List<CamtStatement>();
|
||||
if (string.IsNullOrWhiteSpace(xml)) return result;
|
||||
|
||||
XDocument doc;
|
||||
try { doc = XDocument.Parse(xml); }
|
||||
catch (System.Xml.XmlException ex) { throw new FormatException("Invalid CAMT XML.", ex); }
|
||||
|
||||
var root = doc.Root;
|
||||
if (root is null) return result;
|
||||
|
||||
var docType = DetectType(xml, root);
|
||||
|
||||
// Stmt (053) / Rpt (052) / Ntfctn (054)
|
||||
foreach (var stmtEl in root.DescendantsLocal("Stmt")
|
||||
.Concat(root.DescendantsLocal("Rpt"))
|
||||
.Concat(root.DescendantsLocal("Ntfctn")))
|
||||
{
|
||||
var stmt = new CamtStatement { DocumentType = docType };
|
||||
var acct = stmtEl.ChildLocal("Acct");
|
||||
stmt.AccountIdentification = AccountId(acct);
|
||||
stmt.Currency = acct?.ChildLocal("Ccy")?.Value?.Trim();
|
||||
|
||||
foreach (var ntry in stmtEl.ChildrenLocal("Ntry"))
|
||||
ParseEntry(ntry, stmt);
|
||||
|
||||
result.Add(stmt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CamtDocumentType DetectType(string xml, XElement root)
|
||||
{
|
||||
string probe = xml.Length > 4000 ? xml[..4000] : xml;
|
||||
if (probe.Contains("camt.052", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrAcctRpt").Any()) return CamtDocumentType.Camt052;
|
||||
if (probe.Contains("camt.054", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrDbtCdtNtfctn").Any()) return CamtDocumentType.Camt054;
|
||||
if (probe.Contains("camt.053", StringComparison.OrdinalIgnoreCase) ||
|
||||
root.DescendantsLocal("BkToCstmrStmt").Any()) return CamtDocumentType.Camt053;
|
||||
return CamtDocumentType.Unknown;
|
||||
}
|
||||
|
||||
private static void ParseEntry(XElement ntry, CamtStatement stmt)
|
||||
{
|
||||
bool reversal = string.Equals(ntry.ChildLocal("RvslInd")?.Value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
string? ntryCdtDbt = ntry.ChildLocal("CdtDbtInd")?.Value?.Trim();
|
||||
DateTime? entryDate = ParseDate(ntry.ChildLocal("BookgDt"));
|
||||
DateTime? valueDate = ParseDate(ntry.ChildLocal("ValDt"));
|
||||
string? bankRef = ntry.ChildLocal("NtryRef")?.Value?.Trim()
|
||||
?? ntry.ChildLocal("AcctSvcrRef")?.Value?.Trim();
|
||||
string? addtlInfo = ntry.ChildLocal("AddtlNtryInf")?.Value?.Trim();
|
||||
string? bkTxCd = BankTxCode(ntry.ChildLocal("BkTxCd"));
|
||||
var (ntryAmt, ntryCcy) = ParseAmount(ntry.ChildLocal("Amt"));
|
||||
|
||||
var txDetails = ntry.ChildLocal("NtryDtls")?.ChildrenLocal("TxDtls").ToList() ?? new List<XElement>();
|
||||
|
||||
if (txDetails.Count == 0)
|
||||
{
|
||||
// No transaction detail block — emit a single entry from the Ntry level.
|
||||
stmt.Entries.Add(new CamtEntry
|
||||
{
|
||||
Amount = ntryAmt,
|
||||
Currency = ntryCcy ?? stmt.Currency,
|
||||
Mark = MarkOf(ntryCdtDbt, reversal),
|
||||
EntryDate = entryDate,
|
||||
ValueDate = valueDate,
|
||||
BankReference = bankRef,
|
||||
AdditionalInfo = addtlInfo,
|
||||
BankTransactionCode = bkTxCd
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var tx in txDetails)
|
||||
{
|
||||
var (txAmt, txCcy) = ParseAmount(tx.ChildLocal("Amt"));
|
||||
string? cdtDbt = tx.ChildLocal("CdtDbtInd")?.Value?.Trim() ?? ntryCdtDbt;
|
||||
var mark = MarkOf(cdtDbt, reversal);
|
||||
bool isCredit = mark is CamtDebitCreditMark.Credit or CamtDebitCreditMark.ReverseCredit;
|
||||
|
||||
var refs = tx.ChildLocal("Refs");
|
||||
var rmtInf = tx.ChildLocal("RmtInf");
|
||||
var rltdPties = tx.ChildLocal("RltdPties");
|
||||
var rltdAgts = tx.ChildLocal("RltdAgts");
|
||||
|
||||
// Counterparty: payer (Dbtr) for incoming credits, payee (Cdtr) for outgoing debits.
|
||||
string partyTag = isCredit ? "Dbtr" : "Cdtr";
|
||||
string acctTag = isCredit ? "DbtrAcct" : "CdtrAcct";
|
||||
string agtTag = isCredit ? "DbtrAgt" : "CdtrAgt";
|
||||
|
||||
stmt.Entries.Add(new CamtEntry
|
||||
{
|
||||
Amount = txAmt ?? ntryAmt,
|
||||
Currency = txCcy ?? ntryCcy ?? stmt.Currency,
|
||||
Mark = mark,
|
||||
EntryDate = entryDate,
|
||||
ValueDate = valueDate,
|
||||
BankReference = bankRef,
|
||||
EndToEndReference = refs?.ChildLocal("EndToEndId")?.Value?.Trim().NullIfNa(),
|
||||
MandateReference = refs?.ChildLocal("MndtId")?.Value?.Trim(),
|
||||
CustomerReference = refs?.ChildLocal("InstrId")?.Value?.Trim()
|
||||
?? refs?.ChildLocal("AcctSvcrRef")?.Value?.Trim(),
|
||||
CreditorReference = rmtInf?.ChildLocal("Strd")?.ChildLocal("CdtrRefInf")?.ChildLocal("Ref")?.Value?.Trim(),
|
||||
CounterpartyName = rltdPties?.ChildLocal(partyTag)?.ChildLocal("Nm")?.Value?.Trim(),
|
||||
CounterpartyIban = rltdPties?.ChildLocal(acctTag)?.ChildLocal("Id")?.ChildLocal("IBAN")?.Value?.Trim(),
|
||||
CounterpartyBic = rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BICFI")?.Value?.Trim()
|
||||
?? rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BIC")?.Value?.Trim(),
|
||||
RemittanceUnstructured = JoinUstrd(rmtInf),
|
||||
RemittanceStructured = FlattenStrd(rmtInf?.ChildLocal("Strd")),
|
||||
AdditionalInfo = addtlInfo,
|
||||
BankTransactionCode = bkTxCd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──────────────────────────────────────────────────────
|
||||
private static CamtDebitCreditMark MarkOf(string? cdtDbtInd, bool reversal)
|
||||
{
|
||||
bool credit = string.Equals(cdtDbtInd, "CRDT", StringComparison.OrdinalIgnoreCase);
|
||||
if (reversal) return credit ? CamtDebitCreditMark.ReverseCredit : CamtDebitCreditMark.ReverseDebit;
|
||||
return credit ? CamtDebitCreditMark.Credit : CamtDebitCreditMark.Debit;
|
||||
}
|
||||
|
||||
private static (decimal? amount, string? ccy) ParseAmount(XElement? amt)
|
||||
{
|
||||
if (amt is null) return (null, null);
|
||||
string? ccy = amt.Attribute("Ccy")?.Value?.Trim();
|
||||
return decimal.TryParse(amt.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)
|
||||
? (d, ccy) : (null, ccy);
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(XElement? dateContainer)
|
||||
{
|
||||
if (dateContainer is null) return null;
|
||||
string? raw = dateContainer.ChildLocal("Dt")?.Value?.Trim()
|
||||
?? dateContainer.ChildLocal("DtTm")?.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
private static string AccountId(XElement? acct)
|
||||
{
|
||||
var id = acct?.ChildLocal("Id");
|
||||
return id?.ChildLocal("IBAN")?.Value?.Trim()
|
||||
?? id?.ChildLocal("Othr")?.ChildLocal("Id")?.Value?.Trim()
|
||||
?? "";
|
||||
}
|
||||
|
||||
private static string? BankTxCode(XElement? bkTxCd)
|
||||
{
|
||||
if (bkTxCd is null) return null;
|
||||
var domn = bkTxCd.ChildLocal("Domn");
|
||||
if (domn is not null)
|
||||
{
|
||||
string? d = domn.ChildLocal("Cd")?.Value?.Trim();
|
||||
string? f = domn.ChildLocal("Fmly")?.ChildLocal("Cd")?.Value?.Trim();
|
||||
string? s = domn.ChildLocal("Fmly")?.ChildLocal("SubFmlyCd")?.Value?.Trim();
|
||||
string joined = string.Join("/", new[] { d, f, s }.Where(x => !string.IsNullOrEmpty(x)));
|
||||
if (!string.IsNullOrEmpty(joined)) return joined;
|
||||
}
|
||||
return bkTxCd.ChildLocal("Prtry")?.ChildLocal("Cd")?.Value?.Trim();
|
||||
}
|
||||
|
||||
private static string? JoinUstrd(XElement? rmtInf)
|
||||
{
|
||||
if (rmtInf is null) return null;
|
||||
var parts = rmtInf.ChildrenLocal("Ustrd").Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static string? FlattenStrd(XElement? strd)
|
||||
{
|
||||
if (strd is null) return null;
|
||||
var parts = strd.Descendants().Where(e => !e.HasElements)
|
||||
.Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(" ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CamtXmlExtensions
|
||||
{
|
||||
public static XElement? ChildLocal(this XElement? e, string localName) =>
|
||||
e?.Elements().FirstOrDefault(x => x.Name.LocalName == localName);
|
||||
|
||||
public static IEnumerable<XElement> ChildrenLocal(this XElement? e, string localName) =>
|
||||
e?.Elements().Where(x => x.Name.LocalName == localName) ?? Enumerable.Empty<XElement>();
|
||||
|
||||
public static IEnumerable<XElement> DescendantsLocal(this XElement e, string localName) =>
|
||||
e.Descendants().Where(x => x.Name.LocalName == localName);
|
||||
|
||||
public static string? NullIfNa(this string? s) =>
|
||||
string.IsNullOrEmpty(s) || s.Equals("NOTPROVIDED", StringComparison.OrdinalIgnoreCase) ? null : s;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# CLAUDE.md — Project instructions for Claude Code
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file and **`.github/copilot-instructions.md`** are two views of the same
|
||||
> project rules and **must stay in sync**. When you change a shared rule
|
||||
> (architecture, coding standards, configuration, libraries, secrets,
|
||||
> observability, testing), make the equivalent change in **both files in the
|
||||
> same commit**. This file may add Claude Code / workflow specifics; the shared
|
||||
> project facts must match `copilot-instructions.md`.
|
||||
|
||||
## Project Overview
|
||||
- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`.
|
||||
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`).
|
||||
- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project.
|
||||
|
||||
## Build & Test (workflow)
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
|
||||
|
||||
## Coding Standards
|
||||
- C# only. Modern, performance-oriented .NET 10 (async/await, LINQ, DI).
|
||||
- Keep files ≤ 400 (max 600) lines; refactor larger files into focused classes.
|
||||
- PascalCase types/methods, camelCase locals/params.
|
||||
|
||||
## Configuration
|
||||
- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`.
|
||||
- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration.
|
||||
- `appsettings.Development.json` (git-ignored) overrides secrets locally.
|
||||
|
||||
## Libraries
|
||||
- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only.
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
## MFR ERP integration
|
||||
- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its contract (base URLs, auth, OData conventions, pagination, error/retry, deep-create + document-upload) is in **`MFR_RESTClient/Docs/mfr_interface_description.md`** — read it before changing the client.
|
||||
- HTTP Basic auth; configurable timeout; idempotent GETs retry on transient errors (429/5xx, network/timeout) with backoff. Create clients via `IMfrClientFactory`. Active project is `MFR_RESTClient.csproj` (legacy `.vbproj` removed).
|
||||
|
||||
## Database
|
||||
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
|
||||
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
|
||||
|
||||
## Bank statement parsing (MT940 + CAMT)
|
||||
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
||||
- `BankingService.ParseToDatatable` auto-detects (XML → CAMT, else MT940) → `fds__tt__bankingtransactions`. `bam/up` + the frontend accept both formats. `CAMTParser` is namespace-agnostic. Keep both column mappings aligned with the banking schema.
|
||||
|
||||
## Observability
|
||||
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
|
||||
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings).
|
||||
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. For each service/handler change, add tests for **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled). Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||
|
||||
## Secrets (Azure Key Vault)
|
||||
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
||||
|
||||
## Documentation map
|
||||
- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes).
|
||||
- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide.
|
||||
- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract.
|
||||
- `.github/instructions/*.instructions.md` — domain-specific contributor guidance.
|
||||
+19
-14
@@ -1,17 +1,20 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using programmersdigest.MT940Parser;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Banking helper robustness tests.
|
||||
/// Banking service robustness tests.
|
||||
/// </summary>
|
||||
public class BankingDebitCreditMarkTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
[Theory]
|
||||
[InlineData(DebitCreditMark.Credit, "C")]
|
||||
[InlineData(DebitCreditMark.Debit, "D")]
|
||||
@@ -19,18 +22,20 @@ public class BankingDebitCreditMarkTests
|
||||
[InlineData(DebitCreditMark.ReverseDebit, "RD")]
|
||||
public void DebitCreditMarkAbb_ReturnsExpected(DebitCreditMark mark, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Banking.DebitCreditMarkAbb(mark));
|
||||
Assert.Equal(expected, Svc.DebitCreditMarkAbb(mark));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebitCreditMarkAbb_UndefinedValue_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", Banking.DebitCreditMarkAbb((DebitCreditMark)999));
|
||||
Assert.Equal("", Svc.DebitCreditMarkAbb((DebitCreditMark)999));
|
||||
}
|
||||
}
|
||||
|
||||
public class BankingParseToDatatableTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
private static readonly string MinimalMT940 =
|
||||
"\r\n:20:STARTUMSE\r\n" +
|
||||
":25:DE12345678901234567890\r\n" +
|
||||
@@ -48,7 +53,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_ReturnsOneRow()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(1, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasAccountColumn()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("AccountIdentification"));
|
||||
Assert.Equal("DE12345678901234567890", table.Rows[0]["AccountIdentification"]);
|
||||
}
|
||||
@@ -65,7 +70,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasAmountColumn()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("Amount"));
|
||||
Assert.Equal(500m, table.Rows[0]["Amount"]);
|
||||
}
|
||||
@@ -74,7 +79,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_ValidMT940_HasDebitCreditMark()
|
||||
{
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal("C", table.Rows[0]["DebitCreditMark"]);
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_EmptyStream_ReturnsEmptyTable()
|
||||
{
|
||||
using var stream = ToStream("");
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(0, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_EmptyStream_HasDefaultSchema()
|
||||
{
|
||||
using var stream = ToStream("");
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.True(table.Columns.Contains("AccountIdentification"));
|
||||
Assert.True(table.Columns.Contains("Amount"));
|
||||
Assert.True(table.Columns.Contains("DebitCreditMark"));
|
||||
@@ -104,7 +109,7 @@ public class BankingParseToDatatableTests
|
||||
schema.Columns.Add("Amount", typeof(decimal));
|
||||
|
||||
using var stream = ToStream(MinimalMT940);
|
||||
var table = Banking.ParseToDatatable(stream, schemaDatatable: schema);
|
||||
var table = Svc.ParseToDatatable(stream, schemaDatatable: schema);
|
||||
Assert.Equal(2, table.Columns.Count);
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ public class BankingParseToDatatableTests
|
||||
{
|
||||
var multi = MinimalMT940 + "\n" + MinimalMT940;
|
||||
using var stream = ToStream(multi);
|
||||
var table = Banking.ParseToDatatable(stream);
|
||||
var table = Svc.ParseToDatatable(stream);
|
||||
Assert.Equal(2, table.Rows.Count);
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ public class BankingParseToDatatableTests
|
||||
public void ParseToDatatable_MalformedContent_DoesNotThrow()
|
||||
{
|
||||
using var stream = ToStream("This is not MT940 data at all");
|
||||
var ex = Record.Exception(() => Banking.ParseToDatatable(stream));
|
||||
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using CAMTParser;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the ISO 20022 CAMT parser and for the BankingService dual-format
|
||||
/// (CAMT + MT940) routing. Covers both succeeding and failing/edge inputs.
|
||||
/// </summary>
|
||||
public class CamtParserTests
|
||||
{
|
||||
// camt.053 with one incoming (CRDT) and one outgoing (DBIT) entry.
|
||||
private const string Camt053 = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<GrpHdr><MsgId>MSG1</MsgId><CreDtTm>2023-01-31T08:00:00</CreDtTm></GrpHdr>
|
||||
<Stmt>
|
||||
<Id>STMT-1</Id>
|
||||
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">500.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
|
||||
<ValDt><Dt>2023-01-16</Dt></ValDt>
|
||||
<NtryRef>REF-CR-1</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<Refs><EndToEndId>E2E-CR-1</EndToEndId></Refs>
|
||||
<RltdPties>
|
||||
<Dbtr><Nm>Max Mustermann</Nm></Dbtr>
|
||||
<DbtrAcct><Id><IBAN>DE99888877776666555544</IBAN></Id></DbtrAcct>
|
||||
</RltdPties>
|
||||
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">89.90</Amt>
|
||||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||
<BookgDt><Dt>2023-01-16</Dt></BookgDt>
|
||||
<ValDt><Dt>2023-01-16</Dt></ValDt>
|
||||
<NtryRef>REF-DB-1</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<Refs><MndtId>MND-1</MndtId></Refs>
|
||||
<RltdPties>
|
||||
<Cdtr><Nm>Stadtwerke</Nm></Cdtr>
|
||||
<CdtrAcct><Id><IBAN>DE11223344556677889900</IBAN></Id></CdtrAcct>
|
||||
</RltdPties>
|
||||
<RmtInf><Ustrd>Strom Januar</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
// ── Detection ──────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void LooksLikeXml_XmlContent_True()
|
||||
=> Assert.True(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(Camt053)));
|
||||
|
||||
[Fact]
|
||||
public void LooksLikeXml_Mt940Content_False()
|
||||
=> Assert.False(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(":20:STARTUMSE\r\n:25:DE123\r\n")));
|
||||
|
||||
[Fact]
|
||||
public void LooksLikeXml_Empty_False()
|
||||
=> Assert.False(CamtParser.LooksLikeXml(Array.Empty<byte>()));
|
||||
|
||||
// ── Successful parse ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Parse_Camt053_ReturnsOneStatementWithAccountAndTwoEntries()
|
||||
{
|
||||
var statements = new CamtParser().Parse(Camt053);
|
||||
var stmt = Assert.Single(statements);
|
||||
Assert.Equal(CamtDocumentType.Camt053, stmt.DocumentType);
|
||||
Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
|
||||
Assert.Equal(2, stmt.Entries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Camt053_CreditEntry_MappedCorrectly()
|
||||
{
|
||||
var stmt = new CamtParser().Parse(Camt053).Single();
|
||||
var credit = stmt.Entries[0];
|
||||
Assert.Equal(500.00m, credit.Amount);
|
||||
Assert.Equal("EUR", credit.Currency);
|
||||
Assert.Equal(CamtDebitCreditMark.Credit, credit.Mark);
|
||||
Assert.Equal("C", credit.MarkAbbreviation);
|
||||
Assert.Equal(new DateTime(2023, 1, 15), credit.EntryDate);
|
||||
Assert.Equal(new DateTime(2023, 1, 16), credit.ValueDate);
|
||||
Assert.Equal("Max Mustermann", credit.CounterpartyName); // payer for a credit
|
||||
Assert.Equal("DE99888877776666555544", credit.CounterpartyIban);
|
||||
Assert.Equal("E2E-CR-1", credit.EndToEndReference);
|
||||
Assert.Equal("Rechnung 4711", credit.RemittanceUnstructured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Camt053_DebitEntry_MappedCorrectly()
|
||||
{
|
||||
var stmt = new CamtParser().Parse(Camt053).Single();
|
||||
var debit = stmt.Entries[1];
|
||||
Assert.Equal(89.90m, debit.Amount);
|
||||
Assert.Equal(CamtDebitCreditMark.Debit, debit.Mark);
|
||||
Assert.Equal("D", debit.MarkAbbreviation);
|
||||
Assert.Equal("Stadtwerke", debit.CounterpartyName); // payee for a debit
|
||||
Assert.Equal("MND-1", debit.MandateReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NamespaceVersionAgnostic_StillParses()
|
||||
{
|
||||
// Different schema version namespace (…001.08) must parse identically.
|
||||
string v8 = Camt053.Replace("camt.053.001.02", "camt.053.001.08");
|
||||
var stmt = new CamtParser().Parse(v8).Single();
|
||||
Assert.Equal(2, stmt.Entries.Count);
|
||||
Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReversalEntry_MarkedAsReverseCredit()
|
||||
{
|
||||
string xml = """
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt><Stmt>
|
||||
<Acct><Id><IBAN>DE00</IBAN></Id></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><RvslInd>true</RvslInd>
|
||||
<BookgDt><Dt>2023-02-01</Dt></BookgDt>
|
||||
</Ntry>
|
||||
</Stmt></BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
var entry = new CamtParser().Parse(xml).Single().Entries.Single();
|
||||
Assert.Equal(CamtDebitCreditMark.ReverseCredit, entry.Mark);
|
||||
Assert.Equal("RC", entry.MarkAbbreviation);
|
||||
}
|
||||
|
||||
// ── Failing / edge inputs ───────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Parse_EmptyString_ReturnsEmptyList()
|
||||
=> Assert.Empty(new CamtParser().Parse(""));
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidXml_ThrowsFormatException()
|
||||
=> Assert.Throws<FormatException>(() => new CamtParser().Parse("<broken"));
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonCamtXml_ReturnsNoStatements()
|
||||
=> Assert.Empty(new CamtParser().Parse("<root><foo>bar</foo></root>"));
|
||||
}
|
||||
|
||||
/// <summary>BankingService routing: CAMT and MT940 both land in the same DataTable.</summary>
|
||||
public class BankingDualFormatTests
|
||||
{
|
||||
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
|
||||
|
||||
private static Stream ToStream(string s) => new MemoryStream(Encoding.UTF8.GetBytes(s));
|
||||
|
||||
private const string Camt053 = """
|
||||
<?xml version="1.0"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt><Stmt>
|
||||
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">500.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
|
||||
<NtryDtls><TxDtls>
|
||||
<RltdPties><Dbtr><Nm>Max Mustermann</Nm></Dbtr></RltdPties>
|
||||
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt></BkToCstmrStmt>
|
||||
</Document>
|
||||
""";
|
||||
|
||||
private const string MinimalMT940 =
|
||||
"\r\n:20:STARTUMSE\r\n:25:DE12345678901234567890\r\n:28C:00001/001\r\n" +
|
||||
":60F:C230101EUR1000,00\r\n:61:2301010101CR500,00NTRFREF123\r\n:86:Gutschrift\r\n" +
|
||||
":62F:C230101EUR1500,00\r\n-\r\n";
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Camt_RoutesToCamtAndMapsColumns()
|
||||
{
|
||||
using var s = ToStream(Camt053);
|
||||
var t = Svc.ParseToDatatable(s);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||
Assert.Equal(500.00m, t.Rows[0]["Amount"]);
|
||||
Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
|
||||
Assert.Equal("Max Mustermann", t.Rows[0]["NameOfPayer"]);
|
||||
Assert.Equal("Rechnung 4711", t.Rows[0]["SepaRemittanceInformation"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Mt940_StillRoutesToMt940()
|
||||
{
|
||||
using var s = ToStream(MinimalMT940);
|
||||
var t = Svc.ParseToDatatable(s);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
|
||||
Assert.Equal(500m, t.Rows[0]["Amount"]);
|
||||
Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToDatatable_Camt_RespectsCustomSchema()
|
||||
{
|
||||
var schema = new DataTable();
|
||||
schema.Columns.Add("AccountIdentification", typeof(string));
|
||||
schema.Columns.Add("Amount", typeof(decimal));
|
||||
using var s = ToStream(Camt053);
|
||||
var t = Svc.ParseToDatatable(s, schemaDatatable: schema);
|
||||
Assert.Equal(2, t.Columns.Count);
|
||||
Assert.Equal(1, t.Rows.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the refactored data holders (FdsInvoiceData / FdsReminderData).
|
||||
/// These are now pure data objects — persistence/PDF moved to the DI services.
|
||||
/// </summary>
|
||||
public class FdsDataTests
|
||||
{
|
||||
[Fact]
|
||||
public void FdsInvoiceData_Empty_DefaultsToDraftWithNoRegistration()
|
||||
{
|
||||
var inv = new FdsInvoiceData();
|
||||
Assert.True(inv.IsDraft);
|
||||
Assert.Null(inv.InvoiceRegistration);
|
||||
Assert.Equal("", inv.Id);
|
||||
Assert.Equal("R", inv.InvoiceType); // fallback when no registration
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsInvoiceData_ParsesFormSections()
|
||||
{
|
||||
var jo = JObject.Parse(
|
||||
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"42\" }, " +
|
||||
" \"new\": { \"title\": \"Test\", \"total_gross\": \"119.00\" }, " +
|
||||
" \"sms\": { \"tscn\": \"10\" }, " +
|
||||
" \"req\": [] }");
|
||||
var inv = new FdsInvoiceData(jo);
|
||||
|
||||
Assert.NotNull(inv.Admin);
|
||||
Assert.NotNull(inv.NewValues);
|
||||
Assert.NotNull(inv.Sms);
|
||||
Assert.Equal("R", inv.Admin!.getString("type"));
|
||||
Assert.Equal("Test", inv.NewValues!.getString("title"));
|
||||
Assert.True(inv.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsInvoiceData_BuildInvoiceParams_PicksHighestVatAndMapsFields()
|
||||
{
|
||||
// VAT comes from the sms.vat map (rate → amount); highest rate (19) selected.
|
||||
var jo = JObject.Parse(
|
||||
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"7\", \"p13b\": false }, " +
|
||||
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"paymentterm\": \"10d\" }, " +
|
||||
" \"sms\": { \"vat\": { \"7,0%\": 7.0, \"19,0%\": 19.0 } } }");
|
||||
var inv = new FdsInvoiceData(jo);
|
||||
|
||||
var pl = inv.BuildInvoiceParams(change: false, invId: "");
|
||||
var vat = pl.Find(p => p.ParameterName == "@InvoiceVAT_1");
|
||||
var title = pl.Find(p => p.ParameterName == "@InvoiceTitle");
|
||||
|
||||
Assert.NotNull(vat);
|
||||
Assert.Equal("19", vat!.Value);
|
||||
Assert.Equal("Bad", title!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsReminderData_Empty_DefaultsToDraft()
|
||||
{
|
||||
var rem = new FdsReminderData();
|
||||
Assert.True(rem.IsDraft);
|
||||
Assert.Null(rem.ReminderRegistration);
|
||||
Assert.Equal("", rem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FdsReminderData_ParsesFormSections()
|
||||
{
|
||||
var jo = JObject.Parse(
|
||||
"{ \"new\": { \"amount\": \"50\", \"invoiceemail\": \"a@b.de\" }, " +
|
||||
" \"rem\": { \"type\": \"f\", \"invid\": \"123\" } }");
|
||||
var rem = new FdsReminderData(jo);
|
||||
|
||||
Assert.NotNull(rem.NewValues);
|
||||
Assert.NotNull(rem.Rem);
|
||||
Assert.Equal("50", rem.NewValues!.getString("amount"));
|
||||
Assert.Equal("f", rem.Rem!.getString("type"));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for the non-DB parts of the restored report engine and the
|
||||
/// giro-code QR generator. The SQL-driven report rendering itself requires a
|
||||
/// live report catalog and is validated separately against the database.
|
||||
/// </summary>
|
||||
public class FuchsReportRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetPaycode_ProducesNonEmptyBitmap()
|
||||
{
|
||||
using var bmp = FuchsPdf.GetPaycode(
|
||||
iban: "DE52301502000002091478", bic: "WELADED1KSD",
|
||||
name: "Sebastian Fuchs Bad und Heizung", amount: 123.45m, purpose: "Rechnung 4711");
|
||||
Assert.NotNull(bmp);
|
||||
Assert.True(bmp.Width > 0);
|
||||
Assert.True(bmp.Height > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtmlPage_Web_FallbackShell_EmbedsContentTitleAndReload()
|
||||
{
|
||||
var page = new FuchsHtmlPage("My Report", templatePath: "", queryDuration: 0)
|
||||
{
|
||||
ReloadSeconds = 60
|
||||
};
|
||||
page.Add("<div id=\"frm\">hello</div>");
|
||||
|
||||
string html = page.ToHtml(FdsDestination.web);
|
||||
|
||||
Assert.Contains("hello", html);
|
||||
Assert.Contains("My Report", html); // title injected
|
||||
Assert.Contains("http-equiv=\"refresh\"", html); // reload meta injected
|
||||
Assert.Contains("content=\"60\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtmlPage_Content_OmitsReloadAndTitleChrome()
|
||||
{
|
||||
var page = new FuchsHtmlPage("Ignored", templatePath: "");
|
||||
page.Add("<span>fragment</span>");
|
||||
|
||||
string html = page.ToHtml(FdsDestination.content);
|
||||
|
||||
Assert.Contains("fragment", html);
|
||||
Assert.DoesNotContain("http-equiv=\"refresh\"", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the posted <c>admin</c> flags map to the <c>@InvoiceOptions</c>
|
||||
/// CSV the backend persists — the channel both §13b and the set-pricing
|
||||
/// <c>setmode</c> token ride. The PDF reads the mode back via
|
||||
/// <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/>.
|
||||
/// </summary>
|
||||
public class InvoiceOptionsTests
|
||||
{
|
||||
private static string InvoiceOptionsFor(object? admin)
|
||||
{
|
||||
var root = new JObject();
|
||||
if (admin != null) root["admin"] = JObject.FromObject(admin);
|
||||
var data = new FdsInvoiceData(root);
|
||||
var p = data.BuildInvoiceParams(change: false, invId: "")
|
||||
.First(x => x.ParameterName == "@InvoiceOptions");
|
||||
return p.Value is DBNull or null ? "" : p.Value!.ToString()!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoFlags_EmptyOptions()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||
|
||||
[Fact]
|
||||
public void DefaultSetPrice_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||
|
||||
[Theory]
|
||||
[InlineData("itemprices", "setmode:itemprices")]
|
||||
[InlineData("setonly", "setmode:setonly")]
|
||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
||||
public void SetMode_EmitsToken(string mode, string expected)
|
||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
||||
|
||||
[Fact]
|
||||
public void UnknownSetMode_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||
|
||||
[Fact]
|
||||
public void P13bOnly_EmitsLegacyToken()
|
||||
=> Assert.Equal("§13b", InvoiceOptionsFor(new { type = "r", p13b = true }));
|
||||
|
||||
[Fact]
|
||||
public void P13bAndSetMode_EmitsBoth()
|
||||
=> Assert.Equal("§13b,setmode:setonly", InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" }));
|
||||
|
||||
// ── VAT: highest rate + amount taken from the sms.vat map ─────────────────
|
||||
private static GenericObjectDictionary SmsWithVat(params (string rate, double amount)[] vat)
|
||||
{
|
||||
var map = new Dictionary<string, double>();
|
||||
foreach (var (r, a) in vat) map[r] = a;
|
||||
return new GenericObjectDictionary(new Dictionary<string, object> { ["vat"] = JObject.FromObject(map) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_PicksHighestRate_GermanFormatted()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("19,0%", 38.0), ("7,0%", 7.0)));
|
||||
Assert.Equal("19", rate); // integer, invariant
|
||||
Assert.Equal("38", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_NonIntegerRate_Preserved()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("10,5%", 5.25)));
|
||||
Assert.Equal("10.5", rate); // invariant decimal point — safe for numeric(5,2)
|
||||
Assert.Equal("5.25", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_EmptyOrMissing_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(null));
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(new GenericObjectDictionary(new Dictionary<string, object>())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_FlowsIntoParams()
|
||||
{
|
||||
var root = new JObject
|
||||
{
|
||||
["admin"] = JObject.FromObject(new { type = "r" }),
|
||||
["sms"] = JObject.FromObject(new Dictionary<string, object>
|
||||
{
|
||||
["vat"] = new Dictionary<string, double> { ["19,0%"] = 38.0 }
|
||||
})
|
||||
};
|
||||
var pars = new FdsInvoiceData(root).BuildInvoiceParams(false, "");
|
||||
Assert.Equal("19", pars.First(p => p.ParameterName == "@InvoiceVAT_1").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||
{
|
||||
// The token this side emits must parse back to the same mode on the PDF side.
|
||||
Assert.Equal(SetDisplayMode.ItemPrices,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||
Assert.Equal(SetDisplayMode.SetOnly,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the set-pricing display modes — the agreed interplay
|
||||
/// between the front-end (which picks the mode and groups items) and the
|
||||
/// back-end PDF renderer (which honours ShowPrice / IsSetHeader). Set items use
|
||||
/// type="set"; members carry setId == the set header's id.
|
||||
/// </summary>
|
||||
public class InvoiceSetPricingTests
|
||||
{
|
||||
private static Dictionary<string, object?> SetHeader(string id, string title, string? total = null) => new()
|
||||
{
|
||||
["type"] = "set", ["id"] = id, ["title"] = title, ["qty"] = "1",
|
||||
["total_net"] = total ?? "0", ["price_net"] = total ?? "0"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Member(string setId, string title, string price, string total) => new()
|
||||
{
|
||||
["setId"] = setId, ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Standalone(string title, string price, string total) => new()
|
||||
{
|
||||
["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
// A set (sum 1000) with two members, plus one standalone item (50).
|
||||
private static List<Dictionary<string, object?>> Sample() => new()
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
Member("10", "Waschbecken", "600.00", "600.00"),
|
||||
Member("10", "Armatur", "400.00", "400.00"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsSets_DetectsHeaderOrMember()
|
||||
{
|
||||
Assert.True(InvoiceSetPricing.ContainsSets(Sample()));
|
||||
Assert.False(InvoiceSetPricing.ContainsSets(new List<Dictionary<string, object?>> { Standalone("X", "1", "1") }));
|
||||
}
|
||||
|
||||
// ── SetPrice (default): set priced, members blank ─────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetPrice_SetLinePricedMembersBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
|
||||
|
||||
Assert.Equal(4, lines.Count); // header + 2 members + standalone
|
||||
var header = lines[0];
|
||||
Assert.True(header.IsSetHeader);
|
||||
Assert.True(header.ShowPrice);
|
||||
Assert.Equal(1000.00m, header.TotalNet);
|
||||
|
||||
Assert.False(lines[1].ShowPrice); // Waschbecken — no price
|
||||
Assert.False(lines[2].ShowPrice); // Armatur — no price
|
||||
Assert.Equal("Waschbecken", lines[1].Title);
|
||||
|
||||
Assert.True(lines[3].ShowPrice); // standalone keeps price
|
||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||
}
|
||||
|
||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
||||
[Fact]
|
||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
||||
|
||||
Assert.Equal(4, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
||||
Assert.True(lines[3].ShowPrice); // standalone
|
||||
}
|
||||
|
||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetOnly);
|
||||
|
||||
Assert.Equal(2, lines.Count); // set header + standalone only
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.True(lines[0].ShowPrice);
|
||||
Assert.Equal(1000.00m, lines[0].TotalNet);
|
||||
Assert.False(lines[1].IsSetHeader);
|
||||
Assert.Equal("Anfahrt", lines[1].Title);
|
||||
}
|
||||
|
||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"), // total 0
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
||||
}
|
||||
|
||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_NoSets_PassThroughAllPriced()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
Standalone("A", "10.00", "10.00"),
|
||||
Standalone("B", "20.00", "20.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.All(lines, l => Assert.True(l.ShowPrice));
|
||||
Assert.All(lines, l => Assert.False(l.IsSetHeader));
|
||||
}
|
||||
|
||||
// ── Text/Title lines never show a price (any mode) ────────────────────────
|
||||
private static Dictionary<string, object?> TextLine(string title) => new()
|
||||
{
|
||||
["type"] = "title", ["title"] = title, ["qty"] = "", ["price_net"] = "", ["total_net"] = ""
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_Standalone_NoPrice()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
TextLine("Leistungszeitraum 2026"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.False(lines[0].ShowPrice); // heading — blank price
|
||||
Assert.True(lines[1].ShowPrice); // real item — priced
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||
Member("10", "Waschbecken", "600.00", "600.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
|
||||
var note = lines.First(l => l.Title == "Hinweis");
|
||||
Assert.False(note.ShowPrice); // text member stays blank
|
||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
[Fact]
|
||||
public void SetPrice_EqualsSumOfMembers()
|
||||
{
|
||||
var setLine = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice).First(l => l.IsSetHeader);
|
||||
decimal memberSum = Sample()
|
||||
.Where(i => i.TryGetValue("setId", out var s) && s?.ToString() == "10")
|
||||
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(memberSum, setLine.TotalNet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the outbound communication service. Cover both intentionally
|
||||
/// succeeding (API 200) and intentionally failing (invalid email, disabled,
|
||||
/// API 500) paths, plus the attachment payload contract and metric emission.
|
||||
///
|
||||
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
|
||||
/// which is wrapped in try/catch and therefore harmless in a unit test.
|
||||
/// </summary>
|
||||
public class ProcessWebComServiceTests
|
||||
{
|
||||
// ── Test doubles ────────────────────────────────────────────────────────
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body = "OK")
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
BaseUrl = "https://mailer.test",
|
||||
AccountId = "acct",
|
||||
Token = "tok"
|
||||
});
|
||||
return new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance,
|
||||
intranet: null!,
|
||||
settings,
|
||||
new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
// ── Success path ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Failure paths ──────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_ReturnsFalse()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
[InlineData("missing@")]
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount); // never reached the API
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, enabled: false);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Attachment payload contract ────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
byte[] pdf = { 1, 2, 3, 4 };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
bool result = await svc.SendSmsAsync("", "Code 1234");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Metric emission (performance indicator) ────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_IncrementsEmailsSentCounter()
|
||||
{
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.sent")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
|
||||
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
|
||||
}
|
||||
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
}
|
||||
@@ -15,12 +15,15 @@ public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Bankings(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Bankings action={Action} user={User}", id, UserAccountID);
|
||||
switch (id.ToLower())
|
||||
{
|
||||
case "auth":
|
||||
return await JSONAsync(new { manage = 1 });
|
||||
|
||||
case "up":
|
||||
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
|
||||
Request.Form.Files.Count, UserAccountID);
|
||||
foreach (var fle in Request.Form.Files)
|
||||
{
|
||||
using var stream = fle.OpenReadStream();
|
||||
@@ -29,7 +32,7 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code))).DataTable;
|
||||
|
||||
var tbl = Banking.ParseToDatatable(stream, schemaDt);
|
||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
||||
|
||||
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
||||
@@ -50,6 +53,8 @@ public partial class IntranetController
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
dtwa.DoSubmit();
|
||||
}
|
||||
return Ok();
|
||||
@@ -76,11 +81,13 @@ public partial class IntranetController
|
||||
string mode = Form("mode").ToLower();
|
||||
if (mode == "s" && Form("tgt").Contains(':'))
|
||||
{
|
||||
// Search mode: @tgtdate is unused by the proc but required by its signature.
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@mode", Form("mode")),
|
||||
SQL_VarChar("@search", Form("tgt")));
|
||||
SQL_Date("@tgtdate", DBNull.Value),
|
||||
SQL_VarChar("@mode", Form("mode").ne("m")),
|
||||
SQL_VarChar("@search", Form("tgt")));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getBankingtransactions_list2] @mode, @search, @authuser;",
|
||||
"EXECUTE [dbo].[fds__getBankingtransfers_list2] @tgtdate, @mode, @search, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "bank" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -13,12 +13,14 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
|---|---|---|
|
||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||
| **OCORE_web** | Class Library (shared) | Web utilities: MVC helpers, middleware, auth, captcha |
|
||||
| **OCORE_web_pdf** | Class Library (shared) | PDF generation (MigraDoc/PDFsharp, HTML→PDF) |
|
||||
| **OCORE_Charting** | Class Library (shared) | Data visualization / charting (ported System.Windows.Forms.DataVisualization) |
|
||||
| **MT940Parser** | Class Library (shared) | SWIFT MT940/MT942 bank statement parser |
|
||||
| **MT940Parser** | Class Library (external) | SWIFT MT940/MT942 bank statement parser |
|
||||
| **CAMTParser** | Class Library (in-repo) | ISO 20022 CAMT (camt.052/053/054) bank statement parser |
|
||||
|
||||
**All projects target `net10.0`.**
|
||||
|
||||
@@ -163,8 +165,12 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
||||
### 4.2 Singleton Configuration Object
|
||||
`Fuchs_intranet` is a manually-managed singleton (via `FuchsOcmsIntranet`) initialized at startup with `IConfiguration`. It holds connection strings, app settings, auth helpers, and DB connection factory methods.
|
||||
|
||||
### 4.3 Static Business Logic Classes
|
||||
Most business logic lives in **static classes** (`FuchsPdf`, `FuchsWidgets`, `FuchsReports`, `FuchsFdsEmail`, `Banking`) that receive the controller instance or `Fuchs_intranet` as a parameter. This is a legacy pattern from the VB.NET conversion.
|
||||
### 4.3 Service Layer (Dependency Injection)
|
||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||
|
||||
### 4.4 SQL-First Data Access
|
||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||
@@ -177,9 +183,17 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
|
||||
|
||||
---
|
||||
|
||||
## 5. DI Service Extraction Candidates
|
||||
## 5. Service Layer (implemented)
|
||||
|
||||
The following static classes and tightly-coupled code sections are strong candidates for refactoring into proper DI-registered services. This improves testability, decouples dependencies, and aligns with ASP.NET Core best practices.
|
||||
> **Status: DONE.** The services below are implemented and DI-registered in
|
||||
> `Program.cs`. The original extraction rationale is retained for reference /
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
|
||||
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
|
||||
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
|
||||
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
|
||||
> (backed by the ported `FuchsVisualization` engine), `FdsMfrClient` →
|
||||
> `IMfrClientFactory`.
|
||||
|
||||
---
|
||||
|
||||
@@ -391,8 +405,35 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
|
||||
## 7. Additional Observations
|
||||
|
||||
1. **`System.Configuration.ConfigurationManager` usage** in `FuchsFdsEmail.cs` directly violates the project's coding standards (`appsettings.json` only).
|
||||
2. **No dependency injection in `FdsInvoiceData`/`FdsReminderData`** — these classes receive the entire controller, creating circular-style dependencies.
|
||||
3. **`FdsMfrClient` is `new`-ed directly** in controller partials (e.g., `IntranetController.Invoices2.cs`) instead of being injected.
|
||||
4. **`OCORE_Charting`** is in the solution but not directly referenced by any project — verify if it's still needed.
|
||||
5. **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
1. ✅ **Resolved** — email/SMS moved off `ConfigurationManager` into `IComService` (ProcessWeb Mailer API).
|
||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
|
||||
---
|
||||
|
||||
## 8. Observability (OpenTelemetry)
|
||||
|
||||
- Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs`: one `ActivitySource` and one `Meter` (`Fuchs.Intranet`).
|
||||
- **Metrics** — counters (`fuchs.invoices.rendered`, `fuchs.reminders.rendered`, `fuchs.reports.rendered`, `fuchs.emails.sent`/`.failed`, `fuchs.sms.sent`, `fuchs.banking.mt940.rows`, `fuchs.mfr.calls`) and duration histograms (`fuchs.pdf.render.duration`, `fuchs.report.render.duration`, `fuchs.email.send.duration`).
|
||||
- **Tracing** — ASP.NET Core, HttpClient and SqlClient instrumentation plus the app `ActivitySource`; services start spans for their key operations.
|
||||
- Configured in `Program.cs`. Always collected in-process; **OTLP export is opt-in** via `Fuchs:Telemetry:OtlpEndpoint` (and can be disabled with `Fuchs:Telemetry:Enabled=false`), so a missing collector never affects the app.
|
||||
- All services + handlers log entry/result/timing/errors via `ILogger<T>` with structured placeholders.
|
||||
|
||||
## 9. Bank Statement Parsing (MT940 + CAMT)
|
||||
|
||||
- `BankingService` (`IBankingService`) accepts **both** MT940 (SWIFT text, via the external `MT940Parser`) and **CAMT** (ISO 20022 camt.052/053/054 XML, via the in-repo `CAMTParser`).
|
||||
- `ParseToDatatable` **auto-detects** the format from content (XML → CAMT, else MT940) and maps either into the `fds__tt__bankingtransactions` schema; the `bam/up` handler and the frontend upload accept both.
|
||||
- `CAMTParser` matches elements by **local name** (namespace-agnostic) so it works across every camt schema version. When the banking schema changes, keep the MT940 and CAMT column mappings in `BankingService` aligned.
|
||||
|
||||
## 10. MFR ERP Integration
|
||||
|
||||
- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its integration contract — base URLs, auth, OData conventions, pagination, error/retry semantics, deep-create and document-upload endpoints — is documented in **`MFR_RESTClient/Docs/mfr_interface_description.md`**. Consult that file before changing the client.
|
||||
- The client uses HTTP Basic auth, a configurable timeout/user-agent, and **retries idempotent GETs** on transient failures (HTTP 429/5xx, network/timeout) with exponential backoff + jitter (honouring `Retry-After`). `ReadODataAllPages` follows `@odata.nextLink` pagination.
|
||||
- `Fuchs_DataService` (Topshelf worker) polls MFR on a timer and syncs entities + invoice files; the web app creates clients via `IMfrClientFactory`.
|
||||
|
||||
## 11. Database
|
||||
|
||||
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
|
||||
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Evaluation — Backend-cached invoice editing over SignalR
|
||||
|
||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||
The backend becomes the single source of truth for the in-progress invoice.
|
||||
|
||||
This note evaluates that against the current design and recommends a path.
|
||||
|
||||
---
|
||||
|
||||
## 1. How invoice editing works today
|
||||
|
||||
- **Stateless.** The browser holds the editor state. It posts the whole `invc`
|
||||
JSON to `req/sprep|sedit|save`; the server computes/persists and returns a PDF
|
||||
preview (image collection). No per-user editing state lives on the server.
|
||||
- Totals/§13b/§35a and now **set pricing** are computed from posted data; the
|
||||
invoice **total comes from the registration balance**, not summed lines.
|
||||
- Auth is cookie-based (`OCORECookieAuthenticationEvents`), SQL-first data access,
|
||||
controller is stateless and DI-scoped.
|
||||
|
||||
**Implication:** the server is horizontally scalable and crash-tolerant for
|
||||
editing — there is nothing to lose if a node restarts mid-edit.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the proposal would buy
|
||||
|
||||
| Benefit | Real for Fuchs? |
|
||||
|---|---|
|
||||
| Server-authoritative calculation (one place for pricing/VAT/set rules) | **Partly already true** — the PDF/totals are server-computed; the editor only previews. The duplicated logic is the *live* line math in JS. |
|
||||
| Real-time multi-user co-editing | **Low value** — invoices are edited by one back-office user at a time; concurrent editing of the same draft is rare. |
|
||||
| Live validation / instant recompute without full round-trips | **Some value** — smoother UX than re-posting the whole `invc` for each tweak. |
|
||||
| Reduced payload (deltas vs whole invoice) | Marginal — invoices are small. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Costs and risks
|
||||
|
||||
- **Server-side edit state.** Per-user/per-draft cache with lifetime management
|
||||
(idle expiry, explicit discard, max size), or the server leaks memory. Needs a
|
||||
distributed cache if scaled out (Redis), or **sticky sessions** for SignalR.
|
||||
- **Concurrency/locking.** Two tabs or two users on the same draft → need
|
||||
optimistic concurrency / locking semantics that don't exist today.
|
||||
- **Connection lifecycle.** Reconnect, replay, and "lost update" handling;
|
||||
offline/flaky networks; auth on the socket (cookie works, but token refresh and
|
||||
disconnect-on-logout must be handled).
|
||||
- **Scaling.** SignalR with multiple instances needs a backplane (Redis/Azure
|
||||
SignalR). Today the app has none.
|
||||
- **Big rewrite of the editor.** The 1,200-line `fis.inv_shared.js` becomes an
|
||||
event-driven client of server state — a substantial, risky rewrite of working
|
||||
code, with new failure modes (desync between optimistic UI and server truth).
|
||||
- **Testing surface** grows (connection states, races) far beyond the current
|
||||
request/response model.
|
||||
|
||||
For a single-tenant back-office app with one editor at a time, this is a lot of
|
||||
**accidental complexity** for modest UX gains.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
**Do not do a full SignalR rewrite now.** It optimises a problem (real-time
|
||||
collaboration, server-held edit state) the business doesn't strongly have, while
|
||||
adding stateful-server, scaling, and reconnection complexity to an app that is
|
||||
currently simple and robust.
|
||||
|
||||
Prefer an **incremental, lower-risk path** that captures most of the value:
|
||||
|
||||
1. **Single source of pricing truth (highest value, do first).**
|
||||
Add a stateless endpoint `inv/calc` that takes the `invc` JSON and returns the
|
||||
computed lines + totals + set-mode resolution using the **same backend code**
|
||||
(`InvoiceSetPricing`, VAT, §13b/§35a). The editor calls it on change (debounced)
|
||||
to recompute, instead of duplicating the math in JS. This removes the
|
||||
front/back duplication — the actual pain — **without** sockets or server state.
|
||||
It also makes the interplay fully unit-testable (the endpoint is pure).
|
||||
|
||||
2. **Keep preview as-is** (post → PDF image) but allow it to reuse `inv/calc`.
|
||||
|
||||
3. **If/when live UX is still wanted**, add SignalR **only as a transport** on top
|
||||
of the stateless calc (push recompute results), keeping persistence stateless.
|
||||
Defer server-held draft state until genuine multi-user co-editing is required.
|
||||
|
||||
4. **If server-held drafts are truly needed**, scope a pilot: one entity (invoice
|
||||
draft), `IDistributedCache`-backed, explicit acquire/release lock, idle TTL,
|
||||
and a reconnect/replay protocol — behind a feature flag, measured against the
|
||||
current flow before rollout.
|
||||
|
||||
### Why this fits the codebase
|
||||
It aligns with the just-completed DI service layer: the calc lives in
|
||||
`IInvoiceService`/`InvoiceSetPricing` (already tested), reused by both the preview
|
||||
and a future socket. We get "changes computed by the backend, reflected in the
|
||||
frontend" — the stated goal — **without** turning a stateless, scalable web app
|
||||
into a stateful real-time system prematurely.
|
||||
|
||||
**Suggested next step:** implement `inv/calc` (item 1) and migrate the editor's
|
||||
line math to it; revisit SignalR only if real-time/co-editing becomes a real
|
||||
requirement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
|
||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
|
||||
| Mode | Set line | Member items | Use as |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
|
||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
|
||||
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
|
||||
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
||||
set header line is rendered emphasised. Invoices without sets pass through
|
||||
unchanged.
|
||||
|
||||
## Front-end contract (implemented in the invoice editor)
|
||||
|
||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
|
||||
editor only posted the legacy on-screen `itm`/`co` objects, which
|
||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||
|
||||
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with
|
||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
|
||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||
`BuildInvoiceParams` reads different ones. At post time `$inv.invcPayload(d)` maps the
|
||||
working model onto the exact field names the back-end reads (non-destructively):
|
||||
`sms.ttn → new.total_net`, `sms.ttb → new.total_gross`, each `sms.vat` rate →
|
||||
`new.vat_<rate>_net`, `new.invoicetitle → new.title`, `new.loc → new.provisionlocation`,
|
||||
`admin.paymentterms → new.paymentterm`, `admin.CustomerId → admin.customerid`. Both
|
||||
`req/save` and `req/sprep|sedit` post through it, so titles/balances/VAT now reach the
|
||||
backend correctly.
|
||||
|
||||
VAT (rate **and** amount) is taken by the backend directly from the posted `sms.vat`
|
||||
map via `FdsInvoiceData.HighestVat` (highest rate wins) — `invcPayload` therefore emits
|
||||
no per-rate `vat_*` keys.
|
||||
|
||||
**Back-end fixes applied alongside the wiring:**
|
||||
- Heading/free-text lines (`type` `text`/`title`) now render a **blank** price/total
|
||||
(`InvoiceSetPricing.IsNoPriceLine`), instead of `0,00 €`.
|
||||
- VAT rate detection no longer reads line items (the old `items is List<object>` test
|
||||
failed on Newtonsoft `JArray` and pinned `@InvoiceVAT_1` to `19`); it now comes from
|
||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
|
||||
### Why the switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. Persisting the
|
||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
||||
only required if a finalised invoice must be **re-generated** from stored items in
|
||||
a different mode later — not done here.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Fuchs Intranet — Benutzerhandbuch
|
||||
|
||||
> Praxisleitfaden für Mitarbeiter:innen der Firma Sebastian Fuchs Bad und Heizung.
|
||||
> Beschreibt die täglichen Abläufe im Intranet. Technische Details siehe
|
||||
> `ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anmeldung & Konto
|
||||
|
||||
### Anmelden
|
||||
1. Intranet im Browser öffnen.
|
||||
2. E-Mail-Adresse und Passwort eingeben → **Anmelden**.
|
||||
3. Die Sitzung bleibt 8 Stunden aktiv (gleitend).
|
||||
|
||||
### Passwort vergessen
|
||||
1. „Passwort vergessen" wählen, **Nachname** und **E-Mail** eingeben.
|
||||
2. Es wird ein Bestätigungscode per **SMS** an die hinterlegte Mobilnummer gesendet.
|
||||
3. Code eingeben → das Passwort wird per E-Mail zugesandt.
|
||||
*(Aus Sicherheitsgründen erfolgt keine Rückmeldung, ob die Adresse existiert.)*
|
||||
|
||||
### Passwort ändern
|
||||
1. Konto → **Passwort ändern**.
|
||||
2. Bestätigungscode per SMS anfordern und eingeben.
|
||||
3. Altes Passwort + neues Passwort (mind. 6 Zeichen) zweimal eingeben.
|
||||
Das alte Passwort und die Code-Bestätigung werden geprüft.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard (Widgets)
|
||||
- Nach der Anmeldung erscheinen die persönlichen **Widgets** (Kennzahlen, Tabellen, Hinweise).
|
||||
- Widgets werden aus der Datenbank gespeist; welche angezeigt werden, hängt an Ihrem Benutzerkonto.
|
||||
|
||||
---
|
||||
|
||||
## 3. Serviceaufträge (Requests)
|
||||
Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatisch synchronisiert.
|
||||
|
||||
**Typische Schritte:**
|
||||
1. **Liste** öffnen (nach Datum oder Suche).
|
||||
2. Auftrag öffnen → Details, Positionen und ggf. bereits vorhandene Rechnungen ansehen.
|
||||
3. **Rechnung vorbereiten** (`iget`/`sprep`): Positionen werden in eine Rechnung übernommen und als Vorschau (PDF) angezeigt.
|
||||
4. Bei Bedarf einzelne Aufträge **aus-/einblenden**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rechnungen (Invoices)
|
||||
|
||||
### Erstellen / Bearbeiten
|
||||
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
||||
|
||||
### Finalisieren & Versenden
|
||||
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
|
||||
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.
|
||||
3. **Erneut senden** ist jederzeit möglich.
|
||||
|
||||
> **Hinweis Versand:** Der E-Mail-/SMS-Versand läuft über die ProcessWeb-Mailer-API
|
||||
> und ist über `Fuchs:Mailer:Enabled` geschaltet. Ist er deaktiviert, wird der
|
||||
> Versand nur protokolliert (kein echter Versand).
|
||||
|
||||
### Weitere Aktionen
|
||||
- **Bezahlt / Unbezahlt** markieren.
|
||||
- **Storno** oder **Gutschrift** erzeugen (einfach, als Kopie oder als Gutschrift).
|
||||
- **Zahlungen** und **Positionen** zu einer Rechnung einsehen.
|
||||
- **Rechnungs-PDF** anzeigen/herunterladen (gespeichertes Dokument oder neu erzeugt).
|
||||
|
||||
### Was steht auf der Rechnung
|
||||
Das PDF enthält Positionen, Netto/MwSt./Brutto, den **GiroCode (QR zur Überweisung)**
|
||||
sowie die gesetzlich relevanten Hinweise (u. a. §13b bzw. §35a-Lohnkostenhinweis,
|
||||
§14-Aufbewahrung, §48-Freistellungsbescheinigung, Steuernummer/USt-ID).
|
||||
|
||||
---
|
||||
|
||||
## 5. Zahlungserinnerungen (Reminders)
|
||||
1. Zu einer offenen Rechnung eine **Erinnerung vorbereiten** (Typ/Stufe wählen) → Vorschau.
|
||||
2. **Finalisieren**: PDF wird erzeugt; bei hinterlegter E-Mail erfolgt der Versand
|
||||
(Erinnerungs-PDF, ggf. inkl. Rechnungskopie, als Anhang).
|
||||
3. **Erneut senden** und **Erinnerungen nachschlagen** sind möglich.
|
||||
|
||||
---
|
||||
|
||||
## 6. Banking (Kontoumsätze)
|
||||
|
||||
### Umsätze importieren
|
||||
1. Banking → **Kontobericht hochladen**.
|
||||
2. Datei(en) wählen. Es werden **beide Formate** akzeptiert:
|
||||
- **MT940** (SWIFT-Text: `.sta`, `.mt940`, `.txt`)
|
||||
- **CAMT** (ISO 20022 XML: `.xml`, `.camt` — camt.052/053/054)
|
||||
3. Das System erkennt das Format automatisch, liest die Buchungen ein und führt sie
|
||||
in die Kontoumsätze über.
|
||||
|
||||
### Umsätze zuordnen
|
||||
- **Fragliche Überweisungen** anzeigen und prüfen.
|
||||
- Eine Transaktion einer **Rechnung zuordnen** (`ati`) oder als **erledigt** markieren (`smd`).
|
||||
- Per Rechnungsnummer nach passenden Rechnungen suchen (`vfi`).
|
||||
|
||||
---
|
||||
|
||||
## 7. DATEV-Export
|
||||
- **DATEV-Übersicht** für einen Stichtag/Zeitraum anzeigen (Dateien, Rechnungen).
|
||||
- **DATEV-ZIP** herunterladen — optional inkl. der Beleg-PDFs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Berichte (Reports)
|
||||
1. **Katalog** öffnen — Liste aller verfügbaren Berichte mit Parametern.
|
||||
2. Bericht auswählen, Parameter setzen, ausführen:
|
||||
- als **HTML-Seite** (mit optionaler automatischer Aktualisierung),
|
||||
- als **HTML-Inhalt** (eingebettet),
|
||||
- oder als **Diagramm (PNG)**.
|
||||
|
||||
---
|
||||
|
||||
## 9. MFR-Synchronisation (Hintergrund)
|
||||
- Stammdaten (Firmen, Kontakte, Serviceaufträge, Rechnungen, Belege …) werden vom
|
||||
**Fuchs_DataService** regelmäßig aus dem MFR-ERP abgeglichen.
|
||||
- Administrator:innen können eine **Aktualisierung anstoßen** (`mfr_update`) bzw. die
|
||||
Verknüpfung einzelner Datensätze zurücksetzen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Hilfe & Support
|
||||
- Bei Versandproblemen: prüfen, ob der Mailer aktiviert ist und ob eine gültige
|
||||
E-Mail-/Mobilnummer hinterlegt ist.
|
||||
- Technische Fehler werden protokolliert (Logging/OpenTelemetry); bitte mit
|
||||
Uhrzeit und betroffener Aktion an die IT melden:
|
||||
[info@processweb.de](mailto:info@processweb.de).
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Fuchs.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Central definition of the application's OpenTelemetry instrumentation:
|
||||
/// a single <see cref="ActivitySource"/> for tracing and a single
|
||||
/// <see cref="Meter"/> with the business / performance instruments.
|
||||
///
|
||||
/// These use the in-box <c>System.Diagnostics</c> APIs, which the OpenTelemetry
|
||||
/// SDK (wired up in <c>Program.cs</c>) collects and exports. Code can therefore
|
||||
/// emit spans and metrics without taking a direct dependency on the OTel SDK.
|
||||
/// </summary>
|
||||
public static class FuchsTelemetry
|
||||
{
|
||||
public const string ServiceName = "Fuchs.Intranet";
|
||||
public static readonly string ServiceVersion =
|
||||
typeof(FuchsTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
|
||||
/// <summary>Tracing source — register this name with the tracer provider.</summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion);
|
||||
|
||||
/// <summary>Metrics meter — register this name with the meter provider.</summary>
|
||||
public static readonly Meter Meter = new(ServiceName, ServiceVersion);
|
||||
|
||||
// ── Business counters ────────────────────────────────────────────────────
|
||||
public static readonly Counter<long> InvoicesRendered =
|
||||
Meter.CreateCounter<long>("fuchs.invoices.rendered", "{invoice}", "Number of invoice PDFs rendered.");
|
||||
public static readonly Counter<long> RemindersRendered =
|
||||
Meter.CreateCounter<long>("fuchs.reminders.rendered", "{reminder}", "Number of reminder PDFs rendered.");
|
||||
public static readonly Counter<long> ReportsRendered =
|
||||
Meter.CreateCounter<long>("fuchs.reports.rendered", "{report}", "Number of reports rendered (by function).");
|
||||
public static readonly Counter<long> EmailsSent =
|
||||
Meter.CreateCounter<long>("fuchs.emails.sent", "{email}", "Number of emails accepted by the mailer API.");
|
||||
public static readonly Counter<long> EmailsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.emails.failed", "{email}", "Number of emails that failed to send.");
|
||||
public static readonly Counter<long> SmsSent =
|
||||
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
||||
public static readonly Counter<long> Mt940RowsParsed =
|
||||
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
||||
public static readonly Counter<long> MfrCalls =
|
||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.pdf.render.duration", "ms", "PDF render duration.");
|
||||
public static readonly Histogram<double> ReportRenderDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration.");
|
||||
public static readonly Histogram<double> EmailSendDuration =
|
||||
Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
|
||||
|
||||
/// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary>
|
||||
public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
|
||||
ActivitySource.StartActivity(name, kind);
|
||||
}
|
||||
+46
-1
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Fuchs.Observability;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.web;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Fuchs;
|
||||
|
||||
@@ -36,7 +40,7 @@ public class Program
|
||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||
|
||||
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings
|
||||
fds.FdsConfig.Initialize();
|
||||
fds.FdsConfig.Initialize(builder.Configuration);
|
||||
|
||||
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
||||
@@ -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)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using CAMTParser;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using programmersdigest.MT940Parser;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// MT940 bank statement parsing service. Replaces the static <c>Banking</c> class.
|
||||
/// Bank statement parsing service. Accepts both MT940 (SWIFT text) and
|
||||
/// CAMT (ISO 20022 camt.052/053/054 XML) and maps either into the same
|
||||
/// banking-transactions DataTable. The format is auto-detected from content.
|
||||
/// </summary>
|
||||
public class BankingService : IBankingService
|
||||
{
|
||||
@@ -18,21 +23,56 @@ public class BankingService : IBankingService
|
||||
|
||||
public string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
|
||||
{
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.ReverseCredit => "RC",
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
|
||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
||||
byte[] bytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
stream.CopyTo(ms);
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
|
||||
string format;
|
||||
if (CamtParser.LooksLikeXml(bytes))
|
||||
{
|
||||
format = "camt";
|
||||
FillFromCamt(tbl, bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
format = "mt940";
|
||||
using var msMt = new MemoryStream(bytes);
|
||||
FillFromMt940(tbl, msMt);
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
sw.Stop();
|
||||
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format));
|
||||
act?.SetTag("fuchs.banking.format", format);
|
||||
act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
|
||||
_logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms",
|
||||
format, tbl.Rows.Count, sw.ElapsedMilliseconds);
|
||||
return tbl;
|
||||
}
|
||||
|
||||
// ── MT940 ─────────────────────────────────────────────────────────────────
|
||||
private void FillFromMt940(DataTable tbl, Stream stream)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null)
|
||||
nr[key] = value;
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
|
||||
using var ps = new Parser(stream: stream);
|
||||
@@ -47,89 +87,130 @@ public class BankingService : IBankingService
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
SetNfo(nr, "BankReference", line.BankReference);
|
||||
|
||||
var info = line.InformationToOwner;
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation", info.UnstructuredRemittanceInformation);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
|
||||
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails);
|
||||
SetNfo(nr, "TransactionTypeIdCode", line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
|
||||
}
|
||||
|
||||
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
||||
private void FillFromCamt(DataTable tbl, byte[] bytes)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
_logger.LogError(ex, "MT940 statement parse failed.");
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
return tbl;
|
||||
try
|
||||
{
|
||||
var statements = new CamtParser().Parse(bytes);
|
||||
foreach (var stmt in statements)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
|
||||
foreach (var e in stmt.Entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", stmt.AccountIdentification);
|
||||
if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
|
||||
if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
|
||||
if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate);
|
||||
SetNfo(nr, "FundsCode", e.Currency);
|
||||
SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
|
||||
SetNfo(nr, "BankReference", e.BankReference);
|
||||
SetNfo(nr, "EndToEndReference", e.EndToEndReference);
|
||||
SetNfo(nr, "MandateReference", e.MandateReference);
|
||||
SetNfo(nr, "CustomerReference", e.CustomerReference);
|
||||
SetNfo(nr, "CreditorReference", e.CreditorReference);
|
||||
SetNfo(nr, "AccountNumberOfPayer", e.CounterpartyIban);
|
||||
SetNfo(nr, "BankCodeOfPayer", e.CounterpartyBic);
|
||||
SetNfo(nr, "NameOfPayer", e.CounterpartyName);
|
||||
SetNfo(nr, "PostingText", e.AdditionalInfo);
|
||||
SetNfo(nr, "TransactionTypeIdCode",e.BankTransactionCode);
|
||||
SetNfo(nr, "SepaRemittanceInformation",
|
||||
string.IsNullOrEmpty(e.RemittanceUnstructured) ? e.RemittanceStructured : e.RemittanceUnstructured);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation", e.RemittanceUnstructured);
|
||||
SetNfo(nr, "UnstructuredData", e.RemittanceUnstructured);
|
||||
SetNfo(nr, "IsUnstructuredData", e.IsUnstructuredData);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
|
||||
}
|
||||
|
||||
private static DataTable BuildDefaultSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
var cols = t.Columns;
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("UnstructuredRemittanceInformation", typeof(string));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
|
||||
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods
|
||||
/// while providing a DI-friendly injectable wrapper.
|
||||
/// while providing a DI-friendly injectable wrapper, structured logging and
|
||||
/// render-duration metrics.
|
||||
/// </summary>
|
||||
public class FuchsPdfService : IPdfService
|
||||
{
|
||||
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
|
||||
{
|
||||
_logger = logger;
|
||||
FuchsPdf.SetLicense();
|
||||
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
|
||||
}
|
||||
|
||||
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
|
||||
{
|
||||
_logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
|
||||
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
|
||||
}
|
||||
|
||||
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
|
||||
FdsInvoiceData invoice, bool draft = false)
|
||||
{
|
||||
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
|
||||
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
|
||||
}
|
||||
|
||||
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
|
||||
FdsReminderData reminder, bool draft = false)
|
||||
{
|
||||
_logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
|
||||
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, draft);
|
||||
}
|
||||
|
||||
public byte[] DocToPdfBytes(Document doc) => FuchsPdf.DocToPdfBytes(doc);
|
||||
public byte[] DocToPdfBytes(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.render");
|
||||
try
|
||||
{
|
||||
byte[] bytes = FuchsPdf.DocToPdfBytes(doc);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "pdf"));
|
||||
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
|
||||
_logger.LogDebug("DocToPdfBytes rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "DocToPdfBytes failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc) =>
|
||||
FuchsPdf.DocToImageCollection(doc);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.render.images");
|
||||
try
|
||||
{
|
||||
var col = await FuchsPdf.DocToImageCollection(doc);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "images"));
|
||||
act?.SetTag("fuchs.pdf.pages", col.TotalPages);
|
||||
_logger.LogDebug("DocToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
|
||||
return col;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "DocToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes) =>
|
||||
FuchsPdf.BytesToImageCollection(pdfBytes);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.bytes.images");
|
||||
try
|
||||
{
|
||||
var col = await FuchsPdf.BytesToImageCollection(pdfBytes);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "bytes-images"));
|
||||
_logger.LogDebug("BytesToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
|
||||
return col;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "BytesToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,153 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Report service implementation. Replaces the static <c>FuchsReports</c> class.
|
||||
/// Report processing for the Fuchs intranet — SQL-driven reports from the
|
||||
/// fds__ report catalog, rendered as HTML pages, HTML fragments, or PNG charts
|
||||
/// via <see cref="FuchsVisualization"/>. Replaces the static <c>FuchsReports</c>.
|
||||
/// </summary>
|
||||
public class FuchsReportService : IReportService
|
||||
{
|
||||
private const int DefaultReloadSeconds = 60 * 10;
|
||||
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ILogger<FuchsReportService> _logger;
|
||||
|
||||
public FuchsReportService(ILogger<FuchsReportService> logger)
|
||||
public FuchsReportService(Fuchs_intranet intranet, ILogger<FuchsReportService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IActionResult> ProcessRequestAsync(string action, string id,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
|
||||
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters)
|
||||
{
|
||||
// Specific report actions are dispatched here.
|
||||
// Extend with additional cases as needed.
|
||||
return Task.FromResult<IActionResult>(new OkResult());
|
||||
var prms = new Dictionary<string, string>(parameters, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["@authuser"] = userAccountId
|
||||
};
|
||||
|
||||
string tgt = (string.IsNullOrEmpty(fnc)
|
||||
? (prms.TryGetValue("fnc", out var f) ? f : fnc)
|
||||
: fnc).Replace("gct", "generic_content");
|
||||
string report = prms.TryGetValue("report", out var r) && !string.IsNullOrEmpty(r)
|
||||
? r
|
||||
: (!string.IsNullOrEmpty(reportId) ? reportId : "");
|
||||
|
||||
string templatePath = Path.Combine(AppContext.BaseDirectory, "Content", "FDS_Template.html");
|
||||
|
||||
// Report configuration (refresh interval + cache flag) from the catalog.
|
||||
int ciRefresh = -2;
|
||||
bool ciCache = false;
|
||||
try
|
||||
{
|
||||
var catalog = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
|
||||
Conn,
|
||||
new List<SqlParameter> { SQL_VarChar("@report_name", report), SQL_VarChar("@authuser", userAccountId) },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
var cfg = catalog.FirstRow.toObjectDictionary();
|
||||
if (cfg.TryGetValue("refresh", out var rf) && rf is not null && rf is not DBNull &&
|
||||
int.TryParse(rf.ToString(), out var rfi)) ciRefresh = rfi;
|
||||
if (cfg.TryGetValue("functions", out var fn) && fn is not null)
|
||||
ciCache = (fn.ToString() ?? "").Split(',').Contains("cache");
|
||||
}
|
||||
catch (Exception cex)
|
||||
{
|
||||
_logger.LogError(cex, "Report catalog read failed for report {Report}", report);
|
||||
}
|
||||
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("report.process");
|
||||
act?.SetTag("fuchs.report.fn", tgt);
|
||||
act?.SetTag("fuchs.report.id", report);
|
||||
_logger.LogInformation("Report request fnc={Fnc} report={Report} tgt={Tgt} cache={Cache} user={User}",
|
||||
fnc, report, tgt, ciCache, userAccountId);
|
||||
try
|
||||
{
|
||||
switch (tgt)
|
||||
{
|
||||
case "generic_content":
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
return new ContentResult
|
||||
{
|
||||
Content = await FuchsVisualization.RenderContentAsync(
|
||||
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms),
|
||||
ContentType = "text/html"
|
||||
};
|
||||
|
||||
case "generic":
|
||||
{
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
var page = await FuchsVisualization.RenderPageAsync(
|
||||
Conn, dbSec, userAccountId, report, report, FdsQueryType.generic, prms,
|
||||
FdsDestination.web, templatePath, allowcache: ciCache, forceReload: ciForce);
|
||||
ApplyReload(page, prms, ciRefresh);
|
||||
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
|
||||
}
|
||||
|
||||
case "chart":
|
||||
byte[]? png = await FuchsVisualization.RenderQueryAsChartAsync(
|
||||
Conn, dbSec, userAccountId, report, FdsQueryType.generic, prms);
|
||||
if (png is null) return new StatusCodeResult(500);
|
||||
return new FileContentResult(png, "image/png")
|
||||
{
|
||||
FileDownloadName = $"{report.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd_HHmm}.png"
|
||||
};
|
||||
|
||||
case "xls":
|
||||
return new StatusCodeResult(501); // not implemented (matches legacy NotImplementedException)
|
||||
|
||||
default:
|
||||
if (Enum.TryParse<FdsQueryType>(fnc, ignoreCase: true, out var qt))
|
||||
{
|
||||
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
|
||||
var page = await FuchsVisualization.RenderPageAsync(
|
||||
Conn, dbSec, userAccountId, report, report, qt, prms,
|
||||
FdsDestination.web, templatePath);
|
||||
ApplyReload(page, prms, -2);
|
||||
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
|
||||
}
|
||||
return new StatusCodeResult(300);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt);
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
FuchsTelemetry.ReportRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("fn", tgt));
|
||||
FuchsTelemetry.ReportsRendered.Add(1, new KeyValuePair<string, object?>("fn", tgt));
|
||||
_logger.LogDebug("Report request completed tgt={Tgt} report={Report} in {Ms} ms", tgt, report, sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyReload(FuchsHtmlPage page, IDictionary<string, string> prms, int ciRefresh)
|
||||
{
|
||||
if (prms.TryGetValue("reload", out var rl) && int.TryParse(rl, out var rs)) page.ReloadSeconds = rs;
|
||||
else if (ciRefresh > -2) page.ReloadSeconds = ciRefresh;
|
||||
else if (DefaultReloadSeconds > 0) page.ReloadSeconds = DefaultReloadSeconds;
|
||||
|
||||
if (page.QueryDuration > 180 && page.ReloadSeconds is > 0 and < 3600) page.ReloadSeconds = 1200;
|
||||
else if (page.QueryDuration > 60 && page.ReloadSeconds is > 0 and < 1200) page.ReloadSeconds = 1200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
@@ -12,8 +12,9 @@ using static OCORE.web.mvc_helper_async;
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Widget service implementation. Replaces the static <c>FuchsWidgets</c> class.
|
||||
/// No longer depends on <c>IntranetController</c>.
|
||||
/// Widget service for the Fuchs intranet dashboard. Port of fuchs_fds_widgets.vb —
|
||||
/// SQL-driven widget cases. Replaces the static <c>FuchsWidgets</c> class
|
||||
/// (no longer depends on <c>IntranetController</c>).
|
||||
/// </summary>
|
||||
public class FuchsWidgetService : IWidgetService
|
||||
{
|
||||
@@ -26,39 +27,48 @@ public class FuchsWidgetService : IWidgetService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId,
|
||||
DatabaseSecurity dbSec, HttpRequest request)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("widget.get");
|
||||
act?.SetTag("fuchs.widget.id", widgetId);
|
||||
_logger.LogDebug("GetWidgetAsync widget={WidgetId} user={User}", widgetId, userAccountId);
|
||||
try
|
||||
{
|
||||
return widgetId.ToLower() switch
|
||||
var result = widgetId.ToLower() switch
|
||||
{
|
||||
"my" => await HandleWidgetMy(userAccountId, dbSec),
|
||||
"my" => await HandleWidgetMy(userAccountId, dbSec),
|
||||
"one" => await HandleWidgetOne(userAccountId, dbSec, request),
|
||||
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
|
||||
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
|
||||
};
|
||||
_logger.LogDebug("GetWidgetAsync widget={WidgetId} result={Result} user={User}",
|
||||
widgetId, result.GetType().Name, userAccountId);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SqlParameter> MakeParams(string userAccountId, params SqlParameter[] extra)
|
||||
private static List<Microsoft.Data.SqlClient.SqlParameter> Params(string userAccountId,
|
||||
params Microsoft.Data.SqlClient.SqlParameter[] extra)
|
||||
{
|
||||
var list = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
var list = new List<Microsoft.Data.SqlClient.SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
list.AddRange(extra);
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── "my" — list of widget short-names for the current user ───────────────
|
||||
private async Task<IActionResult> HandleWidgetMy(string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId, SQL_VarChar("@account", "fis")),
|
||||
Security: dbSec);
|
||||
Conn, Params(userAccountId, SQL_VarChar("@account", "fis")), Security: dbSec);
|
||||
var names = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
|
||||
@@ -67,6 +77,7 @@ public class FuchsWidgetService : IWidgetService
|
||||
return await JSONAsync(names);
|
||||
}
|
||||
|
||||
// ── "one" — full widget data for a single widget ──────────────────────────
|
||||
private async Task<IActionResult> HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec,
|
||||
HttpRequest request)
|
||||
{
|
||||
@@ -75,55 +86,120 @@ public class FuchsWidgetService : IWidgetService
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId,
|
||||
Conn,
|
||||
Params(userAccountId,
|
||||
SQL_VarChar("@shortname", shortName),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
Security: dbSec);
|
||||
|
||||
if (dt.Count != 1) return new StatusCodeResult(404);
|
||||
var wdg = dt.FirstRow.toObjectDictionary();
|
||||
return await BuildWidgetResponse(userAccountId, dbSec, wdg);
|
||||
return await BuildWidgetResponse(userAccountId, dbSec, shortName, wdg);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
|
||||
// ── Unknown widget id ──────────────────────────────────────────────────────
|
||||
// The dashboard only requests "my" and "one"; there is no generic widget
|
||||
// source in the schema (the legacy code had no such procedure either).
|
||||
private Task<IActionResult> HandleWidgetGeneric(string widgetId, string userAccountId,
|
||||
DatabaseSecurity dbSec)
|
||||
{
|
||||
var pl = MakeParams(userAccountId, SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "data" },
|
||||
Security: dbSec);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
data = dset.Tables("data").toArrayofObjectDictionaries()
|
||||
});
|
||||
_ = dbSec;
|
||||
_logger.LogWarning("GetWidgetAsync: unknown widget id '{WidgetId}' requested by user={User}", widgetId, userAccountId);
|
||||
return Task.FromResult<IActionResult>(new NotFoundResult());
|
||||
}
|
||||
|
||||
private async Task<IActionResult> BuildWidgetResponse(string userAccountId,
|
||||
DatabaseSecurity dbSec, Dictionary<string, object?> wdg)
|
||||
// ── Widget renderer dispatcher ────────────────────────────────────────────
|
||||
private async Task<IActionResult> BuildWidgetResponse(string userAccountId, DatabaseSecurity dbSec,
|
||||
string shortName, Dictionary<string, object?> wdg)
|
||||
{
|
||||
string type = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
string dbType = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
var ropts = ParseRenderingOptions(wdg.nz("rendering_options", "") ?? "");
|
||||
string name = wdg.nz("name", "") ?? "";
|
||||
string descr = wdg.nz("description", "") ?? "";
|
||||
|
||||
if (type.StartsWith("sql") && !string.IsNullOrEmpty(sql))
|
||||
object widgetData;
|
||||
|
||||
switch (dbType)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
MakeParams(userAccountId), Security: dbSec);
|
||||
return await JSONAsync(new
|
||||
case "sql_table":
|
||||
{
|
||||
name = wdg.nz("name", ""),
|
||||
type,
|
||||
rows = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary())
|
||||
.ToArray()
|
||||
});
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "table",
|
||||
rendering_options = ropts,
|
||||
columns = dt.DataTable.Columns.Cast<System.Data.DataColumn>()
|
||||
.Select(c => c.ColumnName).ToArray(),
|
||||
data = dt.DataTable.Rows.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary()).ToArray()
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sql_indicator":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
var firstRow = dt.DataTable.Rows.Count > 0
|
||||
? dt.DataTable.Rows[0].toObjectDictionary()
|
||||
: new Dictionary<string, object?>();
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "ind",
|
||||
rendering_options = ropts,
|
||||
data = new
|
||||
{
|
||||
status = firstRow.nz("status", "") ?? "",
|
||||
value = firstRow.nz("value", "") ?? "",
|
||||
label = firstRow.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "html",
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? ""
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = dbType,
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? "",
|
||||
url = wdg.nz("url", "") ?? "",
|
||||
image = wdg.nz("image", "") ?? "",
|
||||
data = (object)new
|
||||
{
|
||||
status = wdg.nz("status", "") ?? "",
|
||||
value = wdg.nz("value", "") ?? "",
|
||||
label = wdg.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return await JSONAsync(wdg);
|
||||
// Wrap under short_name key so the front-end can do response[wi]
|
||||
return await JSONAsync(new Dictionary<string, object> { [shortName] = widgetData });
|
||||
}
|
||||
|
||||
private static string[] ParseRenderingOptions(string raw) =>
|
||||
string.IsNullOrWhiteSpace(raw)
|
||||
? Array.Empty<string>()
|
||||
: raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for MT940 bank statement parsing.
|
||||
/// Abstraction for bank statement parsing. Supports both MT940 (SWIFT text)
|
||||
/// and CAMT (ISO 20022 camt.052/053/054 XML); the format is auto-detected.
|
||||
/// </summary>
|
||||
public interface IBankingService
|
||||
{
|
||||
/// <summary>Abbreviation for a debit/credit mark.</summary>
|
||||
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
|
||||
|
||||
/// <summary>Parses an MT940 stream into a DataTable.</summary>
|
||||
/// <summary>
|
||||
/// Parses a bank statement stream (MT940 or CAMT, auto-detected) into a DataTable
|
||||
/// matching the banking-transactions schema.
|
||||
/// </summary>
|
||||
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQL connections to the Fuchs database.
|
||||
/// </summary>
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
/// <summary>Gets the primary FDS connection string.</summary>
|
||||
string ConnectionString { get; }
|
||||
|
||||
/// <summary>Creates and opens a new SQL connection.</summary>
|
||||
SqlConnection CreateConnection();
|
||||
|
||||
/// <summary>Creates a new SQL connection (not opened).</summary>
|
||||
SqlConnection CreateClosedConnection();
|
||||
}
|
||||
@@ -13,8 +13,8 @@ public interface IInvoiceService
|
||||
/// <summary>Loads an existing invoice by ID.</summary>
|
||||
Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Registers (creates or updates) an invoice from form data.</summary>
|
||||
Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
|
||||
/// <summary>Registers (creates or updates) an invoice from a parsed data object.</summary>
|
||||
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Generates a PDF document for an invoice.</summary>
|
||||
|
||||
@@ -13,8 +13,8 @@ public interface IReminderService
|
||||
/// <summary>Loads an existing reminder by ID.</summary>
|
||||
Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Registers (creates) a reminder from form data.</summary>
|
||||
Task<FdsReminderData> RegisterReminderAsync(object formData, bool change, string remId,
|
||||
/// <summary>Registers (creates) a reminder from a parsed data object.</summary>
|
||||
Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Generates a PDF document for a reminder.</summary>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for report processing.
|
||||
/// Abstraction for SQL-driven report processing (HTML page / fragment / PNG chart).
|
||||
/// </summary>
|
||||
public interface IReportService
|
||||
{
|
||||
/// <summary>Processes a report request.</summary>
|
||||
Task<IActionResult> ProcessRequestAsync(string action, string id,
|
||||
string userAccountId, DatabaseSecurity dbSec);
|
||||
/// <summary>
|
||||
/// Processes a report request.
|
||||
/// </summary>
|
||||
/// <param name="fnc">Target function: "generic", "generic_content"/"gct", "chart", or a query-type name.</param>
|
||||
/// <param name="reportId">Report name/id (the URL <c>code</c> segment).</param>
|
||||
/// <param name="userAccountId">Authenticated user id.</param>
|
||||
/// <param name="dbSec">Database security context.</param>
|
||||
/// <param name="parameters">Merged query-string + form parameters.</param>
|
||||
Task<IActionResult> ProcessRequestAsync(string fnc, string reportId,
|
||||
string userAccountId, DatabaseSecurity dbSec, IDictionary<string, string> parameters);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,151 @@
|
||||
using Fuchs.intranet;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using OCORE.security;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice service implementation. Extracts DB operations from <c>FdsInvoiceData</c>
|
||||
/// and PDF generation into a proper DI service.
|
||||
/// Invoice service — load, register, render and store invoices.
|
||||
/// Replaces the controller-coupled, sync-over-async logic that previously lived
|
||||
/// inside <see cref="FdsInvoiceData"/>.
|
||||
/// </summary>
|
||||
public class InvoiceService : IInvoiceService
|
||||
{
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdfService, ILogger<InvoiceService> logger)
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdfService = pdfService;
|
||||
_pdf = pdf;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
// TODO: Complete after FdsInvoiceData is refactored to remove IntranetController dependency
|
||||
throw new NotImplementedException("InvoiceService.LoadInvoiceAsync pending FdsInvoiceData refactor.");
|
||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||
var inv = new FdsInvoiceData();
|
||||
if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadInvoiceAsync called with empty id (user={User})", userAccountId); return inv; }
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", id),
|
||||
SQL_Bit("@includefile", false)
|
||||
};
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
|
||||
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
_logger.LogError("LoadInvoiceAsync sql exception for {Id}: {Ex}", id, dset.Exception);
|
||||
|
||||
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
||||
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
|
||||
return inv;
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(object formData, bool change, string invId,
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.RegisterInvoiceAsync pending FdsInvoiceData refactor.");
|
||||
_logger.LogInformation("RegisterInvoiceAsync change={Change} invId={InvId} user={User}", change, invId, userAccountId);
|
||||
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
|
||||
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (invoice.RawProvisionLocation.Length > 0)
|
||||
{
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
Conn, pl, tablenames: new[] { "inv", "det", "req", "itm" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
|
||||
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.GenerateInvoicePdf pending FdsInvoiceData refactor.");
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||
act?.SetTag("fuchs.invoice.draft", draft);
|
||||
_logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = reg?.getString("Id") ?? "",
|
||||
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
};
|
||||
// WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core).
|
||||
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
|
||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||
_pdf.ApplyInvoice(doc, tb, invoice, draft);
|
||||
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
|
||||
return doc;
|
||||
}
|
||||
|
||||
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.RenderInvoicePdfBytesAsync pending FdsInvoiceData refactor.");
|
||||
}
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
throw new NotImplementedException("InvoiceService.StoreInvoiceDocumentFileAsync pending FdsInvoiceData refactor.");
|
||||
byte[] ba;
|
||||
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
fds.IFdsMfr mfr)
|
||||
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
|
||||
{
|
||||
if (invoice.InvoiceRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory implementation for <see cref="fds.FdsMfrClient"/>.
|
||||
/// Centralizes MFR client creation and supplies logger from DI.
|
||||
/// Centralizes MFR client creation, supplies the logger from DI, and counts
|
||||
/// client instantiations as a proxy for MFR ERP interactions.
|
||||
/// </summary>
|
||||
public class MfrClientFactory : IMfrClientFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<MfrClientFactory> _logger;
|
||||
|
||||
public MfrClientFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
|
||||
}
|
||||
|
||||
public fds.FdsMfrClient Create()
|
||||
{
|
||||
FuchsTelemetry.MfrCalls.Add(1);
|
||||
_logger.LogDebug("Creating new FdsMfrClient instance.");
|
||||
return new fds.FdsMfrClient(_loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"AccountId": "",
|
||||
"Token": "MANAGED_BY_KEYVAULT",
|
||||
"Enabled": false
|
||||
},
|
||||
"Telemetry": {
|
||||
"Enabled": true,
|
||||
"OtlpEndpoint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Data;
|
||||
using programmersdigest.MT940Parser;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// MT940 bank statement parser helpers.
|
||||
/// </summary>
|
||||
public static class Banking
|
||||
{
|
||||
public static string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
|
||||
{
|
||||
DebitCreditMark.Credit => "C",
|
||||
DebitCreditMark.Debit => "D",
|
||||
DebitCreditMark.ReverseCredit => "RC",
|
||||
DebitCreditMark.ReverseDebit => "RD",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public static DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
logger ??= NullLogger.Instance;
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null)
|
||||
nr[key] = value;
|
||||
}
|
||||
|
||||
using var ps = new Parser(stream: stream);
|
||||
try
|
||||
{
|
||||
foreach (var statement in ps.Parse())
|
||||
{
|
||||
if (string.IsNullOrEmpty(statement.AccountIdentification)) continue;
|
||||
foreach (var line in statement.Lines)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nr = tbl.NewRow();
|
||||
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
|
||||
if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
|
||||
if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
|
||||
if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
|
||||
SetNfo(nr, "BankReference", line.BankReference);
|
||||
|
||||
var info = line.InformationToOwner;
|
||||
SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
|
||||
SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
|
||||
SetNfo(nr, "CompensationAmount", info.CompensationAmount);
|
||||
SetNfo(nr, "CreditorReference", info.CreditorReference);
|
||||
SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
|
||||
SetNfo(nr, "CustomerReference", info.CustomerReference);
|
||||
SetNfo(nr, "EndToEndReference", info.EndToEndReference);
|
||||
SetNfo(nr, "JournalNumber", info.JournalNumber);
|
||||
SetNfo(nr, "MandateReference", info.MandateReference);
|
||||
SetNfo(nr, "NameOfPayer", info.NameOfPayer);
|
||||
SetNfo(nr, "OriginalAmount", info.OriginalAmount);
|
||||
SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
|
||||
SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
|
||||
SetNfo(nr, "PostingText", info.PostingText);
|
||||
SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
|
||||
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
|
||||
SetNfo(nr, "TransactionCode", info.TransactionCode);
|
||||
SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
|
||||
SetNfo(nr, "UnstructuredData", info.UnstructuredData);
|
||||
SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
|
||||
|
||||
SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
|
||||
SetNfo(nr, "SupplementaryDetails",line.SupplementaryDetails);
|
||||
SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
|
||||
SetNfo(nr, "ValueDate", line.ValueDate);
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { logger.LogError(ex, "MT940 statement parse failed."); }
|
||||
|
||||
tbl.AcceptChanges();
|
||||
return tbl;
|
||||
}
|
||||
|
||||
private static DataTable BuildDefaultSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
var cols = t.Columns;
|
||||
cols.Add("AccountIdentification", typeof(string));
|
||||
cols.Add("Amount", typeof(decimal));
|
||||
cols.Add("BankReference", typeof(string));
|
||||
cols.Add("EntryDate", typeof(DateTime));
|
||||
cols.Add("FundsCode", typeof(string));
|
||||
cols.Add("AccountNumberOfPayer", typeof(string));
|
||||
cols.Add("BankCodeOfPayer", typeof(string));
|
||||
cols.Add("CompensationAmount", typeof(string));
|
||||
cols.Add("CreditorReference", typeof(string));
|
||||
cols.Add("CreditorsReferenceParty", typeof(string));
|
||||
cols.Add("CustomerReference", typeof(string));
|
||||
cols.Add("EndToEndReference", typeof(string));
|
||||
cols.Add("JournalNumber", typeof(string));
|
||||
cols.Add("MandateReference", typeof(string));
|
||||
cols.Add("NameOfPayer", typeof(string));
|
||||
cols.Add("OriginalAmount", typeof(string));
|
||||
cols.Add("OriginatorsIdentificationCode", typeof(string));
|
||||
cols.Add("PayersReferenceParty", typeof(string));
|
||||
cols.Add("PostingText", typeof(string));
|
||||
cols.Add("SepaRemittanceInformation", typeof(string));
|
||||
cols.Add("TextKeyAddition", typeof(int));
|
||||
cols.Add("TransactionCode", typeof(int));
|
||||
cols.Add("IsUnstructuredData", typeof(bool));
|
||||
cols.Add("UnstructuredData", typeof(string));
|
||||
cols.Add("UnstructuredRemittanceInformation", typeof(string));
|
||||
cols.Add("DebitCreditMark", typeof(string));
|
||||
cols.Add("SupplementaryDetails", typeof(string));
|
||||
cols.Add("TransactionTypeIdCode", typeof(string));
|
||||
cols.Add("ValueDate", typeof(DateTime));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
+87
-177
@@ -1,10 +1,6 @@
|
||||
using System.Data;
|
||||
using Fuchs.Controllers;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.commons;
|
||||
@@ -12,25 +8,26 @@ using static OCORE.commons;
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates invoice (Rechnung) data. Converted from VB fds__invoice_data class.
|
||||
/// Invoice (Rechnung) data holder. Converted from VB fds__invoice_data.
|
||||
/// Pure data + parameter mapping — all persistence and PDF generation now live
|
||||
/// in <see cref="Fuchs.Services.IInvoiceService"/> (no controller coupling).
|
||||
/// </summary>
|
||||
public class FdsInvoiceData
|
||||
{
|
||||
private readonly JObject? _base;
|
||||
private Document? _letter;
|
||||
|
||||
public GenericObjectDictionary? Admin { get; private set; }
|
||||
public GenericObjectDictionary? NewValues { get; private set; }
|
||||
public GenericObjectDictionary? Sms { get; private set; }
|
||||
public List<Dictionary<string, object>>? Req { get; private set; }
|
||||
|
||||
public GenericObjectDictionary? InvoiceRegistration { get; private set; }
|
||||
public bool IsDraft { get; private set; } = true;
|
||||
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
|
||||
public bool IsDraft { get; internal set; } = true;
|
||||
|
||||
public string Id => InvoiceRegistration?.getString("Id") ?? "";
|
||||
public string PaymentTerms => InvoiceRegistration?.getString("PaymentTerm") ?? "";
|
||||
|
||||
// -- PDF-facing properties (used by FuchsPdf.ApplyInvoice) ----------------
|
||||
// -- PDF-facing properties (used by IPdfService.ApplyInvoice) -------------
|
||||
public string InvoiceType =>
|
||||
InvoiceRegistration?.getString("InvoiceType").Substr(0, 1) ?? "R";
|
||||
public string InvoiceId =>
|
||||
@@ -56,7 +53,7 @@ public class FdsInvoiceData
|
||||
{
|
||||
IEnumerable<Dictionary<string, object?>>? itms =
|
||||
itmsObj as IEnumerable<Dictionary<string, object?>>
|
||||
?? (itmsObj is Newtonsoft.Json.Linq.JArray ja
|
||||
?? (itmsObj is JArray ja
|
||||
? ja.ToObject<List<Dictionary<string, object?>>>()
|
||||
: null);
|
||||
if (itms != null) result.AddRange(itms);
|
||||
@@ -73,33 +70,33 @@ public class FdsInvoiceData
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, object?>>();
|
||||
if (InvoiceRegistration == null) return result;
|
||||
// Primary VAT slot
|
||||
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_1"), out decimal ust1)
|
||||
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net1"), out decimal net1)
|
||||
&& !(ust1 == 0 && net1 == 0))
|
||||
result[ust1.ToString("0.##")] = new Dictionary<string, object?>
|
||||
{ ["vat_amount"] = net1 };
|
||||
// Secondary VAT slot
|
||||
result[ust1.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net1 };
|
||||
if (FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_2"), out decimal ust2)
|
||||
&& FuchsPdf.ParseDec(InvoiceRegistration.getItem("InvoiceVAT_net2"), out decimal net2)
|
||||
&& !(ust2 == 0 && net2 == 0))
|
||||
result[ust2.ToString("0.##")] = new Dictionary<string, object?>
|
||||
{ ["vat_amount"] = net2 };
|
||||
result[ust2.ToString("0.##")] = new Dictionary<string, object?> { ["vat_amount"] = net2 };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Raw properties --------------------------------------------------------
|
||||
private string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
||||
private string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
private string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
private string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
||||
private string[] RawProvisionLocation =>
|
||||
// -- Raw form properties (used by parameter mapping) -----------------------
|
||||
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
||||
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
||||
internal string[] RawProvisionLocation =>
|
||||
NewValues?.nz("provisionlocation") is { Length: > 0 } s
|
||||
? s.Replace("\r\n", "\n").Split('\n').Select(t => t.Trim()).Where(t => t != "").ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
// -- Ctors -----------------------------------------------------------------
|
||||
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
|
||||
public FdsInvoiceData() { }
|
||||
|
||||
/// <summary>Parses form data (admin/new/sms/req) into the data object.</summary>
|
||||
public FdsInvoiceData(object ctd)
|
||||
{
|
||||
_base = ctd as JObject;
|
||||
@@ -113,157 +110,14 @@ public class FdsInvoiceData
|
||||
}
|
||||
}
|
||||
|
||||
public FdsInvoiceData(string id, IntranetController ctrl) => RegisterInvoice(id, ctrl);
|
||||
|
||||
// -- PDF -------------------------------------------------------------------
|
||||
public Document InvoicePDF(IntranetController ctrl)
|
||||
// -- Parameter mapping (consumed by InvoiceService.RegisterInvoiceAsync) ---
|
||||
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
|
||||
{
|
||||
if (_letter != null) return _letter;
|
||||
if (InvoiceRegistration == null) RegisterInvoice(Id, ctrl);
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = InvoiceRegistration?.getString("Id") ?? "",
|
||||
Address = InvoiceRegistration?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = InvoiceRegistration?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = InvoiceRegistration?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = InvoiceRegistration?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
};
|
||||
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
|
||||
_letter.Info.Title = InvoiceRegistration?.getString("InvoiceTitle") ?? "";
|
||||
FuchsPdf.ApplyInvoice(_letter, tb, this, draft: IsDraft);
|
||||
return _letter;
|
||||
}
|
||||
|
||||
// -- File operations -------------------------------------------------------
|
||||
public async Task<byte[]?> GetInvoiceFile(IntranetController ctrl)
|
||||
{
|
||||
if (InvoiceRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
byte[]? ba = null;
|
||||
ctrl._mfr.GetFdsDoc(ref ba, Id, "invoice");
|
||||
return ba;
|
||||
}
|
||||
return await RenderToPdfBytes(ctrl);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFile(IntranetController ctrl)
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderToPdfBytes(ctrl); }
|
||||
catch { ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", Id));
|
||||
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
|
||||
{
|
||||
var pdfrend = new PdfDocumentRenderer() { Document = InvoicePDF(ctrl) };
|
||||
pdfrend.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
pdfrend.PdfDocument.Save(ms, false);
|
||||
ms.Position = 0;
|
||||
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
|
||||
}
|
||||
|
||||
// -- Registration ----------------------------------------------------------
|
||||
public void RegisterInvoice(IntranetController ctrl, bool change, string invId)
|
||||
{
|
||||
if (NewValues == null) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist();
|
||||
pl.AddRange(BuildInvoiceParams(change, invId));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (RawProvisionLocation.Length > 0)
|
||||
{
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "inv", "det", "req", "itm" },
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice - sql exception",
|
||||
data: new { exception = invdset.Exception });
|
||||
|
||||
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public void RegisterInvoice(string id, IntranetController ctrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@Id", id));
|
||||
pl.Add(SQL_Bit("@includefile", false));
|
||||
var invdset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id) - sql exception",
|
||||
data: new { exception = invdset.Exception });
|
||||
InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
IsDraft = InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsInvoiceData.RegisterInvoice(id)", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
// -- Param builder ---------------------------------------------------------
|
||||
private List<SqlParameter> BuildInvoiceParams(bool change, string invId)
|
||||
{
|
||||
var vatsDic = new Dictionary<string, string>();
|
||||
if (Req != null)
|
||||
{
|
||||
foreach (var rq in Req)
|
||||
{
|
||||
if (rq.TryGetValue("items", out var itmsObj) &&
|
||||
itmsObj is List<object> itms)
|
||||
{
|
||||
foreach (var itm in itms.OfType<Dictionary<string, object?>>())
|
||||
{
|
||||
string vatKey = itm.nz("vat", "").Replace("%", "").Trim();
|
||||
if (!string.IsNullOrEmpty(vatKey) && vatKey != "0")
|
||||
vatsDic.TryAdd(vatKey, vatsDic.TryGetValue(vatKey, out var ve) ? ve : "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
string vathigh = vatsDic.Keys.OrderByDescending(k => double.TryParse(k, out var d) ? d : 0).FirstOrDefault() ?? "19";
|
||||
_ = change; _ = invId;
|
||||
// VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
|
||||
// matching the legacy contract. Item-level VAT strings are German-formatted and
|
||||
// single-rate procs only store one rate, so the highest rate wins.
|
||||
var (vatRate, vatNet) = HighestVat(Sms);
|
||||
|
||||
return new List<SqlParameter>
|
||||
{
|
||||
@@ -271,8 +125,8 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"),
|
||||
SQL_VarChar("@InvoiceVAT_1", vathigh),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
|
||||
@@ -281,8 +135,64 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||
SQL_VarChar("@InvoiceOptions",
|
||||
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
|
||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
||||
/// selects the set-pricing display mode (the default <c>setprice</c> is omitted).
|
||||
/// Read back by <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/> (set mode)
|
||||
/// and the §13b PDF note. Both ride the <c>admin</c> object the editor posts.
|
||||
/// </summary>
|
||||
internal string BuildInvoiceOptions()
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
|
||||
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
|
||||
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the highest VAT rate and its net VAT amount from the editor's
|
||||
/// computed <c>sms.vat</c> map (rate string → amount), e.g. {"19,0%": 123.45}.
|
||||
/// Mirrors the legacy contract (VAT comes from the totals block, not line items).
|
||||
/// Returns invariant numeric strings — rate ("19") and amount ("123.45") — ready
|
||||
/// for the <c>numeric</c> proc params. Empty/absent map → ("0","0").
|
||||
/// </summary>
|
||||
internal static (string rate, string netAmount) HighestVat(GenericObjectDictionary? sms)
|
||||
{
|
||||
if (sms == null || !sms.TryGetValue("vat", out var vobj) || vobj is null)
|
||||
return ("0", "0");
|
||||
|
||||
bool found = false; double bestRate = 0, bestAmount = 0;
|
||||
void Consider(string key, double amount)
|
||||
{
|
||||
double r = ParseRate(key);
|
||||
if (!found || r > bestRate) { found = true; bestRate = r; bestAmount = amount; }
|
||||
}
|
||||
|
||||
if (vobj is JObject jo)
|
||||
foreach (var p in jo)
|
||||
Consider(p.Key, p.Value is { } t && t.Type is JTokenType.Float or JTokenType.Integer ? t.Value<double>() : 0);
|
||||
else if (vobj is IDictionary<string, object?> dict)
|
||||
foreach (var kv in dict)
|
||||
Consider(kv.Key, double.TryParse(Convert.ToString(kv.Value, CultureInfo.InvariantCulture),
|
||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var a) ? a : 0);
|
||||
|
||||
if (!found) return ("0", "0");
|
||||
string rate = bestRate == Math.Floor(bestRate)
|
||||
? ((long)bestRate).ToString(CultureInfo.InvariantCulture)
|
||||
: bestRate.ToString(CultureInfo.InvariantCulture);
|
||||
return (rate, bestAmount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Parses a VAT rate string ("19,0%", "7%", "19") to a number (German or invariant).</summary>
|
||||
private static double ParseRate(string? key)
|
||||
{
|
||||
string s = (key ?? "").Replace("%", "").Trim().Replace(',', '.');
|
||||
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-167
@@ -1,42 +1,35 @@
|
||||
using System.Data;
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.commons;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a reminder (Zahlungserinnerung) data object.
|
||||
/// Converted from VB fds__reminder_data class.
|
||||
/// Reminder (Zahlungserinnerung) data holder. Converted from VB fds__reminder_data.
|
||||
/// Pure data — persistence and PDF generation live in
|
||||
/// <see cref="Fuchs.Services.IReminderService"/> (no controller coupling).
|
||||
/// </summary>
|
||||
public class FdsReminderData
|
||||
{
|
||||
private readonly JObject? _base;
|
||||
private Document? _letter;
|
||||
|
||||
public GenericObjectDictionary? NewValues { get; private set; }
|
||||
public GenericObjectDictionary? Rem { get; private set; }
|
||||
public GenericObjectDictionary? ReminderRegistration { get; private set; }
|
||||
public bool IsDraft { get; private set; } = true;
|
||||
public GenericObjectDictionary? ReminderRegistration { get; internal set; }
|
||||
public bool IsDraft { get; internal set; } = true;
|
||||
|
||||
public string Id => ReminderRegistration?.getString("Id") ?? "";
|
||||
|
||||
// -- Raw props from form data ---------------------------------------------
|
||||
public string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
|
||||
internal string[] RawInvoiceAddress => NewValues?.nz("invoiceaddress") is { Length: > 0 } s
|
||||
? s.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n")
|
||||
.Replace("\r\n", "\n").Replace("\n\n", "\n").Split('\n')
|
||||
.Select(t => System.Web.HttpUtility.HtmlDecode(t.Trim())).Where(t => t != "").ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
public string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
public string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
|
||||
public string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||
internal string RawInvId => Rem?.nz("invid").ne(Rem?.nz("InvId") ?? "").Trim() ?? "";
|
||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
|
||||
// -- Computed props from registration -------------------------------------
|
||||
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
|
||||
@@ -59,11 +52,10 @@ public class FdsReminderData
|
||||
get
|
||||
{
|
||||
if (ReminderRegistration == null) return new List<Dictionary<string, object?>>();
|
||||
// Items are stored as nested JSON under "invoices" key in the registration
|
||||
var raw = ReminderRegistration.getItem("invoices");
|
||||
try
|
||||
{
|
||||
if (raw is Newtonsoft.Json.Linq.JArray ja)
|
||||
if (raw is JArray ja)
|
||||
return ja.ToObject<List<Dictionary<string, object?>>>() ?? new();
|
||||
if (raw is string s && s.StartsWith("["))
|
||||
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object?>>>(s) ?? new();
|
||||
@@ -74,6 +66,10 @@ public class FdsReminderData
|
||||
}
|
||||
|
||||
// -------------------------------- Ctors ----------------------------------
|
||||
/// <summary>Empty instance (used when loading from the DB via the service).</summary>
|
||||
public FdsReminderData() { }
|
||||
|
||||
/// <summary>Parses form data (new/rem) into the data object.</summary>
|
||||
public FdsReminderData(object ctd)
|
||||
{
|
||||
if (ctd is JObject jo) { _base = jo; }
|
||||
@@ -84,153 +80,4 @@ public class FdsReminderData
|
||||
if (_base.ContainsKey("rem")) Rem = new GenericObjectDictionary(_base["rem"]!.ToObject<Dictionary<string, object>>()!);
|
||||
}
|
||||
}
|
||||
|
||||
public FdsReminderData(string id, IntranetController ctrl) => RegisterReminder(id, ctrl);
|
||||
|
||||
// -------------------------------- PDF ------------------------------------
|
||||
public Document ReminderPDF(IntranetController ctrl)
|
||||
{
|
||||
if (_letter != null) return _letter;
|
||||
if (ReminderRegistration == null) RegisterReminder(Id, ctrl);
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = "",
|
||||
Address = InvoiceAddress,
|
||||
AdminUser = UserNameFinalized,
|
||||
AdminUserEmail = UserEmailFinalized,
|
||||
AdminDatumValue = DateCreated ?? DateTime.Now
|
||||
};
|
||||
if (!string.IsNullOrEmpty(RawCustomValues))
|
||||
{
|
||||
var o = new GenericObjectDictionary(RawCustomValues);
|
||||
string oEmail = (string?)o.getItem("contactEmail") ?? "";
|
||||
string oName = (string?)o.getItem("contactName") ?? "";
|
||||
if (!string.IsNullOrEmpty(oEmail) || !string.IsNullOrEmpty(oName))
|
||||
{
|
||||
tb.AdminUser = oName;
|
||||
tb.AdminUserEmail = oEmail;
|
||||
}
|
||||
}
|
||||
_letter = Task.Run(async () => await FuchsPdf.WriteLetter(tb, draft: IsDraft, locale: FuchsPdf.DeCulture)).Result;
|
||||
_letter.Info.Title = ReminderTitle;
|
||||
FuchsPdf.ApplyReminder(_letter, tb, this, draft: IsDraft);
|
||||
return _letter;
|
||||
}
|
||||
|
||||
// --------------------------- File operations -----------------------------
|
||||
public async Task<byte[]> GetReminderFile(IntranetController ctrl)
|
||||
{
|
||||
if (ReminderRegistration?.getItem("IsFinal", false) is true)
|
||||
{
|
||||
if (!ReminderRegistration.ContainsKey("hasFile") || ReminderRegistration.getItem("hasFile", false) is not true)
|
||||
await StoreReminderDocumentFile(ctrl);
|
||||
byte[]? ba = null;
|
||||
ctrl._mfr.GetFdsDoc(ref ba, Id, "reminder");
|
||||
return ba ?? Array.Empty<byte>();
|
||||
}
|
||||
return await RenderToPdfBytes(ctrl);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreReminderDocumentFile(IntranetController ctrl)
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderToPdfBytes(ctrl); }
|
||||
catch { ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
var pl = ctrl.StdParamlist("Id", Id);
|
||||
pl.Add(new SqlParameter("@file", SqlDbType.VarBinary) { Value = ba });
|
||||
bool r = await setSQLValue_async("EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private async Task<byte[]> RenderToPdfBytes(IntranetController ctrl)
|
||||
{
|
||||
var pdfrend = new PdfDocumentRenderer() { Document = ReminderPDF(ctrl) };
|
||||
pdfrend.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
pdfrend.PdfDocument.Save(ms, false);
|
||||
ms.Position = 0;
|
||||
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
|
||||
}
|
||||
|
||||
// ---------------------------- Registration -------------------------------
|
||||
public void RegisterReminder(IntranetController ctrl, bool change, string remId)
|
||||
{
|
||||
if (Rem == null || Rem.Count == 0) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist();
|
||||
pl.Add(SQL_VarChar("InvId", RawInvId));
|
||||
pl.Add(SQL_Char("type", Rem["type"]?.ToString() ?? ""));
|
||||
pl.Add(SQL_Float("amount", stringvalue: NewValues?.nz("amount") ?? ""));
|
||||
pl.Add(SQL_Float("amount_payed", stringvalue: NewValues?.nz("amount_payed") ?? ""));
|
||||
pl.Add(SQL_VarChar("SendToAddress", string.Join("\n", RawInvoiceAddress)));
|
||||
pl.Add(SQL_NVarChar("SendToEmail", RawInvoiceEmail));
|
||||
pl.Add(SQL_NVarChar("subject", NewValues?.getString("subject") ?? "", dbNull_IfEmpty: true));
|
||||
pl.Add(SQL_NVarChar("text", NewValues?.nz("text") ?? "", dbNull_IfEmpty: true));
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change || string.IsNullOrEmpty(remId))
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
else
|
||||
pl.Add(SQL_VarChar("RemId", remId));
|
||||
|
||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec,
|
||||
tablenames: new[] { "rem" }, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder - sql exception",
|
||||
data: new { exception = remdset.Exception });
|
||||
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public void RegisterReminder(string id, IntranetController ctrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = ctrl.StdParamlist("Id", id);
|
||||
pl.Add(SQL_Bit("@includefile", false));
|
||||
var remdset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: ctrl.DbSec,
|
||||
tablenames: new[] { "admin", "rem" }, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id) - sql exception",
|
||||
data: new { exception = remdset.Exception });
|
||||
ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
IsDraft = !(ReminderRegistration.getItem("IsFinal") is true);
|
||||
}
|
||||
catch (Exception ex) { ctrl._intranet.debug_log("FdsReminderData.RegisterReminder(id)", ex: ex); }
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
public static FileInfo? GetStoredFile(ref byte[]? file, string reminderId, IntranetController ctrl)
|
||||
{
|
||||
var sqlrw = Task.Run(async () =>
|
||||
(await getSQLDataSet_async(
|
||||
"SELECT TOP(1) * FROM [dbo].[fds__reminder] WHERE [Id] = @Id AND [file] is not null;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(SQL_VarChar("@Id", reminderId)),
|
||||
Security: ctrl.DbSec))
|
||||
.FirstTable().FirstRow.toObjectDictionary()).Result;
|
||||
|
||||
if (sqlrw.Count > 0 && !string.IsNullOrEmpty(sqlrw.nz("DocumentName")) && sqlrw.no("file", null!) is byte[] b)
|
||||
{
|
||||
file = b;
|
||||
return new FileInfo(sqlrw.nz("DocumentName"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+162
-21
@@ -473,25 +473,26 @@ public static class FuchsPdf
|
||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||
}
|
||||
|
||||
// Data rows — from InvoiceItems
|
||||
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
|
||||
// For invoices without sets this passes items through unchanged; for sets it
|
||||
// emits set header + members per the chosen mode, blanking price cells where
|
||||
// a line should show no price. Totals come from the registration balance, so
|
||||
// the mode is purely presentational.
|
||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||
int pos = 1;
|
||||
foreach (var itm in inv.InvoiceItems)
|
||||
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
|
||||
{
|
||||
string title = itm.nz("title", "");
|
||||
string desc = itm.nz("desc", "");
|
||||
string qty = itm.nz("qty", "1");
|
||||
ParseDec(itm.no("price_net", 0), out decimal priceNet);
|
||||
ParseDec(itm.no("total_net", 0), out decimal totalNet);
|
||||
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title);
|
||||
if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"<div>{desc}</div>");
|
||||
row.Cells[2].AddParagraph(qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum";
|
||||
titleCell.Style = "TblCell_RTitle";
|
||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||
else titleCell.AddText(line.Title);
|
||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||
@@ -523,16 +524,95 @@ public static class FuchsPdf
|
||||
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
|
||||
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
|
||||
|
||||
// Payment terms note
|
||||
string terms = TranslatePaymentTerm(inv.PaymentTerms);
|
||||
string ibanLine = p13b
|
||||
? "Die Steuerschuldnerschaft geht auf den Leistungsempf\u00e4nger \u00fcber (§ 13b UStG)."
|
||||
: $"Bitte \u00fcberweisen Sie den Rechnungsbetrag innerhalb von {terms} auf unser Konto:\n" +
|
||||
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)";
|
||||
|
||||
// ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
|
||||
var reg = inv.InvoiceRegistration;
|
||||
void Note(string text, string style = "InvoiceNotes")
|
||||
{
|
||||
var p = sec.AddParagraph(); p.Style = "InvoiceNotes";
|
||||
p.AddText(ibanLine);
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
|
||||
}
|
||||
|
||||
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
|
||||
string serviceGross = Currency(reg?.getItem("InvoiceService"));
|
||||
|
||||
if (p13b)
|
||||
{
|
||||
string ustString = ParseDec(reg?.getItem("InvoiceVAT_1"), out decimal ust)
|
||||
? $" mit einem Steuersatz von {ust.ToString("0.##", DeCulture)}%" : "";
|
||||
Note("Gem. §13b Umsatzsteuergesetz unterliegen Sie der Steuerschuldnerschaft des " +
|
||||
$"Leistungsempfängers zur Umsatzsteuer aus dieser Rechnung{ustString}.");
|
||||
}
|
||||
|
||||
if (inv.InvoiceType == "i")
|
||||
{
|
||||
Note("Für bereits erbrachte Arbeiten, Dienstleistungen, Materiallieferungen und getätigte " +
|
||||
"Bestellvorgänge zum oben genannten Bauvorhaben, die sich aus dem mit Ihnen geschlossenen " +
|
||||
"Vertrag ergeben, stellen wir Ihnen vertragsgemäß unsere Akontozahlung in Rechnung. " +
|
||||
"Eine Endabrechnung erhalten Sie als Schlussrechnung nach Abschluss des gesamten Bauvorhabens. " +
|
||||
"Das Ausführungsdatum entnehmen Sie bitte dem Schlusstext dieser Rechnung. Wir danken Ihnen " +
|
||||
"herzlich für das entgegengebrachte Vertrauen und bitten Sie um kurzfristigen Ausgleich der Akontorechnung.");
|
||||
}
|
||||
else if (invService > 0 && !p13b && serviceGross != "?")
|
||||
{
|
||||
Note($"Im Bruttobetrag sind {serviceGross} Lohnkosten enthalten " +
|
||||
$"(netto {Currency(reg?.getItem("InvoiceService_net"))}). " +
|
||||
$"Die darin enthaltene Mehrwertsteuer beträgt {Currency(reg?.getItem("InvoiceService_VAT"))}.");
|
||||
}
|
||||
|
||||
Note("Bitte beachten Sie, nach §14 Abs. 1 Umsatzsteuergesetz ist diese Rechnung ein Zahlungsbeleg " +
|
||||
"oder eine andere beweiskräftige Unterlage für 2 Jahre nach Ablauf des Kalenderjahres der " +
|
||||
"Ausstellung dieser Rechnung aufzubewahren, soweit nicht aufgrund anderer gesetzlicher Regelungen " +
|
||||
"andere ggf. längere Aufbewahrungsfristen gelten.");
|
||||
|
||||
if (inv.InvoiceType != "i" && invService > 0 && !p13b)
|
||||
{
|
||||
decimal refundRate = ParseDec(reg?.getItem("tax_servicerefund"), out decimal rr) ? rr : 0.2m;
|
||||
Note(($"Privathaushalten erstattet das Finanzamt bis zu {Currency(invService * refundRate)} " +
|
||||
"des Arbeitslohns mit der nächsten Steuererklärung.").ToUpper(), "InvoiceNotes_ucb");
|
||||
}
|
||||
|
||||
Note("Unsere Allgemeinen und ihnen bekannten Geschäftsbedingungen gelten für alle unsere Angebote. " +
|
||||
"Wir liefern oder leisten ausschließlich zu diesen Bedingungen. Andere Bedingungen werden nicht " +
|
||||
"Vertragsinhalt, auch wenn wir diesen nicht ausdrücklich widersprochen haben. Ergänzend zu diesen " +
|
||||
"Bedingungen gelten unsere Zusatzbedingungen für allgemeine Dienstverträge, Handwerksleitungen und " +
|
||||
"Wartungsverträge. Spätestens mit der Entgegennahme der entsprechenden Lieferung und/oder Leistung " +
|
||||
"gelten unsere Bedingungen als angenommen. Sie gelten auch für künftige Geschäftsbeziehungen, auch " +
|
||||
"wenn sie nicht nochmals ausdrücklich vereinbart werden. Insbesondere auch was die Datenverarbeitung " +
|
||||
"nach Datenschutz-Grundverordnung (DSGVO) Artikel 5 anbelangt.");
|
||||
Note("Steuernummer: 106/5849/2962");
|
||||
|
||||
string paymentTermPhrase = (reg?.nz("PaymentTermPhrase") ?? "")
|
||||
.ne($"Zahlbar innerhalb von {TranslatePaymentTerm(reg?.nz("PaymentTerm") ?? "")}.");
|
||||
Note("Freistellungsbescheinigung zum Steuerabzug bei Bauleistungen gemäß § 48 Abs. 1 Satz1 des EStG " +
|
||||
"liegt vor. Es gelten unsere derzeit gültigen allgemeinen Liefer- und Zahlungsbedingungen. " +
|
||||
$"{paymentTermPhrase} Danach erfolgt Verzugseintritt ohne Mahnung (§ 286 Absatz II BGB).");
|
||||
Note("Hinweis zu unseren Verrechnungssätzen: In den ausgewiesenen Arbeitswerten sind die Dienstleistungen " +
|
||||
"als Arbeitslohn auf Basis der benötigten Zeit enthalten, inklusive der Fahrtzeit, Rüstzeit, " +
|
||||
"Auftragsvorbereitung und Werkzeugen (ausgenommen Spezialwerkzeuge wie Pressen Stemmhammer, etc.) " +
|
||||
"und die Verfügbarkeit von gängigen Ersatzteilen im Kundendienstfahrzeug. Alle Reparatureinsätze " +
|
||||
"(ggf. nur oder einschließlich Störungsdiagnoseeinsätze) des Kundendienst werden grundsätzlich mit " +
|
||||
"einem Verrechnungssatz nach Aufwand (1 Arbeitswert/Stück Zeiteinheit = 10 Minuten) abgerechnet. " +
|
||||
"Der Verrechnungssatz „Servicepauschale/ Notdienst” ausschließlich ausserhalb unserer Öffnungszeiten " +
|
||||
"am Samstag und Sonntag sowie feiertags.");
|
||||
Note("Weitere Informationen erhalten Sie unter www.sanitaerfuchs.de");
|
||||
Note("\"Ach übrigens, wenn Sie mit uns zufrieden waren, dann sagen Sie es doch bitte den anderen. " +
|
||||
"Und falls Sie mal nicht so zufrieden sind, dann sagen Sie es bitte gleich uns.\" Denn schließlich " +
|
||||
"ist die Zufriedenheit unserer Kunden unser wichtigstes Ziel - und ihre Weiterempfehlung unsere " +
|
||||
"Beste Visitenkarte.");
|
||||
Note("PLANT-MY-TREE für jedes gebaute Badezimmer und für jede gebaute Heizung spenden wir einen Baum. " +
|
||||
"PLANT MY TREE führt als Unternehmen eigene Erstaufforstungsprojekte auf eigenen Flächen in " +
|
||||
"Deutschland durch, die zuvor anderweitig genutzt wurden. Im Vorfeld arbeiten wir dabei eng mit den " +
|
||||
"lokalen Forstbehörden zusammen. Unser Ziel ist die langfristige CO2-Kompensierung und damit der " +
|
||||
"nachhaltige Umwelt- und Klimaschutz. Nach der Vermeidung des CO2-Ausstoß bzw. der Reduzierung ist " +
|
||||
"die Aufforstung nicht nur unserer Meinung nach der beste und nachhaltigste Weg, das Klima und damit " +
|
||||
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
|
||||
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
|
||||
|
||||
// GiroCode payment QR (only on finalized invoices with a positive balance)
|
||||
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
|
||||
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
|
||||
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
|
||||
}
|
||||
|
||||
// ── ApplyReminder ─────────────────────────────────────────────────────────
|
||||
@@ -602,6 +682,10 @@ public static class FuchsPdf
|
||||
$"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
|
||||
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)");
|
||||
|
||||
// GiroCode payment QR (only on finalized reminders with a positive open amount)
|
||||
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
|
||||
AddGirocode(sec, openTotal, $"Rechnung {rem.InvoiceId}");
|
||||
|
||||
// Greeting
|
||||
var greet = sec.AddParagraph(); greet.Style = "BodyText";
|
||||
greet.Format.SpaceBefore = cm(0.8);
|
||||
@@ -711,6 +795,63 @@ public static class FuchsPdf
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
|
||||
/// matching the legacy fuchs_fds_pdf.vb invoice/reminder layout. No-op on failure.
|
||||
/// </summary>
|
||||
private static void AddGirocode(Section sec, decimal amount, string purpose)
|
||||
{
|
||||
byte[]? girocodeImg = null;
|
||||
try
|
||||
{
|
||||
using var girocode = GetPaycode(
|
||||
iban: "DE52301502000002091478", bic: "WELADED1KSD",
|
||||
name: "Sebastian Fuchs Bad und Heizung", amount: amount, purpose: purpose);
|
||||
girocodeImg = ImageToByteArray(girocode);
|
||||
}
|
||||
catch { girocodeImg = null; }
|
||||
if (girocodeImg == null) return;
|
||||
|
||||
var spacer = sec.AddParagraph();
|
||||
spacer.Format.SpaceBefore = cm(2);
|
||||
spacer.AddText("");
|
||||
|
||||
var girotbl = sec.AddTable();
|
||||
girotbl.AddColumn(cm(17 - 3 - 0.6));
|
||||
girotbl.AddColumn(cm(3 + 0.6));
|
||||
girotbl.Borders.Color = Colors.Black;
|
||||
girotbl.Borders.Width = 0.5;
|
||||
girotbl.Borders.Style = BorderStyle.Single;
|
||||
girotbl.Borders.Distance = cm(1);
|
||||
|
||||
var rw = girotbl.AddRow();
|
||||
|
||||
rw.Cells[0].Borders.Right.Visible = false;
|
||||
rw.Cells[0].Format.LeftIndent = cm(0.6);
|
||||
var p = rw.Cells[0].AddParagraph();
|
||||
p.Format.SpaceBefore = cm(0.3);
|
||||
p.Format.SpaceAfter = cm(0.2);
|
||||
p.AddText("Zahlen mit Girocode. Mit dem GiroCode bezahlen Sie Ihre Rechnungen schnell, " +
|
||||
"sicher und vor allem fehlerfrei. Ihre Banking App liest aus dem Code alle " +
|
||||
"relevanten Daten für Ihre Überweisung.");
|
||||
p = rw.Cells[0].AddParagraph();
|
||||
p.AddText("Weitere Infos finden Sie unter ");
|
||||
p.AddHyperlink("http://www.girocode.de", HyperlinkType.Web).AddText("http://www.girocode.de");
|
||||
p.AddText(".");
|
||||
|
||||
rw.Cells[1].Borders.Left.Visible = false;
|
||||
var imgPara = rw.Cells[1].AddParagraph();
|
||||
var img = imgPara.AddImage(MigraDocFilenameFromByteArray(girocodeImg));
|
||||
img.Resolution = 300;
|
||||
img.WrapFormat.Style = WrapStyle.TopBottom;
|
||||
img.RelativeHorizontal = RelativeHorizontal.Column;
|
||||
img.RelativeVertical = RelativeVertical.Line;
|
||||
img.Width = cm(3);
|
||||
img.LockAspectRatio = true;
|
||||
img.Left = ShapePosition.Right;
|
||||
img.Top = ShapePosition.Top;
|
||||
}
|
||||
|
||||
private static string MigraDocFilenameFromByteArray(byte[] image) =>
|
||||
"base64:" + Convert.ToBase64String(image);
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Report processing helpers for the Fuchs intranet.
|
||||
/// Delegates to the SQL-driven report catalog.
|
||||
/// </summary>
|
||||
public static class FuchsReports
|
||||
{
|
||||
public static async Task<IActionResult> ProcessFdsRequest(
|
||||
IntranetController ctrl, string action, string id)
|
||||
{
|
||||
// Specific report actions are dispatched here.
|
||||
// Extend with additional cases from the VB fuchs_reports.vb as needed.
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using System.Data;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.GenericCharts;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
// Ported from OCORE_web/imported/commons/ocms_visualization.vb.
|
||||
// Renders SQL-driven reports (from the fds__ report catalog) as HTML pages,
|
||||
// HTML fragments, or PNG charts. Reuses the ported OCORE chart engine
|
||||
// (OCORE.GenericCharts.Chart) for chart visualizations.
|
||||
|
||||
public enum FdsQueryType { udf = 1, udp = 2, generic = 3, dashboard = 4 }
|
||||
public enum FdsDestination { web, email, content }
|
||||
|
||||
/// <summary>
|
||||
/// HTML page builder for reports. Optionally injects content into an HTML
|
||||
/// template (FDS_Template.html); falls back to a minimal document otherwise.
|
||||
/// </summary>
|
||||
public class FuchsHtmlPage
|
||||
{
|
||||
public string Title { get; set; } = "Reporting";
|
||||
public string Style { get; set; } = "body { font-family: Arial, sans-serif; padding: 3rem; margin: 0; } ";
|
||||
public string Script { get; set; } = "";
|
||||
public int ReloadSeconds { get; set; } = -1;
|
||||
public int QueryDuration { get; set; }
|
||||
public readonly List<string> Links = new();
|
||||
|
||||
private readonly StringBuilder _content = new();
|
||||
private readonly string _templatePath;
|
||||
|
||||
public FuchsHtmlPage(string title, string templatePath, int queryDuration = 0)
|
||||
{
|
||||
Title = string.IsNullOrEmpty(title) ? "Report" : title;
|
||||
_templatePath = templatePath;
|
||||
QueryDuration = queryDuration;
|
||||
}
|
||||
|
||||
public void Add(string html) => _content.Append(html);
|
||||
|
||||
private bool TemplateExists()
|
||||
{
|
||||
try { return !string.IsNullOrEmpty(_templatePath) && File.Exists(_templatePath); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public string ToHtml(FdsDestination destination)
|
||||
{
|
||||
string htmlcode;
|
||||
if (destination == FdsDestination.web && TemplateExists())
|
||||
{
|
||||
try { htmlcode = File.ReadAllText(_templatePath); }
|
||||
catch { htmlcode = BasicShell(); }
|
||||
}
|
||||
else htmlcode = BasicShell();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(htmlcode);
|
||||
var headNode = doc.DocumentNode.SelectSingleNode("//head");
|
||||
var bodyNode = doc.DocumentNode.SelectSingleNode("//body");
|
||||
var mainNode = doc.DocumentNode.SelectSingleNode("//body/main");
|
||||
|
||||
if (headNode != null)
|
||||
{
|
||||
if (destination == FdsDestination.web && ReloadSeconds > 9)
|
||||
{
|
||||
var meta = HtmlNode.CreateNode($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
|
||||
var firstMeta = headNode.SelectSingleNode("//meta");
|
||||
if (firstMeta != null) headNode.InsertAfter(meta, firstMeta);
|
||||
else headNode.ChildNodes.Add(meta);
|
||||
}
|
||||
if (destination == FdsDestination.web)
|
||||
{
|
||||
var titleNode = headNode.SelectSingleNode("//title");
|
||||
if (titleNode != null) titleNode.InnerHtml = WebUtility.HtmlEncode(Title);
|
||||
else headNode.AppendChild(HtmlNode.CreateNode($"<title>{WebUtility.HtmlEncode(Title)}</title>"));
|
||||
foreach (var lnk in Links) headNode.AppendChild(HtmlNode.CreateNode(lnk));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Style))
|
||||
headNode.AppendChild(HtmlNode.CreateNode($"<style type=\"text/css\">{Style}</style>"));
|
||||
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
|
||||
headNode.AppendChild(HtmlNode.CreateNode($"<script type=\"text/javascript\">{Script}</script>"));
|
||||
}
|
||||
|
||||
if (mainNode != null) mainNode.InnerHtml = _content.ToString();
|
||||
else if (bodyNode != null) bodyNode.InnerHtml = _content.ToString();
|
||||
|
||||
return doc.DocumentNode.OuterHtml;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (destination == FdsDestination.web) sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\"><head><meta charset=\"utf-8\" />");
|
||||
if (ReloadSeconds > 9) sb.AppendLine($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
|
||||
if (destination == FdsDestination.web)
|
||||
{
|
||||
sb.AppendLine($"<title>{WebUtility.HtmlEncode(Title)}</title>");
|
||||
foreach (var lnk in Links) sb.AppendLine(lnk);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Style)) sb.AppendLine($"<style type=\"text/css\">{Style}</style>");
|
||||
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
|
||||
sb.AppendLine($"<script type=\"text/javascript\">{Script}</script>");
|
||||
sb.AppendLine("</head><body>");
|
||||
sb.Append(_content);
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BasicShell() =>
|
||||
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\" /><title></title></head><body></body></html>";
|
||||
}
|
||||
|
||||
/// <summary>Simple file-based report cache (tmp/*.cache), TTL 72h.</summary>
|
||||
internal sealed class ManagedCache
|
||||
{
|
||||
private readonly DirectoryInfo? _tmp;
|
||||
private readonly string _file;
|
||||
private const string Ext = "cache";
|
||||
|
||||
public bool IsValid { get; }
|
||||
|
||||
public ManagedCache(string filename)
|
||||
{
|
||||
_file = string.IsNullOrEmpty(filename) ? "" : (filename.EndsWith(Ext) ? filename : filename + "." + Ext);
|
||||
try
|
||||
{
|
||||
var baseDir = ApplicationBase();
|
||||
if (baseDir != null)
|
||||
{
|
||||
_tmp = baseDir.GetDirectories("tmp").FirstOrDefault()
|
||||
?? baseDir.CreateSubdirectory("tmp");
|
||||
IsValid = _tmp.Exists;
|
||||
if (IsValid)
|
||||
foreach (var f in _tmp.GetFiles("*." + Ext))
|
||||
try { if (DateTime.UtcNow.Subtract(f.CreationTimeUtc.Date).TotalHours > 72) f.Delete(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
catch { IsValid = false; }
|
||||
}
|
||||
|
||||
private string FullPath => Path.Combine(_tmp!.FullName, _file);
|
||||
|
||||
public bool Exists => IsValid && (File.Exists(FullPath) || File.Exists(FullPath.Replace(" ", "+")));
|
||||
|
||||
public string Read()
|
||||
{
|
||||
try { return Exists ? File.ReadAllText(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")) : ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public void Write(string content)
|
||||
{
|
||||
try { if (IsValid && !string.IsNullOrEmpty(content)) File.WriteAllText(FullPath, content, Encoding.Unicode); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
try { if (Exists) File.Delete(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static class FuchsVisualization
|
||||
{
|
||||
// ── Query execution ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Runs a report (fds__r_ / fds__xls_) and returns its admin table (ADT) + data tables.</summary>
|
||||
public static async Task<(DataTable? adt, List<DataTable> dt)> GetQuery(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, IDictionary<string, string> prms)
|
||||
{
|
||||
var dtList = new List<DataTable>();
|
||||
DataTable? adt = null;
|
||||
|
||||
bool isReport = query.StartsWith("fds__r_");
|
||||
bool isXls = query.StartsWith("fds__xls_");
|
||||
if (!isReport && !isXls) return (adt, dtList);
|
||||
|
||||
var reportinfo = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
|
||||
connStr,
|
||||
new List<SqlParameter> { SQL_VarChar("@report_name", query), SQL_VarChar("@authuser", userAccountId) },
|
||||
tablenames: new[] { "procedures", "parameter", "categories", "tags" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (!reportinfo.Contains("procedures") || reportinfo.Tables("procedures").Rows.Count == 0)
|
||||
return (adt, dtList);
|
||||
|
||||
var qparams = GetQParams(reportinfo.Tables("parameter"), prms);
|
||||
var procRow = reportinfo.Tables("procedures").Rows[0];
|
||||
string sql = $"EXECUTE [dbo].[{procRow["name"]}] {procRow["parameter"]};";
|
||||
|
||||
var dset = await getSQLDataSet_async(sql, connStr, qparams,
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (isXls)
|
||||
{
|
||||
for (int i = 0; i < dset.Count; i++) dtList.Add(dset.Tables(i));
|
||||
}
|
||||
else if (dset.Count >= 1)
|
||||
{
|
||||
if (dset.Count > 1)
|
||||
{
|
||||
for (int i = 1; i < dset.Count; i++) dtList.Add(dset.Tables(i));
|
||||
adt = dset.Tables(0);
|
||||
}
|
||||
else dtList.Add(dset.Tables(0));
|
||||
}
|
||||
return (adt, dtList);
|
||||
}
|
||||
|
||||
private static List<SqlParameter> GetQParams(DataTable paramTbl, IDictionary<string, string> prms)
|
||||
{
|
||||
var list = new List<SqlParameter>();
|
||||
foreach (DataRow prw in paramTbl.Rows)
|
||||
{
|
||||
string name = prw["name"]?.ToString() ?? "";
|
||||
string type = (prw.Table.Columns.Contains("Type") ? prw["Type"]?.ToString() : "")?.ToLower() ?? "";
|
||||
string val = ParamValue(prms, name);
|
||||
switch (type)
|
||||
{
|
||||
case "int": list.Add(SQL_Int(name, stringvalue: val)); break;
|
||||
case "bigint": list.Add(SQL_BigInt(name, stringvalue: val)); break;
|
||||
default: list.Add(new SqlParameter(name, (object?)(string.IsNullOrEmpty(val) ? null : val) ?? DBNull.Value)); break;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string ParamValue(IDictionary<string, string> prms, string name)
|
||||
{
|
||||
if (prms.TryGetValue(name, out var v) && v != null) return v;
|
||||
string trimmed = name.TrimStart('@');
|
||||
if (prms.TryGetValue(trimmed, out var v2) && v2 != null) return v2;
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Chart settings ──────────────────────────────────────────────────────
|
||||
private static ChartSettingsDic GetChartSettings(DataRow adtRow, DataTable dataTbl)
|
||||
{
|
||||
var cs = new ChartSettingsDic();
|
||||
if (!adtRow.Table.Columns.Contains("settings") ||
|
||||
adtRow["settings"] is DBNull ||
|
||||
string.IsNullOrEmpty(adtRow["settings"]?.ToString()))
|
||||
return cs;
|
||||
|
||||
var imported = JsonConvert.DeserializeObject<Dictionary<string, object?>>(adtRow["settings"]!.ToString()!);
|
||||
if (imported != null) cs.Import(imported, true, true);
|
||||
|
||||
cs["title"] = ColValue(adtRow, "title");
|
||||
cs["label"] = ColValue(adtRow, "label");
|
||||
if (!cs.ContainsKey("x1_column") && dataTbl.Columns.Count == 2) cs["x1_column"] = dataTbl.Columns[0].ColumnName;
|
||||
if (!cs.ContainsKey("y1_column") && dataTbl.Columns.Count == 2) cs["y1_column"] = dataTbl.Columns[1].ColumnName;
|
||||
return cs;
|
||||
}
|
||||
|
||||
private static async Task<string?> ChartDataUriAsync(DataTable data, ChartSettingsDic cs)
|
||||
{
|
||||
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
|
||||
try
|
||||
{
|
||||
var chart = new Chart(data, DateTime.Now);
|
||||
chart.Init(cs, null);
|
||||
string b64 = await chart.ToBase64string(System.Drawing.Imaging.ImageFormat.Png);
|
||||
return "data:image/png;base64," + b64;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── HTML form builder (tabpages with table / chart / html visualizations) ─
|
||||
private static async Task<string> BuildFormHtmlAsync(
|
||||
DataTable? adt, List<DataTable> dt, FdsQueryType qtype, FdsDestination dest,
|
||||
IDictionary<string, string> prms, FuchsHtmlPage page, string query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<div class=\"frm\" id=\"frm\">");
|
||||
|
||||
if (qtype != FdsQueryType.dashboard)
|
||||
{
|
||||
int selectedtab = (prms.TryGetValue("tab", out var tv) && int.TryParse(tv, out var ti)) ? ti : -1;
|
||||
int from = selectedtab > 0 ? selectedtab - 1 : 0;
|
||||
int to = selectedtab > 0 ? selectedtab - 1 : dt.Count - 1;
|
||||
|
||||
for (int dti = from; dti <= to && dti < dt.Count; dti++)
|
||||
{
|
||||
bool hasAdmin = adt != null && adt.Rows.Count > dti;
|
||||
DataRow? aRow = hasAdmin ? adt!.Rows[dti] : null;
|
||||
|
||||
string label = AdtStr(aRow, "Label", query);
|
||||
string css = AdtStr(aRow, "class", "");
|
||||
string style = AdtStr(aRow, "style", "");
|
||||
string vtype = AdtStr(aRow, "typ", "table");
|
||||
|
||||
sb.Append($"<div class=\"tabpage {WebUtility.HtmlEncode(css)}\" id=\"tab_{dti}\" data-title=\"{WebUtility.HtmlEncode(label)}\">");
|
||||
|
||||
if (dt[dti].Rows.Count > 0)
|
||||
{
|
||||
sb.Append("<div class=\"headline\" style=\"margin-bottom: 2rem; line-height: 1.3;\">");
|
||||
sb.Append($"<h1>{WebUtility.HtmlEncode(label)}</h1>");
|
||||
sb.Append($"<h3>{WebUtility.HtmlEncode(AdtStr(aRow, "SubLabel", ""))}</h3>");
|
||||
sb.Append("</div>");
|
||||
|
||||
switch (vtype)
|
||||
{
|
||||
case "html":
|
||||
sb.Append("<div>");
|
||||
if (dt[dti].Rows.Count == 1 && dt[dti].Columns.Count == 1 &&
|
||||
(dt[dti].Rows[0][0]?.ToString() ?? "").StartsWith("<"))
|
||||
sb.Append(dt[dti].Rows[0][0]);
|
||||
sb.Append("</div>");
|
||||
break;
|
||||
|
||||
case "chart":
|
||||
sb.Append("<div class=\"chartframe\">");
|
||||
if (aRow != null)
|
||||
{
|
||||
var cs = GetChartSettings(aRow, dt[dti]);
|
||||
string? src = await ChartDataUriAsync(dt[dti], cs);
|
||||
if (src != null) sb.Append($"<img src=\"{src}\" />");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
break;
|
||||
|
||||
default: // "table"
|
||||
sb.Append(BuildTableHtml(dt[dti]));
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(style)) page.Style += " " + style;
|
||||
string legend = AdtStr(aRow, "legend", "");
|
||||
if (!string.IsNullOrEmpty(legend))
|
||||
sb.Append($"<div class=\"legend\">{legend}</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div style=\"font-size:14px; color: red;\">No Data Found</div>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
}
|
||||
}
|
||||
else if (adt != null && adt.Rows.Count == 1)
|
||||
{
|
||||
// Dashboards: data via JSON (script) + layout via id-based css/js
|
||||
page.Script = JsonConvert.SerializeObject(dt);
|
||||
string id = AdtStr(adt.Rows[0], "id", "");
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
page.Links.Add($"<link rel=\"stylesheet\" href=\"/Content/{id}.css\" type=\"text/css\" />");
|
||||
page.Links.Add($"<script src=\"/Scripts/{id}.js\" type=\"text/javascript\"></script>");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div style=\"font-size:14px; color: red;\">Nothing Found</div>");
|
||||
}
|
||||
|
||||
sb.Append("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildTableHtml(DataTable tbl)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("<table style=\"border-collapse: collapse;border: 1px solid #000;\"><thead><tr>");
|
||||
for (int ci = 0; ci < tbl.Columns.Count; ci++)
|
||||
{
|
||||
var c = tbl.Columns[ci];
|
||||
if (c.ColumnName == "order" || c.ColumnName.StartsWith("css") || c.ColumnName.StartsWith("style")) continue;
|
||||
sb.Append($"<th class=\"trh {ToCssClass(c.ColumnName, ci + 1, 0)}\" " +
|
||||
"style=\"white-space:nowrap; padding: 0.5rem 1rem; font-weight: 600;border:1px solid #727272;border-bottom: 3px double #727272;\">" +
|
||||
$"{WebUtility.HtmlEncode(c.ColumnName)}</th>");
|
||||
}
|
||||
sb.Append("</tr></thead><tbody>");
|
||||
|
||||
var sorted = tbl.Select("", tbl.Columns.Contains("order") ? "order" : "");
|
||||
for (int rwi = 0; rwi < sorted.Length; rwi++)
|
||||
{
|
||||
var rw = sorted[rwi];
|
||||
string trStyle = "font-weight: 400";
|
||||
if (tbl.Columns.Contains("css") && rw["css"] is not DBNull && !string.IsNullOrEmpty(rw["css"]?.ToString()))
|
||||
trStyle = rw["css"]!.ToString()!;
|
||||
sb.Append($"<tr class=\"{(rwi == sorted.Length - 1 ? "last" : "")}\" style=\"{WebUtility.HtmlEncode(trStyle)}\">");
|
||||
|
||||
for (int ci = 0; ci < tbl.Columns.Count; ci++)
|
||||
{
|
||||
var c = tbl.Columns[ci];
|
||||
if (c.ColumnName == "order") continue;
|
||||
if (c.ColumnName.StartsWith("css:") || c.ColumnName.StartsWith("style:")) continue;
|
||||
|
||||
string tdStyle = "padding: 0.5rem 1rem; border:1px solid #727272;";
|
||||
string extraCls = "";
|
||||
if (tbl.Columns.Contains("css:" + c.ColumnName) && rw["css:" + c.ColumnName] is not DBNull &&
|
||||
!string.IsNullOrEmpty(rw["css:" + c.ColumnName]?.ToString()))
|
||||
extraCls = " " + rw["css:" + c.ColumnName];
|
||||
if (tbl.Columns.Contains("style:" + c.ColumnName) && rw["style:" + c.ColumnName] is not DBNull &&
|
||||
!string.IsNullOrEmpty(rw["style:" + c.ColumnName]?.ToString()))
|
||||
tdStyle = (tdStyle + ";" + rw["style:" + c.ColumnName]).Replace(";;", ";");
|
||||
|
||||
sb.Append($"<td class=\"{ToCssClass(c.ColumnName, ci + 1, rwi + 1)}{extraCls}\" style=\"{tdStyle}\">");
|
||||
|
||||
if (rw[c] is not DBNull)
|
||||
{
|
||||
string val = rw[c]?.ToString() ?? "";
|
||||
bool largeText = c.DataType == typeof(string) && (c.MaxLength == -1 || c.MaxLength > 100);
|
||||
if (largeText)
|
||||
{
|
||||
sb.Append("<div>");
|
||||
if (val.StartsWith("<") && val.EndsWith(">"))
|
||||
sb.Append(WebUtility.HtmlEncode(val)); // matches legacy SOC .text() (InnerText) behavior
|
||||
else
|
||||
sb.Append(string.Join("<br />",
|
||||
val.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n').Select(WebUtility.HtmlEncode)));
|
||||
sb.Append("</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(WebUtility.HtmlEncode(val));
|
||||
}
|
||||
}
|
||||
sb.Append("</td>");
|
||||
}
|
||||
sb.Append("</tr>");
|
||||
}
|
||||
sb.Append("</tbody></table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToCssClass(string columnName, int c, int r) =>
|
||||
$"c{c} r{r} _{columnName.Replace(" ", "_").ToLower()}";
|
||||
|
||||
private static string AdtStr(DataRow? row, string col, string fallback)
|
||||
{
|
||||
if (row == null || !row.Table.Columns.Contains(col) || row[col] is DBNull) return fallback;
|
||||
string v = row[col]?.ToString() ?? "";
|
||||
return string.IsNullOrEmpty(v) ? fallback : v;
|
||||
}
|
||||
|
||||
private static string ColValue(DataRow row, string col) =>
|
||||
row.Table.Columns.Contains(col) && row[col] is not DBNull ? row[col]?.ToString() ?? "" : "";
|
||||
|
||||
// ── Public render entry points ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a report as a bare HTML fragment (destination = content).</summary>
|
||||
public static async Task<string> RenderContentAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, FdsQueryType qtype, IDictionary<string, string> prms)
|
||||
{
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
var page = new FuchsHtmlPage("", "");
|
||||
return await BuildFormHtmlAsync(adt, dt, qtype, FdsDestination.content, prms, page, query);
|
||||
}
|
||||
|
||||
/// <summary>Renders a report as a full HTML page (destination = web / email), with optional caching.</summary>
|
||||
public static async Task<FuchsHtmlPage> RenderPageAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string uniquename, string query, FdsQueryType qtype,
|
||||
IDictionary<string, string> prms, FdsDestination dest, string templatePath,
|
||||
bool allowcache = false, bool forceReload = false, string title = "")
|
||||
{
|
||||
ManagedCache? cache = null;
|
||||
string cached = "", cachedTitle = "";
|
||||
if (allowcache)
|
||||
{
|
||||
try
|
||||
{
|
||||
var illegal = new Regex($"[{Regex.Escape(new string(Path.GetInvalidFileNameChars()))}]");
|
||||
var keys = prms.Keys.OrderBy(k => k, StringComparer.Ordinal);
|
||||
string paramStr = string.Join("&", keys.Select(k => $"{WebUtility.UrlEncode(k)}={WebUtility.UrlEncode(prms[k])}"));
|
||||
string cacheName = illegal.Replace($"{DateTime.Now:yyyyMMdd}_{uniquename}$${paramStr}.cache", "_");
|
||||
if (cacheName.Length <= 255)
|
||||
{
|
||||
cache = new ManagedCache(cacheName);
|
||||
if (forceReload) cache.Delete();
|
||||
else cached = cache.Read();
|
||||
}
|
||||
}
|
||||
catch { /* caching is best-effort */ }
|
||||
}
|
||||
|
||||
var page = new FuchsHtmlPage(string.IsNullOrEmpty(title) ? "Report" : title, templatePath);
|
||||
string formHtml;
|
||||
|
||||
if (string.IsNullOrEmpty(cached))
|
||||
{
|
||||
var start = DateTime.Now;
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
page.QueryDuration = (int)DateTime.Now.Subtract(start).TotalSeconds;
|
||||
|
||||
// Title from the admin table when available
|
||||
if (adt != null && adt.Columns.Contains("title") && adt.Rows.Count > 0 && adt.Rows[0]["title"] is not DBNull)
|
||||
page.Title = adt.Rows[0]["title"]?.ToString() ?? "FDS Berichte";
|
||||
else
|
||||
page.Title = "FDS Berichte";
|
||||
|
||||
formHtml = await BuildFormHtmlAsync(adt, dt, qtype, dest, prms, page, query);
|
||||
|
||||
if (allowcache && cache is { IsValid: true })
|
||||
try
|
||||
{
|
||||
cache.Write(JsonConvert.SerializeObject(new Dictionary<string, string>
|
||||
{ ["title"] = page.Title, ["frm"] = formHtml }));
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
var dic = JsonConvert.DeserializeObject<Dictionary<string, string>>(cached);
|
||||
cachedTitle = dic != null && dic.TryGetValue("title", out var t) ? t : "";
|
||||
formHtml = dic != null && dic.TryGetValue("frm", out var f) ? f : "";
|
||||
if (!string.IsNullOrEmpty(cachedTitle)) page.Title = cachedTitle;
|
||||
}
|
||||
|
||||
if (dest == FdsDestination.web)
|
||||
{
|
||||
page.Style = ".tabpage.inactive { display: none; } " + page.Style;
|
||||
page.Links.Insert(0, "<script src=\"/Scripts/jquery.min.js\" type=\"text/javascript\"></script>");
|
||||
if (qtype != FdsQueryType.dashboard)
|
||||
{
|
||||
page.Links.Add("<link rel=\"stylesheet\" href=\"/Web/ctm.css\" type=\"text/css\" />");
|
||||
page.Links.Add("<script src=\"/Web/ctm.js\" type=\"text/javascript\"></script>");
|
||||
}
|
||||
}
|
||||
page.Add(formHtml);
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Renders a report query directly as a PNG chart.</summary>
|
||||
public static async Task<byte[]?> RenderQueryAsChartAsync(
|
||||
string connStr, DatabaseSecurity dbSec, string userAccountId,
|
||||
string query, FdsQueryType qtype, IDictionary<string, string> prms)
|
||||
{
|
||||
var (adt, dt) = await GetQuery(connStr, dbSec, userAccountId, query, prms);
|
||||
if (adt == null || dt.Count == 0) return null;
|
||||
|
||||
var cs = GetChartSettings(adt.Rows[0], dt[0]);
|
||||
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
|
||||
try
|
||||
{
|
||||
var chart = new Chart(dt[0], DateTime.Now);
|
||||
chart.Init(cs, null);
|
||||
return await chart.ToByteArray(System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Widget helpers for the Fuchs intranet dashboard.
|
||||
/// Port of fuchs_fds_widgets.vb — SQL-driven widget cases.
|
||||
/// Weather widget (wetter.com API) removed: API deprecated.
|
||||
/// </summary>
|
||||
public static class FuchsWidgets
|
||||
{
|
||||
public static async Task<IActionResult> IntranetWdg(IntranetController ctrl, string widgetId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return widgetId.ToLower() switch
|
||||
{
|
||||
"my" => await HandleWidgetMy(ctrl),
|
||||
"one" => await HandleWidgetOne(ctrl),
|
||||
_ => await HandleWidgetGeneric(ctrl, widgetId)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ctrl._intranet.debug_log("FuchsWidgets.IntranetWdg", ex, ctrl.UserAccountID,
|
||||
new { widgetId });
|
||||
return new StatusCodeResult(500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── "my" — list of widget short-names for the current user ───────────────
|
||||
private static async Task<IActionResult> HandleWidgetMy(IntranetController ctrl)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser);",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(SQL_VarChar("@account", "fis")),
|
||||
Security: ctrl.DbSec);
|
||||
var names = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "")
|
||||
.Select(r => r.nz("short_name"))
|
||||
.ToArray();
|
||||
return await JSONAsync(names);
|
||||
}
|
||||
|
||||
// ── "one" — full widget data for a single widget ──────────────────────────
|
||||
private static async Task<IActionResult> HandleWidgetOne(IntranetController ctrl)
|
||||
{
|
||||
string shortName = ctrl.Request.Form["short_name"].ToString() ?? "";
|
||||
if (string.IsNullOrEmpty(shortName)) return new BadRequestResult();
|
||||
|
||||
var dt = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_getONEPersonWidgets] (@authuser) WHERE [short_name] = @shortname;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(
|
||||
SQL_VarChar("@shortname", shortName),
|
||||
SQL_VarChar("@account", "fis")),
|
||||
Security: ctrl.DbSec);
|
||||
|
||||
if (dt.Count != 1) return new StatusCodeResult(404);
|
||||
var wdg = dt.FirstRow.toObjectDictionary();
|
||||
return await BuildWidgetResponse(ctrl, shortName, wdg);
|
||||
}
|
||||
|
||||
// ── Generic widget by id ──────────────────────────────────────────────────
|
||||
private static async Task<IActionResult> HandleWidgetGeneric(IntranetController ctrl, string widgetId)
|
||||
{
|
||||
var pl = ctrl.StdParamlist(SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getWidget] @widget, @authuser;",
|
||||
ctrl._intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "admin", "data" },
|
||||
Security: ctrl.DbSec);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
admin = dset.Table("admin").FirstRow.toObjectDictionary(),
|
||||
data = dset.Tables("data").toArrayofObjectDictionaries()
|
||||
});
|
||||
}
|
||||
|
||||
// ── Widget renderer dispatcher ────────────────────────────────────────────
|
||||
private static async Task<IActionResult> BuildWidgetResponse(
|
||||
IntranetController ctrl, string shortName, Dictionary<string, object?> wdg)
|
||||
{
|
||||
string dbType = (wdg.nz("type", "") ?? "").ToLower();
|
||||
string sql = wdg.nz("sql", "") ?? "";
|
||||
var ropts = ParseRenderingOptions(wdg.nz("rendering_options", "") ?? "");
|
||||
string name = wdg.nz("name", "") ?? "";
|
||||
string descr = wdg.nz("description", "") ?? "";
|
||||
|
||||
object widgetData;
|
||||
|
||||
switch (dbType)
|
||||
{
|
||||
case "sql_table":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(), Security: ctrl.DbSec);
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "table",
|
||||
rendering_options = ropts,
|
||||
columns = dt.DataTable.Columns
|
||||
.Cast<System.Data.DataColumn>()
|
||||
.Select(c => c.ColumnName)
|
||||
.ToArray(),
|
||||
data = dt.DataTable.Rows
|
||||
.Cast<System.Data.DataRow>()
|
||||
.Select(r => r.toObjectDictionary())
|
||||
.ToArray()
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sql_indicator":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql,
|
||||
ctrl._intranet.Intranet__SQLConnectionString,
|
||||
ctrl.StdParamlist(), Security: ctrl.DbSec);
|
||||
var firstRow = dt.DataTable.Rows.Count > 0
|
||||
? dt.DataTable.Rows[0].toObjectDictionary()
|
||||
: new Dictionary<string, object?>();
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "ind",
|
||||
rendering_options = ropts,
|
||||
data = new
|
||||
{
|
||||
status = firstRow.nz("status", "") ?? "",
|
||||
value = firstRow.nz("value", "") ?? "",
|
||||
label = firstRow.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = "html",
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? ""
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pass through with normalised rendering_options
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
description = descr,
|
||||
type = dbType,
|
||||
rendering_options = ropts,
|
||||
html = wdg.nz("html", "") ?? "",
|
||||
url = wdg.nz("url", "") ?? "",
|
||||
image = wdg.nz("image", "") ?? "",
|
||||
data = (object)new
|
||||
{
|
||||
status = wdg.nz("status", "") ?? "",
|
||||
value = wdg.nz("value", "") ?? "",
|
||||
label = wdg.nz("label", "") ?? ""
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Wrap under short_name key so JS can do response[wi]
|
||||
return await JSONAsync(new Dictionary<string, object> { [shortName] = widgetData });
|
||||
}
|
||||
|
||||
private static string[] ParseRenderingOptions(string raw) =>
|
||||
string.IsNullOrWhiteSpace(raw)
|
||||
? Array.Empty<string>()
|
||||
: raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// How a "set" (mfr__items with <c>Type = "set"</c>) and its member items are
|
||||
/// priced/displayed on the invoice. This is the shared contract between the
|
||||
/// front-end (which lets the user pick the mode and groups the items) and the
|
||||
/// back-end PDF renderer. Unit-tested in <c>InvoiceSetPricingTests</c>.
|
||||
/// </summary>
|
||||
public enum SetDisplayMode
|
||||
{
|
||||
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
|
||||
SetPrice,
|
||||
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
|
||||
ItemPrices,
|
||||
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
|
||||
SetOnly
|
||||
}
|
||||
|
||||
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
|
||||
public sealed class InvoiceSetLine
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string Desc { get; init; } = "";
|
||||
public string Qty { get; init; } = "1";
|
||||
public decimal PriceNet { get; init; }
|
||||
public decimal TotalNet { get; init; }
|
||||
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
|
||||
public bool ShowPrice { get; init; } = true;
|
||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||
public bool IsSetHeader { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms raw invoice line items into display lines according to a
|
||||
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
||||
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
|
||||
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
||||
///
|
||||
/// The invoice total is taken from the registration balance, not from these
|
||||
/// lines, so switching modes is purely presentational and never changes the
|
||||
/// invoice sum — set price always equals the sum of its members.
|
||||
/// </summary>
|
||||
public static class InvoiceSetPricing
|
||||
{
|
||||
public static SetDisplayMode ParseMode(string? raw) =>
|
||||
(raw ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
|
||||
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
||||
_ => SetDisplayMode.SetPrice
|
||||
};
|
||||
|
||||
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
||||
public static SetDisplayMode ModeFromInvoiceOptions(string? invoiceOptions)
|
||||
{
|
||||
foreach (var token in (invoiceOptions ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseMode(token["setmode:".Length..]);
|
||||
}
|
||||
return SetDisplayMode.SetPrice;
|
||||
}
|
||||
|
||||
/// <summary>True if any item in the list is a set header or set member.</summary>
|
||||
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
||||
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||
|
||||
/// <summary>
|
||||
/// Produces the ordered display lines for the given items and mode.
|
||||
/// Standalone items are always shown with their price.
|
||||
/// </summary>
|
||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||
{
|
||||
var result = new List<InvoiceSetLine>();
|
||||
if (items.Count == 0) return result;
|
||||
|
||||
// Group members by their set id.
|
||||
var membersBySet = items
|
||||
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
|
||||
.GroupBy(SetIdOf)
|
||||
.ToDictionary(g => g.Key!, g => g.ToList());
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (IsSetHeader(item))
|
||||
{
|
||||
string setId = HeaderIdOf(item);
|
||||
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
||||
decimal setTot = HeaderTotal(item, members);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case SetDisplayMode.SetPrice:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
||||
break;
|
||||
case SetDisplayMode.ItemPrices:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count)
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
||||
break;
|
||||
case SetDisplayMode.SetOnly:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(SetIdOf(item)))
|
||||
{
|
||||
// Member — already emitted next to its header above; skip standalone emission.
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(MemberLine(item, showPrice: true)); // standalone item
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Text/Title lines are headings/free text with no price — their price/total
|
||||
/// cells render blank (matching the editor and the legacy invoice), regardless
|
||||
/// of the chosen mode.
|
||||
/// </summary>
|
||||
private static bool IsNoPriceLine(Dictionary<string, object?> i)
|
||||
{
|
||||
string t = i.nz("type", "").ToLowerInvariant();
|
||||
return t is "text" or "title";
|
||||
}
|
||||
|
||||
private static string? SetIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
|
||||
private static string HeaderIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
||||
}
|
||||
|
||||
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> members)
|
||||
{
|
||||
FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot);
|
||||
if (headerTot != 0) return headerTot;
|
||||
decimal sum = 0;
|
||||
foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; }
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = setTotal,
|
||||
TotalNet = setTotal,
|
||||
ShowPrice = showPrice,
|
||||
IsSetHeader = true
|
||||
};
|
||||
|
||||
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice)
|
||||
{
|
||||
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
|
||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||
return new InvoiceSetLine
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||
IsSetHeader = false
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -147,7 +147,7 @@ gulp.task("copy", async () => {
|
||||
const jobs = [];
|
||||
for (const cpy of copyconfig) {
|
||||
jobs.push(
|
||||
pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe())
|
||||
pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe(), logSink("Copied: "))
|
||||
);
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
|
||||
@@ -96,6 +96,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -258,7 +261,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -403,7 +406,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -646,12 +649,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -663,7 +673,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -838,6 +848,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -857,12 +914,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -884,7 +961,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -637,6 +643,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -799,7 +808,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -944,7 +953,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1187,12 +1196,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1204,7 +1220,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1379,6 +1395,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1398,12 +1461,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1425,7 +1508,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -618,6 +624,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -780,7 +789,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -925,7 +934,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -23,9 +23,9 @@ public class FdsService : ServiceControl
|
||||
new PeriodicJobDefinition("MfrSync", interval, async ct =>
|
||||
{
|
||||
bool debug = FdsConfig.DebugDetails;
|
||||
await mfr.UpdateIfNecessary_async(debug);
|
||||
await mfr.UpdateRequested_async(debug);
|
||||
await mfr.GetInvoiceFiles_async(debug);
|
||||
await mfr.UpdateIfNecessary_async(debug, ct);
|
||||
await mfr.UpdateRequested_async(debug, ct);
|
||||
await mfr.GetInvoiceFiles_async(debug, ct);
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
+35
-20
@@ -43,15 +43,19 @@ public class FdsMfr : IFdsMfr
|
||||
None = 0
|
||||
}
|
||||
|
||||
public async Task UpdateIfNecessary_async(bool debugDetails = false)
|
||||
/// <summary>Max parallel invoice-file downloads (independent per file).</summary>
|
||||
private const int InvoiceFileDownloadConcurrency = 4;
|
||||
|
||||
public async Task UpdateIfNecessary_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
{
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename: "DebugDetail.txt");
|
||||
await mfr.Update__Entitytables(debugDetails);
|
||||
await mfr.Update__Entitytables(debugDetails, cancellationToken: cancellationToken);
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateIfNecessary_async - unn - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("UpdateIfNecessary_async - main unn", exc: ex);
|
||||
@@ -59,15 +63,16 @@ public class FdsMfr : IFdsMfr
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateRequested_async(bool debugDetails = false)
|
||||
public async Task UpdateRequested_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
{
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - start awaited", filename: "DebugDetail.txt");
|
||||
await mfr.Update__EntityRequests(debugDetails);
|
||||
await mfr.Update__EntityRequests(debugDetails, cancellationToken);
|
||||
if (debugDetails) FdsDebug.DebugToFile("UpdateRequested_async - unn - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("UpdateRequested_async - main unn", exc: ex);
|
||||
@@ -75,7 +80,7 @@ public class FdsMfr : IFdsMfr
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GetInvoiceFiles_async(bool debugDetails = false)
|
||||
public async Task GetInvoiceFiles_async(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var mfr = new FdsMfrClient(_loggerFactory);
|
||||
try
|
||||
@@ -86,35 +91,45 @@ public class FdsMfr : IFdsMfr
|
||||
FdsShared.FDSConnectionString(), SqlParameterList: null, options: new FdsSqlOptions());
|
||||
if (dtbl.Count > 0)
|
||||
{
|
||||
foreach (DataRow ivrw in dtbl.DataTable.Rows)
|
||||
{
|
||||
string id = ivrw.nz("id"), docName = ivrw.nz("DocumentName"), fileurl = ivrw.nz("URI");
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(docName) && !string.IsNullOrEmpty(fileurl) && docName.EndsWith("pdf"))
|
||||
var rows = dtbl.DataTable.Rows.Cast<DataRow>()
|
||||
.Select(r => (id: r.nz("id"), docName: r.nz("DocumentName"), url: r.nz("URI")))
|
||||
.Where(r => !string.IsNullOrEmpty(r.id) && !string.IsNullOrEmpty(r.docName)
|
||||
&& !string.IsNullOrEmpty(r.url) && r.docName.EndsWith("pdf"))
|
||||
.ToList();
|
||||
|
||||
int downloaded = 0;
|
||||
// Files are independent → download (and store) in parallel with bounded concurrency.
|
||||
await Parallel.ForEachAsync(rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = InvoiceFileDownloadConcurrency, CancellationToken = cancellationToken },
|
||||
async (r, ct) =>
|
||||
{
|
||||
var fl = mfr.GetFile(fileurl);
|
||||
if (fl != null && fl.Length > 0)
|
||||
try
|
||||
{
|
||||
try
|
||||
var fl = await mfr.GetFileAsync(r.url, throwErrorIfNotOk: false, cancellationToken: ct);
|
||||
if (fl is { Length: > 0 })
|
||||
{
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;",
|
||||
FdsShared.FDSConnectionString(),
|
||||
SqlParameterList: new ParamList(
|
||||
SQL_VarChar("@Id", id),
|
||||
SQL_VarChar("@filename", docName),
|
||||
SQL_VarChar("@Id", r.id),
|
||||
SQL_VarChar("@filename", r.docName),
|
||||
new SqlParameter("@file", fl) { SqlDbType = SqlDbType.VarBinary }),
|
||||
options: new FdsSqlOptions());
|
||||
}
|
||||
catch (Exception fsex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
|
||||
Interlocked.Increment(ref downloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception fsex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - mfr storefile", exc: fsex);
|
||||
}
|
||||
});
|
||||
_logger.LogInformation("GetInvoiceFiles_async stored {Downloaded}/{Total} invoice files.", downloaded, rows.Count);
|
||||
}
|
||||
if (debugDetails) FdsDebug.DebugToFile("GetInvoiceFiles_async - completed", filename: "DebugDetail.txt");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FdsDebug.DebugLog("GetInvoiceFiles_async - main unn", exc: ex);
|
||||
|
||||
@@ -42,6 +42,9 @@ public class FdsMfrClient : IDisposable
|
||||
public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) =>
|
||||
_mfrClient.GetFile(address, throwErrorIfNotOk);
|
||||
|
||||
public Task<byte[]?> GetFileAsync(string address, bool throwErrorIfNotOk = true, CancellationToken cancellationToken = default) =>
|
||||
_mfrClient.GetFileAsync(address, throwErrorIfNotOk, cancellationToken);
|
||||
|
||||
public async Task<ODataEnvelope> ReadOData(string address, bool throwErrorIfNotOk = true) =>
|
||||
await _mfrClient.ReadOData(address, throwErrorIfNotOk);
|
||||
|
||||
@@ -374,7 +377,8 @@ public class FdsMfrClient : IDisposable
|
||||
private static string NewDatatableSql(string tablename) =>
|
||||
$"Select TOP(0) [setid] = CAST('' as varchar(50)), * FROM [dbo].[{tablename}];";
|
||||
|
||||
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null)
|
||||
public async Task Update__Entitytables(bool debugDetails = false, EntityTypes? tgtEntityType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
|
||||
{
|
||||
@@ -398,6 +402,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
foreach (DataRow rw in updateableTables.Select("updateneed > 0", "updateneed DESC"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string etname = rw.nz("entity_name", "");
|
||||
try
|
||||
{
|
||||
@@ -424,7 +429,7 @@ public class FdsMfrClient : IDisposable
|
||||
catch (Exception exa) { dlg("outer frame", "", "", exa); }
|
||||
}
|
||||
|
||||
public async Task Update__EntityRequests(bool debugDetails = false)
|
||||
public async Task Update__EntityRequests(bool debugDetails = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Action<string, string, string, Exception?> dtf = (note, info, data, ex) =>
|
||||
{
|
||||
@@ -448,6 +453,7 @@ public class FdsMfrClient : IDisposable
|
||||
{
|
||||
foreach (DataRow rw in updateableRequests.Select("", "order"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string etname = rw.nz("entity_name", "");
|
||||
long tgtid = rw.nint64("Id", -1);
|
||||
if (tgtid > -1 && !string.IsNullOrWhiteSpace(etname))
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class FdsConfig
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static void Initialize(IConfiguration configuration)
|
||||
{
|
||||
_config = configuration;
|
||||
}
|
||||
|
||||
// -- Connection strings ---------------------------------------------------
|
||||
internal static string SQLConnectionString() =>
|
||||
Current.GetConnectionString("fuchs_ConnectionString")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="FuchsDatabase.sqlproj" Id="c062672e-866d-4c74-b6de-8d660a42e885">
|
||||
<Build />
|
||||
<Deploy />
|
||||
</Project>
|
||||
</Solution>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE ROLE [fds_rwe]
|
||||
AUTHORIZATION [dbo];
|
||||
|
||||
|
||||
GO
|
||||
ALTER ROLE [fds_rwe] ADD MEMBER [fuchs_fds];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE USER [fuchs_fds] FOR LOGIN [fuchs_fds];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE ROLE [fuchs_rwe]
|
||||
AUTHORIZATION [dbo];
|
||||
|
||||
|
||||
GO
|
||||
ALTER ROLE [fuchs_rwe] ADD MEMBER [fuchs_web];
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user