From 8dee630abb08e9712b51e1aad6d96df284b34af6 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 5 Jun 2026 12:57:59 +0200 Subject: [PATCH] Complete DI migration: wire all business services end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the intranet off the static-helper / Active-Record pattern onto constructor-injected services, removing controller coupling and the sync-over-async (Task.Run().Wait()) hot spots in the data classes. Services now registered and consumed via DI: - IBankingService, IPdfService, IMfrClientFactory (singletons) - IWidgetService, IReportService, IInvoiceService, IReminderService (scoped) Key changes: - FuchsWidgetService: real widget logic (sql_table/indicator/html + rendering_options) ported from the static class, which is deleted. - FuchsReportService + FuchsVisualization: report engine decoupled from IntranetController (takes connStr/dbSec/userAccountId); static FuchsReports deleted. - InvoiceService / ReminderService: implement load/register/render/store (previously NotImplementedException stubs). FdsInvoiceData / FdsReminderData are now pure data holders — all DB + PDF work moved into the services, async throughout (no Task.Run().Wait()). - Controllers inject and call the services; all `new FdsMfrClient()` calls go through IMfrClientFactory. - Deleted dead code: static Banking, FuchsWidgets, FuchsReports, and the unused IDbConnectionFactory. - InternalsVisibleTo("Fuchs.Tests") for testing internal mapping logic. Tests: 127 passing (Banking tests moved to the service; added data-holder tests for FdsInvoiceData/FdsReminderData). Full solution builds clean. Co-Authored-By: Claude Opus 4.8 --- Fuchs.Tests/BankingTests.cs | 33 +-- Fuchs.Tests/FdsDataTests.cs | 83 ++++++++ .../Controllers/IntranetController.Banking.cs | 2 +- .../IntranetController.Invoices.cs | 2 +- .../IntranetController.Invoices2.cs | 8 +- .../IntranetController.Reminder.cs | 29 ++- .../Controllers/IntranetController.Reports.cs | 2 +- .../IntranetController.Requests.cs | 39 ++-- Fuchs/Controllers/IntranetController.cs | 47 ++++- Fuchs/Fuchs.csproj | 4 + Fuchs/Program.cs | 9 + Fuchs/Services/FuchsReportService.cs | 125 +++++++++++- Fuchs/Services/FuchsWidgetService.cs | 146 ++++++++++---- Fuchs/Services/IDbConnectionFactory.cs | 18 -- Fuchs/Services/IInvoiceService.cs | 4 +- Fuchs/Services/IReminderService.cs | 4 +- Fuchs/Services/IReportService.cs | 18 +- Fuchs/Services/InvoiceService.cs | 118 +++++++++-- Fuchs/Services/ReminderService.cs | 136 +++++++++++-- Fuchs/code/Banking.cs | 125 ------------ Fuchs/code/FdsInvoiceData.cs | 180 +++-------------- Fuchs/code/FdsReminderData.cs | 181 ++--------------- Fuchs/code/FuchsReports.cs | 124 ------------ Fuchs/code/FuchsVisualization.cs | 31 +-- Fuchs/code/FuchsWidgets.cs | 190 ------------------ 25 files changed, 711 insertions(+), 947 deletions(-) create mode 100644 Fuchs.Tests/FdsDataTests.cs delete mode 100644 Fuchs/Services/IDbConnectionFactory.cs delete mode 100644 Fuchs/code/Banking.cs delete mode 100644 Fuchs/code/FuchsReports.cs delete mode 100644 Fuchs/code/FuchsWidgets.cs diff --git a/Fuchs.Tests/BankingTests.cs b/Fuchs.Tests/BankingTests.cs index 225b5f3..b6904e7 100644 --- a/Fuchs.Tests/BankingTests.cs +++ b/Fuchs.Tests/BankingTests.cs @@ -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; /// -/// Banking helper robustness tests. +/// Banking service robustness tests. /// public class BankingDebitCreditMarkTests { + private static readonly BankingService Svc = new(NullLogger.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.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); } } diff --git a/Fuchs.Tests/FdsDataTests.cs b/Fuchs.Tests/FdsDataTests.cs new file mode 100644 index 0000000..5533cff --- /dev/null +++ b/Fuchs.Tests/FdsDataTests.cs @@ -0,0 +1,83 @@ +using Fuchs.intranet; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Tests for the refactored data holders (FdsInvoiceData / FdsReminderData). +/// These are now pure data objects — persistence/PDF moved to the DI services. +/// +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() + { + // two line items with different VAT rates → highest (19) selected + var jo = JObject.Parse( + "{ \"admin\": { \"type\": \"R\", \"customerid\": \"7\", \"p13b\": false }, " + + " \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"vat_19_net\": \"100\", \"paymentterm\": \"10d\" }, " + + " \"req\": [ { \"items\": [ { \"vat\": \"7%\" }, { \"vat\": \"19%\" } ] } ] }"); + 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")); + } +} diff --git a/Fuchs/Controllers/IntranetController.Banking.cs b/Fuchs/Controllers/IntranetController.Banking.cs index c5dcda6..36c4e4a 100644 --- a/Fuchs/Controllers/IntranetController.Banking.cs +++ b/Fuchs/Controllers/IntranetController.Banking.cs @@ -29,7 +29,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) diff --git a/Fuchs/Controllers/IntranetController.Invoices.cs b/Fuchs/Controllers/IntranetController.Invoices.cs index 26efcca..328a2d7 100644 --- a/Fuchs/Controllers/IntranetController.Invoices.cs +++ b/Fuchs/Controllers/IntranetController.Invoices.cs @@ -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(); diff --git a/Fuchs/Controllers/IntranetController.Invoices2.cs b/Fuchs/Controllers/IntranetController.Invoices2.cs index 76505e8..fbc1019 100644 --- a/Fuchs/Controllers/IntranetController.Invoices2.cs +++ b/Fuchs/Controllers/IntranetController.Invoices2.cs @@ -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 BuildPdfImageArray(byte[] content) + private async Task BuildPdfImageArray(byte[] content) { - var imgcol = await FuchsPdf.BytesToImageCollection(content); + var imgcol = await _pdf.BytesToImageCollectionAsync(content); return imgcol.ImgB64Array; } } diff --git a/Fuchs/Controllers/IntranetController.Reminder.cs b/Fuchs/Controllers/IntranetController.Reminder.cs index 7ecaafe..3d2a1d1 100644 --- a/Fuchs/Controllers/IntranetController.Reminder.cs +++ b/Fuchs/Controllers/IntranetController.Reminder.cs @@ -38,11 +38,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 +64,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 +106,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) { @@ -140,17 +139,17 @@ public partial class IntranetController private async Task 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 }); } diff --git a/Fuchs/Controllers/IntranetController.Reports.cs b/Fuchs/Controllers/IntranetController.Reports.cs index aa90b53..aa88b06 100644 --- a/Fuchs/Controllers/IntranetController.Reports.cs +++ b/Fuchs/Controllers/IntranetController.Reports.cs @@ -46,7 +46,7 @@ public partial class IntranetController } default: - return await FuchsReports.ProcessFdsRequest(this, id.ToLower(), code); + return await _reports.ProcessRequestAsync(id.ToLower(), code, UserAccountID, DbSec, RequestParamsDict()); } } } diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs index f8f7539..49c2004 100644 --- a/Fuchs/Controllers/IntranetController.Requests.cs +++ b/Fuchs/Controllers/IntranetController.Requests.cs @@ -45,8 +45,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 +56,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 +70,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 +174,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 +261,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, @@ -293,19 +296,19 @@ public partial class IntranetController private async Task 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 +325,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) { diff --git a/Fuchs/Controllers/IntranetController.cs b/Fuchs/Controllers/IntranetController.cs index 81633ea..4ec74b7 100644 --- a/Fuchs/Controllers/IntranetController.cs +++ b/Fuchs/Controllers/IntranetController.cs @@ -26,6 +26,13 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller internal readonly fds.IFdsMfr _mfr; private readonly ILogger _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 _allowedNonAuth = new() { "spwc", "spw" }; private readonly List _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 logger, IComService comService) + public IntranetController( + Fuchs_intranet intranet, + fds.IFdsMfr mfr, + ILogger 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; + } + + /// Merged query-string + form parameters (form wins) for report processing. + internal Dictionary RequestParamsDict() + { + var prms = new Dictionary(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"), @@ -374,7 +409,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller if (string.IsNullOrEmpty(id)) { // Empty id → return the OData EDMX schema ($metadata), matching legacy fds.getSchema() - using var mfrSchema = new fds.FdsMfrClient(); + 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); @@ -383,7 +418,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller { 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"); @@ -402,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(); @@ -411,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(); diff --git a/Fuchs/Fuchs.csproj b/Fuchs/Fuchs.csproj index b690b6a..8ac955d 100644 --- a/Fuchs/Fuchs.csproj +++ b/Fuchs/Fuchs.csproj @@ -7,6 +7,10 @@ db-dev.processweb.de;Debug;Release;server02.processweb.de CA1416 + + + + x64 diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index 7fcfe62..4aa62ab 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -75,6 +75,15 @@ public class Program builder.Services.Configure(builder.Configuration.GetSection("Fuchs:Mailer")); builder.Services.AddHttpClient("ProcessWebMailer"); builder.Services.AddScoped(); + + // Business services (DI migration — replaces the static helper / Active-Record pattern) + builder.Services.AddSingleton(); // stateless parser + builder.Services.AddSingleton(); // stateless renderer (sets license) + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); } private static void ConfigureApp(WebApplication app) diff --git a/Fuchs/Services/FuchsReportService.cs b/Fuchs/Services/FuchsReportService.cs index 843b53e..e941f5e 100644 --- a/Fuchs/Services/FuchsReportService.cs +++ b/Fuchs/Services/FuchsReportService.cs @@ -1,27 +1,136 @@ -using Microsoft.AspNetCore.Mvc; +using Fuchs.intranet; +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; /// -/// Report service implementation. Replaces the static FuchsReports 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 . Replaces the static FuchsReports. /// public class FuchsReportService : IReportService { + private const int DefaultReloadSeconds = 60 * 10; + + private readonly Fuchs_intranet _intranet; private readonly ILogger _logger; - public FuchsReportService(ILogger logger) + public FuchsReportService(Fuchs_intranet intranet, ILogger logger) { + _intranet = intranet; _logger = logger; } - public Task ProcessRequestAsync(string action, string id, - string userAccountId, DatabaseSecurity dbSec) + private string Conn => _intranet.Intranet__SQLConnectionString; + + public async Task ProcessRequestAsync(string fnc, string reportId, + string userAccountId, DatabaseSecurity dbSec, IDictionary parameters) { - // Specific report actions are dispatched here. - // Extend with additional cases as needed. - return Task.FromResult(new OkResult()); + var prms = new Dictionary(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 { 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"; + + 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(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) + { + _logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, 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/Services/FuchsWidgetService.cs b/Fuchs/Services/FuchsWidgetService.cs index 6ece54a..5adfe6d 100644 --- a/Fuchs/Services/FuchsWidgetService.cs +++ b/Fuchs/Services/FuchsWidgetService.cs @@ -1,7 +1,6 @@ -using Fuchs.intranet; +using Fuchs.intranet; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using OCORE.security; using OCORE.SQL; @@ -12,8 +11,9 @@ using static OCORE.web.mvc_helper_async; namespace Fuchs.Services; /// -/// Widget service implementation. Replaces the static FuchsWidgets class. -/// No longer depends on IntranetController. +/// Widget service for the Fuchs intranet dashboard. Port of fuchs_fds_widgets.vb — +/// SQL-driven widget cases. Replaces the static FuchsWidgets class +/// (no longer depends on IntranetController). /// public class FuchsWidgetService : IWidgetService { @@ -26,6 +26,8 @@ public class FuchsWidgetService : IWidgetService _logger = logger; } + private string Conn => _intranet.Intranet__SQLConnectionString; + public async Task GetWidgetAsync(string widgetId, string userAccountId, DatabaseSecurity dbSec, HttpRequest request) { @@ -33,9 +35,9 @@ public class FuchsWidgetService : IWidgetService { return 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) }; } catch (Exception ex) @@ -45,20 +47,20 @@ public class FuchsWidgetService : IWidgetService } } - private List MakeParams(string userAccountId, params SqlParameter[] extra) + private static List Params(string userAccountId, + params Microsoft.Data.SqlClient.SqlParameter[] extra) { - var list = new List { SQL_VarChar("@authuser", userAccountId) }; + var list = new List { SQL_VarChar("@authuser", userAccountId) }; list.AddRange(extra); return list; } + // ── "my" — list of widget short-names for the current user ─────────────── private async Task 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() .OrderBy(r => dt.DataTable.Columns.Contains("order") ? r.nz("order") : "") @@ -67,6 +69,7 @@ public class FuchsWidgetService : IWidgetService return await JSONAsync(names); } + // ── "one" — full widget data for a single widget ────────────────────────── private async Task HandleWidgetOne(string userAccountId, DatabaseSecurity dbSec, HttpRequest request) { @@ -75,55 +78,124 @@ 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); } + // ── Generic widget by id ────────────────────────────────────────────────── private async Task HandleWidgetGeneric(string widgetId, string userAccountId, DatabaseSecurity dbSec) { - var pl = MakeParams(userAccountId, SQL_VarChar("@widget", widgetId, dbNull_IfEmpty: true)); + var pl = Params(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); + Conn, pl, tablenames: new[] { "admin", "data" }, Security: dbSec); return await JSONAsync(new { admin = dset.Table("admin").FirstRow.toObjectDictionary(), - data = dset.Tables("data").toArrayofObjectDictionaries() + data = dset.Tables("data").toArrayofObjectDictionaries() }); } - private async Task BuildWidgetResponse(string userAccountId, - DatabaseSecurity dbSec, Dictionary wdg) + // ── Widget renderer dispatcher ──────────────────────────────────────────── + private async Task BuildWidgetResponse(string userAccountId, DatabaseSecurity dbSec, + string shortName, Dictionary 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() - .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() + .Select(c => c.ColumnName).ToArray(), + data = dt.DataTable.Rows.Cast() + .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(); + 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 { [shortName] = widgetData }); } + + private static string[] ParseRenderingOptions(string raw) => + string.IsNullOrWhiteSpace(raw) + ? Array.Empty() + : raw.Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToArray(); } diff --git a/Fuchs/Services/IDbConnectionFactory.cs b/Fuchs/Services/IDbConnectionFactory.cs deleted file mode 100644 index f9a398a..0000000 --- a/Fuchs/Services/IDbConnectionFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Data.SqlClient; - -namespace Fuchs.Services; - -/// -/// Factory for creating SQL connections to the Fuchs database. -/// -public interface IDbConnectionFactory -{ - /// Gets the primary FDS connection string. - string ConnectionString { get; } - - /// Creates and opens a new SQL connection. - SqlConnection CreateConnection(); - - /// Creates a new SQL connection (not opened). - SqlConnection CreateClosedConnection(); -} diff --git a/Fuchs/Services/IInvoiceService.cs b/Fuchs/Services/IInvoiceService.cs index cee593f..23b8668 100644 --- a/Fuchs/Services/IInvoiceService.cs +++ b/Fuchs/Services/IInvoiceService.cs @@ -13,8 +13,8 @@ public interface IInvoiceService /// Loads an existing invoice by ID. Task LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec); - /// Registers (creates or updates) an invoice from form data. - Task RegisterInvoiceAsync(object formData, bool change, string invId, + /// Registers (creates or updates) an invoice from a parsed data object. + Task RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec); /// Generates a PDF document for an invoice. diff --git a/Fuchs/Services/IReminderService.cs b/Fuchs/Services/IReminderService.cs index 6e41296..32d4b3c 100644 --- a/Fuchs/Services/IReminderService.cs +++ b/Fuchs/Services/IReminderService.cs @@ -13,8 +13,8 @@ public interface IReminderService /// Loads an existing reminder by ID. Task LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec); - /// Registers (creates) a reminder from form data. - Task RegisterReminderAsync(object formData, bool change, string remId, + /// Registers (creates) a reminder from a parsed data object. + Task RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec); /// Generates a PDF document for a reminder. diff --git a/Fuchs/Services/IReportService.cs b/Fuchs/Services/IReportService.cs index 8d9945d..a99459c 100644 --- a/Fuchs/Services/IReportService.cs +++ b/Fuchs/Services/IReportService.cs @@ -1,15 +1,21 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OCORE.security; -using OCORE.SQL; namespace Fuchs.Services; /// -/// Abstraction for report processing. +/// Abstraction for SQL-driven report processing (HTML page / fragment / PNG chart). /// public interface IReportService { - /// Processes a report request. - Task ProcessRequestAsync(string action, string id, - string userAccountId, DatabaseSecurity dbSec); + /// + /// Processes a report request. + /// + /// Target function: "generic", "generic_content"/"gct", "chart", or a query-type name. + /// Report name/id (the URL code segment). + /// Authenticated user id. + /// Database security context. + /// Merged query-string + form parameters. + Task ProcessRequestAsync(string fnc, string reportId, + string userAccountId, DatabaseSecurity dbSec, IDictionary parameters); } diff --git a/Fuchs/Services/InvoiceService.cs b/Fuchs/Services/InvoiceService.cs index 1b05a85..1107a95 100644 --- a/Fuchs/Services/InvoiceService.cs +++ b/Fuchs/Services/InvoiceService.cs @@ -1,62 +1,140 @@ -using Fuchs.intranet; +using System.Data; +using Fuchs.intranet; 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; /// -/// Invoice service implementation. Extracts DB operations from FdsInvoiceData -/// 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 . /// public class InvoiceService : IInvoiceService { private readonly Fuchs_intranet _intranet; - private readonly IPdfService _pdfService; + private readonly IPdfService _pdf; private readonly ILogger _logger; - public InvoiceService(Fuchs_intranet intranet, IPdfService pdfService, ILogger logger) + public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger logger) { _intranet = intranet; - _pdfService = pdfService; + _pdf = pdf; _logger = logger; } + private string Conn => _intranet.Intranet__SQLConnectionString; + public async Task 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."); + var inv = new FdsInvoiceData(); + if (string.IsNullOrEmpty(id)) return inv; + + var pl = new List + { + 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; + return inv; } - public async Task RegisterInvoiceAsync(object formData, bool change, string invId, + public async Task RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec) { - throw new NotImplementedException("InvoiceService.RegisterInvoiceAsync pending FdsInvoiceData refactor."); + if (invoice.NewValues == null) return invoice; + + var pl = new List { SQL_VarChar("@authuser", userAccountId) }; + pl.AddRange(invoice.BuildInvoiceParams(change, invId)); + + var sqlParts = new List { "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("