From c81619fa53a734f7cf0b4a823a0473e199efe6ea Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 4 Jun 2026 16:41:46 +0200 Subject: [PATCH] =?UTF-8?q?Restore=20legacy=20parity=20gaps=20lost=20in=20?= =?UTF-8?q?the=20VB.NET=20=E2=86=92=20C#=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close functional regressions found by comparing the legacy applications (Intranet_Legacy/) against their C# counterparts: - ProcessWebComService: send attachments inline (base64) with the push_com POST so invoice/reminder PDFs are attached again. - FuchsPdf: wire GetPaycode into ApplyInvoice/ApplyReminder to restore the SEPA giro-code payment QR, and restore the full standard invoice text block (§35a labor-cost note, Akonto text, §14/§48 notes, AGB, Steuernummer, Verrechnungssätze, etc.). - IntranetController: restore changepassword validation (password strength, confirmation match, current-password verification) and the mfr empty-id OData $metadata response. - Reports: port the ocms_visualization engine to C# (FuchsVisualization) and wire FuchsReports.ProcessFdsRequest to render generic/ generic_content/chart reports instead of returning an empty OK stub. Adds smoke tests for the giro QR generator and report page builder. Co-Authored-By: Claude Opus 4.8 --- Fuchs.Tests/FuchsReportRenderTests.cs | 52 +++ Fuchs/Controllers/IntranetController.cs | 37 +- Fuchs/Services/ProcessWebComService.cs | 40 +- Fuchs/code/FuchsPdf.cs | 156 ++++++- Fuchs/code/FuchsReports.cs | 122 +++++- Fuchs/code/FuchsVisualization.cs | 556 ++++++++++++++++++++++++ 6 files changed, 942 insertions(+), 21 deletions(-) create mode 100644 Fuchs.Tests/FuchsReportRenderTests.cs create mode 100644 Fuchs/code/FuchsVisualization.cs diff --git a/Fuchs.Tests/FuchsReportRenderTests.cs b/Fuchs.Tests/FuchsReportRenderTests.cs new file mode 100644 index 0000000..7b05b6e --- /dev/null +++ b/Fuchs.Tests/FuchsReportRenderTests.cs @@ -0,0 +1,52 @@ +using Fuchs.intranet; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// 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. +/// +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("
hello
"); + + 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("fragment"); + + string html = page.ToHtml(FdsDestination.content); + + Assert.Contains("fragment", html); + Assert.DoesNotContain("http-equiv=\"refresh\"", html); + } +} diff --git a/Fuchs/Controllers/IntranetController.cs b/Fuchs/Controllers/IntranetController.cs index 5635ca0..81633ea 100644 --- a/Fuchs/Controllers/IntranetController.cs +++ b/Fuchs/Controllers/IntranetController.cs @@ -317,11 +317,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 + { + 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 +356,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller new List { 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,6 +371,14 @@ 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 = new fds.FdsMfrClient(); + 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 ?? "")); diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs index 811870f..a7e64ef 100644 --- a/Fuchs/Services/ProcessWebComService.cs +++ b/Fuchs/Services/ProcessWebComService.cs @@ -66,14 +66,30 @@ public class ProcessWebComService : IComService try { + // Attachments are transmitted inline as base64 in the same push_com POST. + // Each entry: { filename, mimeType, contentBase64 }. + var attachmentPayload = (attachments ?? new Dictionary()) + .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) { @@ -190,6 +206,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 diff --git a/Fuchs/code/FuchsPdf.cs b/Fuchs/code/FuchsPdf.cs index 7b30a5b..a02262c 100644 --- a/Fuchs/code/FuchsPdf.cs +++ b/Fuchs/code/FuchsPdf.cs @@ -523,16 +523,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 +681,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 +794,63 @@ public static class FuchsPdf } } + /// + /// 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. + /// + 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); diff --git a/Fuchs/code/FuchsReports.cs b/Fuchs/code/FuchsReports.cs index 5a22632..b5ecbe5 100644 --- a/Fuchs/code/FuchsReports.cs +++ b/Fuchs/code/FuchsReports.cs @@ -1,20 +1,124 @@ -using Fuchs.Controllers; +using Fuchs.Controllers; using Microsoft.AspNetCore.Mvc; -using static OCORE.web.mvc_helper_async; +using Microsoft.Data.SqlClient; +using OCORE.SQL; +using static OCORE.commons; +using static OCORE.OCORE_dictionaries; +using static OCORE.SQL.sql; namespace Fuchs.intranet; /// -/// Report processing helpers for the Fuchs intranet. -/// Delegates to the SQL-driven report catalog. +/// Report processing for the Fuchs intranet — SQL-driven reports from the +/// fds__ report catalog, rendered as HTML pages, HTML fragments, or PNG charts. +/// Ported from the legacy fuchs_reports.vb (process_fdsrequest) + ocms_visualization. /// public static class FuchsReports { - public static async Task ProcessFdsRequest( - IntranetController ctrl, string action, string id) + private const int DefaultReloadSeconds = 60 * 10; + + /// Current controller (DB, security, request, user). + /// Target function (e.g. "generic", "generic_content"/"gct", "chart"). + /// Report name/id (the URL code segment). + public static async Task ProcessFdsRequest(IntranetController ctrl, string fnc, string id) { - // Specific report actions are dispatched here. - // Extend with additional cases from the VB fuchs_reports.vb as needed. - return new OkResult(); + // Merge query string + form into a single parameter map (form wins); force @authuser. + var prms = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in ctrl.Request.Query) prms[kv.Key] = kv.Value.ToString(); + if (ctrl.Request.HasFormContentType) + foreach (var kv in ctrl.Request.Form) prms[kv.Key] = kv.Value.ToString(); + prms["@authuser"] = ctrl.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(id) ? id : ""); + + 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;", + ctrl._intranet.Intranet__SQLConnectionString, + new List { SQL_VarChar("@report_name", report), SQL_VarChar("@authuser", ctrl.UserAccountID) }, + Security: ctrl.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) + { + ctrl._intranet.debug_log("FuchsReports.ProcessFdsRequest - catalog", ex: cex); + } + bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0"; + + try + { + switch (tgt) + { + case "generic_content": + if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300); + string content = await FuchsVisualization.RenderContentAsync( + ctrl, report, FdsQueryType.generic, prms); + return new ContentResult { Content = content, ContentType = "text/html" }; + + case "generic": + { + if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300); + var page = await FuchsVisualization.RenderPageAsync( + ctrl, 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( + ctrl, 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(fnc, ignoreCase: true, out var qt)) + { + if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300); + var page = await FuchsVisualization.RenderPageAsync( + ctrl, 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) + { + ctrl._intranet.debug_log("FuchsReports.ProcessFdsRequest", + ex: ex, data: new { fnc, id, report, tgt }); + return new StatusCodeResult(500); + } + } + + private static void ApplyReload(FuchsHtmlPage page, IDictionary 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; } } diff --git a/Fuchs/code/FuchsVisualization.cs b/Fuchs/code/FuchsVisualization.cs new file mode 100644 index 0000000..1261d4f --- /dev/null +++ b/Fuchs/code/FuchsVisualization.cs @@ -0,0 +1,556 @@ +using System.Data; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Fuchs.Controllers; +using HtmlAgilityPack; +using Microsoft.Data.SqlClient; +using Newtonsoft.Json; +using OCORE.GenericCharts; +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 } + +/// +/// HTML page builder for reports. Optionally injects content into an HTML +/// template (FDS_Template.html); falls back to a minimal document otherwise. +/// +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 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($""); + 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($"{WebUtility.HtmlEncode(Title)}")); + foreach (var lnk in Links) headNode.AppendChild(HtmlNode.CreateNode(lnk)); + } + if (!string.IsNullOrEmpty(Style)) + headNode.AppendChild(HtmlNode.CreateNode($"")); + if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script)) + headNode.AppendChild(HtmlNode.CreateNode($"")); + } + + 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(""); + sb.AppendLine(""); + if (ReloadSeconds > 9) sb.AppendLine($""); + if (destination == FdsDestination.web) + { + sb.AppendLine($"{WebUtility.HtmlEncode(Title)}"); + foreach (var lnk in Links) sb.AppendLine(lnk); + } + if (!string.IsNullOrEmpty(Style)) sb.AppendLine($""); + if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script)) + sb.AppendLine($""); + sb.AppendLine(""); + sb.Append(_content); + sb.AppendLine(""); + return sb.ToString(); + } + } + + private static string BasicShell() => + ""; +} + +/// Simple file-based report cache (tmp/*.cache), TTL 72h. +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 ───────────────────────────────────────────────────── + private static string ConnStr(IntranetController ctrl) => ctrl._intranet.Intranet__SQLConnectionString; + + /// Runs a report (fds__r_ / fds__xls_) and returns its admin table (ADT) + data tables. + public static async Task<(DataTable? adt, List dt)> GetQuery( + IntranetController ctrl, string query, IDictionary prms) + { + var dtList = new List(); + 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(ctrl), + new List { SQL_VarChar("@report_name", query), SQL_VarChar("@authuser", ctrl.UserAccountID) }, + tablenames: new[] { "procedures", "parameter", "categories", "tags" }, + Security: ctrl.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(ctrl), qparams, + Security: ctrl.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 GetQParams(DataTable paramTbl, IDictionary prms) + { + var list = new List(); + 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 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>(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 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 BuildFormHtmlAsync( + DataTable? adt, List dt, FdsQueryType qtype, FdsDestination dest, + IDictionary prms, FuchsHtmlPage page, string query) + { + var sb = new StringBuilder(); + sb.Append("
"); + + 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($"
"); + + if (dt[dti].Rows.Count > 0) + { + sb.Append("
"); + sb.Append($"

{WebUtility.HtmlEncode(label)}

"); + sb.Append($"

{WebUtility.HtmlEncode(AdtStr(aRow, "SubLabel", ""))}

"); + sb.Append("
"); + + switch (vtype) + { + case "html": + sb.Append("
"); + 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("
"); + break; + + case "chart": + sb.Append("
"); + if (aRow != null) + { + var cs = GetChartSettings(aRow, dt[dti]); + string? src = await ChartDataUriAsync(dt[dti], cs); + if (src != null) sb.Append($""); + } + sb.Append("
"); + 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($"
{legend}
"); + } + else + { + sb.Append("
No Data Found
"); + } + sb.Append("
"); + } + } + 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($""); + page.Links.Add($""); + } + } + else + { + sb.Append("
Nothing Found
"); + } + + sb.Append("
"); + return sb.ToString(); + } + + private static string BuildTableHtml(DataTable tbl) + { + var sb = new StringBuilder(); + sb.Append(""); + 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($""); + } + sb.Append(""); + + 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($""); + + 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($""); + } + sb.Append(""); + } + sb.Append("
" + + $"{WebUtility.HtmlEncode(c.ColumnName)}
"); + + 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("
"); + if (val.StartsWith("<") && val.EndsWith(">")) + sb.Append(WebUtility.HtmlEncode(val)); // matches legacy SOC .text() (InnerText) behavior + else + sb.Append(string.Join("
", + val.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n').Select(WebUtility.HtmlEncode))); + sb.Append("
"); + } + else + { + sb.Append(WebUtility.HtmlEncode(val)); + } + } + sb.Append("
"); + 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 ────────────────────────────────────────── + + /// Renders a report as a bare HTML fragment (destination = content). + public static async Task RenderContentAsync( + IntranetController ctrl, string query, FdsQueryType qtype, IDictionary prms) + { + var (adt, dt) = await GetQuery(ctrl, query, prms); + var page = new FuchsHtmlPage("", ""); + return await BuildFormHtmlAsync(adt, dt, qtype, FdsDestination.content, prms, page, query); + } + + /// Renders a report as a full HTML page (destination = web / email), with optional caching. + public static async Task RenderPageAsync( + IntranetController ctrl, string uniquename, string query, FdsQueryType qtype, + IDictionary 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(ctrl, 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 + { ["title"] = page.Title, ["frm"] = formHtml })); + } + catch { /* ignore */ } + } + else + { + var dic = JsonConvert.DeserializeObject>(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, ""); + if (qtype != FdsQueryType.dashboard) + { + page.Links.Add(""); + page.Links.Add(""); + } + } + page.Add(formHtml); + return page; + } + + /// Renders a report query directly as a PNG chart. + public static async Task RenderQueryAsChartAsync( + IntranetController ctrl, string query, FdsQueryType qtype, IDictionary prms) + { + var (adt, dt) = await GetQuery(ctrl, 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; } + } +}