From 4a1561d85fec39cc901ddfd07200a1d6e8a2c6bb Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 29 Apr 2026 11:07:35 +0200 Subject: [PATCH] Add AD auth and B1 currency fields --- .../Components/Layout/MainLayout.razor | 13 ++ .../Components/Layout/NavMenu.razor | 27 ++- .../Components/Pages/Settings.razor | 1 + .../Components/Pages/Standorte.razor | 1 + .../Components/Pages/Transformations.razor | 1 + TrafagSalesExporter/Components/Routes.razor | 29 ++- TrafagSalesExporter/Components/_Imports.razor | 2 + TrafagSalesExporter/HANDOFF_2026-04-15.md | 165 ++++++++++++++++ TrafagSalesExporter/LLM_SYSTEM_GUIDE.md | 84 +++++++++ .../Models/CentralSalesRecord.cs | 7 + TrafagSalesExporter/Models/SalesRecord.cs | 7 + TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 177 ++++++++++++++++++ TrafagSalesExporter/Program.cs | 29 +++ .../DevelopmentAuthenticationHandler.cs | 43 +++++ .../Security/SecurityOptions.cs | 12 ++ .../Security/SecurityPolicies.cs | 6 + .../Security/SecurityPolicyFactory.cs | 32 ++++ .../Services/CentralSalesRecordService.cs | 27 ++- .../Services/ConfigTransferService.cs | 7 + ...DatabaseInitializationService.SchemaSql.cs | 7 + .../DatabaseSchemaMaintenanceService.cs | 7 + .../Services/ExcelExportService.cs | 26 ++- .../Services/HanaQueryService.cs | 27 ++- .../Services/ManualExcelImportService.cs | 18 ++ .../CentralSalesRecordServiceTests.cs | 116 ++++++++++++ .../ManualExcelImportServiceTests.cs | 33 +++- .../SecurityPolicyFactoryTests.cs | 122 ++++++++++++ .../appsettings.Development.json | 9 + TrafagSalesExporter/appsettings.json | 12 ++ 29 files changed, 1016 insertions(+), 31 deletions(-) create mode 100644 TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs create mode 100644 TrafagSalesExporter/Security/SecurityOptions.cs create mode 100644 TrafagSalesExporter/Security/SecurityPolicies.cs create mode 100644 TrafagSalesExporter/Security/SecurityPolicyFactory.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/SecurityPolicyFactoryTests.cs create mode 100644 TrafagSalesExporter/appsettings.Development.json diff --git a/TrafagSalesExporter/Components/Layout/MainLayout.razor b/TrafagSalesExporter/Components/Layout/MainLayout.razor index 33659ac..5a34d7e 100644 --- a/TrafagSalesExporter/Components/Layout/MainLayout.razor +++ b/TrafagSalesExporter/Components/Layout/MainLayout.razor @@ -1,5 +1,6 @@ @inherits LayoutComponentBase @implements IDisposable +@using System.Security.Claims @inject TrafagSalesExporter.Services.IUiTextService UiText @@ -23,6 +24,11 @@ DE EN + + + @ShortName(authState.User) + + @@ -67,6 +73,13 @@ private string T(string german, string english) => UiText.Text(german, english); + private static string ShortName(ClaimsPrincipal user) + { + var name = user.Identity?.Name ?? string.Empty; + var separator = name.LastIndexOf('\\'); + return separator >= 0 && separator < name.Length - 1 ? name[(separator + 1)..] : name; + } + public void Dispose() { UiText.Changed -= HandleLanguageChanged; diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index eea6a57..d801fe2 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -1,21 +1,30 @@ +@using TrafagSalesExporter.Security @inject TrafagSalesExporter.Services.IUiTextService UiText @T("Dashboard", "Dashboard") - - @T("Standorte", "Sites") - - - @T("Transformationen", "Transformations") - + + + + @T("Standorte", "Sites") + + + @T("Transformationen", "Transformations") + + + @T("Management Cockpit", "Management Cockpit") - - @T("Settings", "Settings") - + + + + @T("Settings", "Settings") + + + @T("Logs", "Logs") diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor index 3da78d6..58c045f 100644 --- a/TrafagSalesExporter/Components/Pages/Settings.razor +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -1,4 +1,5 @@ @page "/settings" +@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] @using TrafagSalesExporter.Models @using TrafagSalesExporter.Services @inject ISettingsPageService SettingsPageActions diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index 613ca4c..aa8b9fc 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -1,4 +1,5 @@ @page "/standorte" +@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] @using Microsoft.AspNetCore.Components.Forms @using System.Text.Json @using System.Reflection diff --git a/TrafagSalesExporter/Components/Pages/Transformations.razor b/TrafagSalesExporter/Components/Pages/Transformations.razor index 5b23ab2..c7c0d5c 100644 --- a/TrafagSalesExporter/Components/Pages/Transformations.razor +++ b/TrafagSalesExporter/Components/Pages/Transformations.razor @@ -1,4 +1,5 @@ @page "/transformations" +@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] @using System.Reflection @using TrafagSalesExporter.Models @using TrafagSalesExporter.Services diff --git a/TrafagSalesExporter/Components/Routes.razor b/TrafagSalesExporter/Components/Routes.razor index faa2a8c..35b3aef 100644 --- a/TrafagSalesExporter/Components/Routes.razor +++ b/TrafagSalesExporter/Components/Routes.razor @@ -1,6 +1,23 @@ - - - - - - +@using Microsoft.AspNetCore.Components.Authorization + + + + + + + + + Zugriff verweigert. Bitte mit einem berechtigten Windows-/Domain-Benutzer anmelden. + + + + + + + + + + + + + diff --git a/TrafagSalesExporter/Components/_Imports.razor b/TrafagSalesExporter/Components/_Imports.razor index 5bef312..c4d2f34 100644 --- a/TrafagSalesExporter/Components/_Imports.razor +++ b/TrafagSalesExporter/Components/_Imports.razor @@ -1,7 +1,9 @@ @using System.Net.Http @using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Authorization @using Microsoft.JSInterop @using MudBlazor @using TrafagSalesExporter.Components diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 643a286..a2c93a5 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,171 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder aus HANA + +Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert. + +Grund: + +- `p.StockPrice` muss fachlich in der B1-Hauswaehrung bewertet werden +- die Hauswaehrung kommt aus `OADM.MainCurncy` +- bisher wurde `StandardCostCurrency` aus `p.Currency` bzw. `h.DocCur` abgeleitet +- fuer Power-BI-/Cockpit-Gegenpruefung muessen Belegwaehrung, Hauswaehrung, Netto-/Steuerbetraege und Kurs sichtbar sein + +Neue Felder in `SalesRecord` und `CentralSalesRecord`: + +- `DocumentCurrency` +- `DocumentTotalForeignCurrency` +- `DocumentTotalLocalCurrency` +- `VatSumForeignCurrency` +- `VatSumLocalCurrency` +- `DocumentRate` +- `CompanyCurrency` + +B1-Feldmapping: + +- `DocumentCurrency` = `OINV/ORIN.DocCur` +- `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC` +- `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal` +- `VatSumForeignCurrency` = `OINV/ORIN.VatSumFC` +- `VatSumLocalCurrency` = `OINV/ORIN.VatSum` +- `DocumentRate` = `OINV/ORIN.DocRate` +- `CompanyCurrency` = `OADM.MainCurncy` +- `StandardCostCurrency` = `OADM.MainCurncy` + +Technische Umsetzung: + +- `HanaQueryService` liest `OADM` jetzt per `CROSS JOIN` +- Invoice- und Credit-Note-Query liefern die neuen Felder +- bei Gutschriften werden Dokument- und Steuerbetraege mit negativem Vorzeichen uebernommen +- `CentralSalesRecords`-Schema wurde erweitert +- bestehende SQLite-DBs erhalten die neuen Spalten per `DatabaseSchemaMaintenanceService` +- `CentralSalesRecordService` persistiert und liest die neuen Felder +- `ExcelExportService` schreibt die neuen Spalten in Standort- und `Sales_All_*.xlsx`-Dateien +- `ManualExcelImportService` kann die neuen Spalten wieder einlesen +- `ConfigTransferService` erhaelt die neuen Felder beim Remapping zentraler Laufzeitdaten + +Wichtig fuer Power BI: + +- die neuen `DocumentTotal*`- und `VatSum*`-Felder sind Belegkopfwerte +- sie werden in der positionsbasierten Excel pro Positionszeile wiederholt +- diese Felder duerfen daher nicht blind positionsweise summiert werden +- fuer Belegkopfsummen in Power BI zuerst nach `DocumentType`, `Invoice Number`, `TSC` und ggf. `Land` deduplizieren +- positionsbasierte Auswertungen sollen weiterhin mit positionsbezogenen Feldern wie `Sales Price/Value`, `Quantity` oder `Standard cost` arbeiten + +Verifikation: + +- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich +- `48/48` Tests gruen +- `ManualExcelImportServiceTests` pruefen die neuen Excel-Spalten +- `CentralSalesRecordServiceTests` pruefen Persistenz und Ruecklesen der neuen B1-Felder + +## Nachtrag 2026-04-29 Clean-Code-/DI-Befund + +Der aktuelle Code ist DI-orientiert und deutlich besser strukturiert als zu Beginn des Refactorings, aber noch nicht durchgehend ein Clean-Code-Ideal. + +Positiv: + +- Services werden weitgehend ueber Interfaces und DI verdrahtet +- `DataSourceAdapter` trennt die Quellsysteme +- Page-Services reduzieren direkte DB-Logik in mehreren Razor-Seiten +- `Scoped` fuer UI-nahe Services und `Singleton` fuer gemeinsame Infrastruktur/Orchestrierung ist bewusst gewaehlt +- Testabdeckung fuer zentrale Fachlogik ist vorhanden und waechst + +Weiterhin offene Clean-Code-Risiken: + +- `DatabaseInitializationService` ist weiterhin produktiver Reparatur-/Migrationspfad +- `Settings.razor` und `Standorte.razor` enthalten noch viel Workflow-/UI-Logik +- `ManagementCockpitService` und `ConfigTransferService` sind breit und sollten spaeter weiter aufgeteilt werden +- Retry-/Robustheitslayer fuer externe Systeme fehlt +- Secret-Store fehlt +- Auth-Rollenmodell ist aktuell pragmatisch, aber noch grob + +Bewertung: + +- Architektur: brauchbar bis gut +- DI: grundsaetzlich sauber +- Clean Code: mittel bis gut, mit klaren Altlasten + +Dieser Befund wurde bewusst nur dokumentiert. Die strukturelle Bereinigung wird spaeter priorisiert. + +## Nachtrag 2026-04-29 Authentifizierung / AD-Zugriffsschutz + +Nach Rueckmeldung der IT wurde ein Zugriffsschutz fuer die Blazor-App eingebaut. + +Vorher konnte jeder Benutzer mit Netzwerkzugriff auf die App-URL die Anwendung oeffnen. Das war kritisch, weil die App Verkaufsdaten, Standort-/Quellsystemkonfiguration, SharePoint-Konfiguration, Config Import/Export und Secrets bzw. Zugangsdatenfelder beruehrt. + +Neuer Stand: + +- die App ist grundsaetzlich authentifizierungspflichtig +- produktives Ziel ist Windows Authentication / Active Directory +- Berechtigungen laufen ueber AD-Gruppen +- es gibt keine eigene Benutzer-/Passwortverwaltung in der App +- es gibt keinen versteckten produktiven Backdoor + +Neue Security-Dateien: + +- `Security/SecurityOptions.cs` +- `Security/SecurityPolicies.cs` +- `Security/DevelopmentAuthenticationHandler.cs` + +Geaenderte zentrale Dateien: + +- `Program.cs` +- `Components/Routes.razor` +- `Components/_Imports.razor` +- `Components/Layout/NavMenu.razor` +- `Components/Layout/MainLayout.razor` +- `appsettings.json` +- `appsettings.Development.json` + +Aktuelles Rollenmodell: + +- `Security:AccessGroups` steuert Zugriff auf die App +- `Security:AdminGroups` steuert Admin-Berechtigung +- Default-Gruppen sind `TRAFAG\\TrafagSalesExporter-Users` und `TRAFAG\\TrafagSalesExporter-Admins` +- echte Gruppennamen muessen von der IT bestaetigt oder angepasst werden + +Admin-geschuetzte Seiten: + +- `Settings` +- `Standorte` +- `Transformations` + +Dashboard, Management Cockpit und Logs bleiben fuer berechtigte angemeldete Benutzer sichtbar. + +Development: + +- `appsettings.Development.json` aktiviert bei `ASPNETCORE_ENVIRONMENT=Development` einen lokalen Development-Auth-Handler +- Default-User: `DEV\\TrafagDeveloper` +- `DevelopmentUserIsAdmin=true`, damit lokal weiter programmiert werden kann +- produktiv darf die App nicht mit `Development` laufen + +IIS-/IT-Hinweise: + +1. Windows Authentication aktivieren +2. Anonymous Authentication deaktivieren +3. `ASPNETCORE_ENVIRONMENT` produktiv nicht auf `Development` setzen +4. AD-Gruppen fuer Benutzer und Admins anlegen oder bestehende Gruppen eintragen +5. `Security:AccessGroups` und `Security:AdminGroups` in produktiver Konfiguration korrekt setzen + +Verifikation: + +```text +dotnet build .\TrafagSalesExporter.csproj --verbosity minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich +- `48/48` Tests gruen +- Auth-Policy-Tests fuer AccessGroup, AdminGroup und Development-Admin vorhanden +- lokaler Development-Auth-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200` +- bekannte MudBlazor-Analyzer-Warnungen zu `Dense` bleiben + ## Nachtrag 2026-04-29 Management-Cockpit-Auswertung Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden. diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md index a51af32..2b1c194 100644 --- a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -25,6 +25,7 @@ Zielbild: ## Technologie-Stack - UI: Blazor Server + MudBlazor +- Authentifizierung: ASP.NET Core Authentication/Authorization, produktiv Windows Authentication / Active Directory - Datenbank: SQLite (`trafag_exporter.db`) - Excel lesen/schreiben: ClosedXML - SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll` @@ -42,6 +43,14 @@ Wichtige Dateien: `Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus. +Zusaetzlich registriert `Program.cs` den Zugriffsschutz: + +- `AddCascadingAuthenticationState` +- Windows Authentication fuer produktive Umgebungen +- Development-Authentication-Handler nur bei `ASPNETCORE_ENVIRONMENT=Development` und `Security:DevelopmentBypass=true` +- globale Fallback-Policy fuer authentifizierte/berechtigte User +- Policy `AdminOnly` fuer administrative Seiten + ## Hauptseiten Navigation: @@ -71,6 +80,13 @@ Kurzrollen: - `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export - `Logs`: technische Ereignisprotokolle +Security: + +- alle Routen erfordern Authentifizierung +- `Settings`, `Standorte` und `Transformations` sind `AdminOnly` +- Admin-Navigation wird nur fuer Admins angezeigt +- eingeloggter Benutzer wird im App-Bar angezeigt + ## Kernmodelle Wichtige Entity-Klassen: @@ -90,6 +106,18 @@ Wichtige Entity-Klassen: - [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs) - [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs) +`SalesRecord` / `CentralSalesRecord` enthalten neben den positionsnahen Feldern auch B1-Belegwaehrungsfelder: + +- `DocumentCurrency` aus `DocCur` +- `DocumentTotalForeignCurrency` aus `DocTotalFC` +- `DocumentTotalLocalCurrency` aus `DocTotal` +- `VatSumForeignCurrency` aus `VatSumFC` +- `VatSumLocalCurrency` aus `VatSum` +- `DocumentRate` aus `DocRate` +- `CompanyCurrency` aus `OADM.MainCurncy` + +Wichtig: diese Dokumentwerte sind Belegkopfwerte und werden in der positionsbasierten Excel pro Position wiederholt. Fuer Belegkopfsummen muessen Auswertungen nach Beleg deduplizieren. + Wichtige Relationen: - `Site -> HanaServer` optional @@ -403,6 +431,49 @@ Bereits gehaertete Fehlerbilder: - Legacy-Credential-Spalten in `HanaServers` - verschobene Spalten im `Sites_old -> Sites`-Kopierpfad +## Authentifizierung / Autorisierung + +Dateien: + +- [Security/SecurityOptions.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityOptions.cs) +- [Security/SecurityPolicies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityPolicies.cs) +- [Security/DevelopmentAuthenticationHandler.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs) +- [Components/Routes.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Routes.razor) +- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor) +- [Components/Layout/MainLayout.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/MainLayout.razor) + +Produktives Ziel: + +- Windows Authentication / Active Directory +- keine eigene Benutzerverwaltung +- Zugriff ueber AD-Gruppen +- Adminrechte ueber separate AD-Gruppe + +Konfiguration in `appsettings.json`: + +- `Security:AccessGroups` +- `Security:AdminGroups` +- `Security:DevelopmentBypass` +- `Security:DevelopmentUserIsAdmin` +- `Security:DevelopmentUserName` + +Default-Gruppen: + +- `TRAFAG\\TrafagSalesExporter-Users` +- `TRAFAG\\TrafagSalesExporter-Admins` + +Development: + +- `appsettings.Development.json` aktiviert einen lokalen Development-Auth-Handler +- dieser ist nur fuer lokale Entwicklung gedacht +- produktiv darf `ASPNETCORE_ENVIRONMENT` nicht `Development` sein + +IIS-Betrieb: + +- Windows Authentication aktivieren +- Anonymous Authentication deaktivieren +- AD-Gruppennamen in produktiver Konfiguration setzen + ## Config Import / Export Dateien: @@ -462,6 +533,19 @@ Aktuell vorhandene Schwerpunkte: - Mengen-Auswertung ohne Waehrungsumrechnung - Zusatz-Summenfelder in Zeitreihen +`SecurityPolicyFactoryTests` decken inzwischen ab: + +- App-Zugriff fuer User in `AccessGroups` +- Ablehnung fuer User ausserhalb der Access-Gruppen +- Development-Auth-Zugriff im lokalen Modus +- Admin-Zugriff fuer User in `AdminGroups` +- Ablehnung normaler User fuer `AdminOnly` +- Development-Admin-Claim + +`CentralSalesRecordServiceTests` decken inzwischen ab: + +- Persistenz und Ruecklesen der B1-Belegwaehrungsfelder in `CentralSalesRecords` + Wichtig: - es gibt aktuell keine echten UI-Komponententests mit `bUnit` diff --git a/TrafagSalesExporter/Models/CentralSalesRecord.cs b/TrafagSalesExporter/Models/CentralSalesRecord.cs index 187bc90..64f1db1 100644 --- a/TrafagSalesExporter/Models/CentralSalesRecord.cs +++ b/TrafagSalesExporter/Models/CentralSalesRecord.cs @@ -32,6 +32,13 @@ public class CentralSalesRecord public string PurchaseOrderNumber { get; set; } = string.Empty; public decimal SalesPriceValue { get; set; } public string SalesCurrency { get; set; } = string.Empty; + public string DocumentCurrency { get; set; } = string.Empty; + public decimal DocumentTotalForeignCurrency { get; set; } + public decimal DocumentTotalLocalCurrency { get; set; } + public decimal VatSumForeignCurrency { get; set; } + public decimal VatSumLocalCurrency { get; set; } + public decimal DocumentRate { get; set; } + public string CompanyCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; public DateTime? InvoiceDate { get; set; } diff --git a/TrafagSalesExporter/Models/SalesRecord.cs b/TrafagSalesExporter/Models/SalesRecord.cs index 1871250..45586ea 100644 --- a/TrafagSalesExporter/Models/SalesRecord.cs +++ b/TrafagSalesExporter/Models/SalesRecord.cs @@ -22,6 +22,13 @@ public class SalesRecord public string PurchaseOrderNumber { get; set; } = string.Empty; public decimal SalesPriceValue { get; set; } public string SalesCurrency { get; set; } = string.Empty; + public string DocumentCurrency { get; set; } = string.Empty; + public decimal DocumentTotalForeignCurrency { get; set; } + public decimal DocumentTotalLocalCurrency { get; set; } + public decimal VatSumForeignCurrency { get; set; } + public decimal VatSumLocalCurrency { get; set; } + public decimal DocumentRate { get; set; } + public string CompanyCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; public DateTime? InvoiceDate { get; set; } diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index c1c220e..5e572e7 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,183 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder + +Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder: + +- `DocCur` +- `DocTotalFC` +- `DocTotal` +- `VatSumFC` +- `VatSum` +- `DocRate` +- `OADM.MainCurncy` + +Neue Zielfelder: + +- `DocumentCurrency` +- `DocumentTotalForeignCurrency` +- `DocumentTotalLocalCurrency` +- `VatSumForeignCurrency` +- `VatSumLocalCurrency` +- `DocumentRate` +- `CompanyCurrency` + +Zusaetzlich gilt jetzt: + +- `StandardCostCurrency` kommt im HANA-Pfad aus `OADM.MainCurncy` +- `Sales_All_*.xlsx` enthaelt die neuen Spalten +- `CentralSalesRecords` enthaelt die neuen Spalten +- bestehende SQLite-DBs werden beim Start um die Spalten erweitert +- Manual-Excel-Import kann die neuen Spalten lesen + +### Wichtig fuer Auswertungen + +Die neuen `DocumentTotal*`- und `VatSum*`-Werte sind Belegkopfwerte und werden in der positionsbasierten Datei pro Position wiederholt. + +Power BI: + +- nicht positionsweise summieren +- zuerst nach Beleg deduplizieren, z. B. `TSC` + `DocumentType` + `Invoice Number` +- danach Belegkopfwerte summieren + +Positionswerte wie `Sales Price/Value`, `Quantity` und `Standard cost` bleiben fuer positionsbasierte Summen geeignet. + +### Verifikation + +Geprueft: + +```text +dotnet build .\TrafagSalesExporter.csproj --verbosity minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich +- `48/48` Tests gruen + +## Nachtrag 2026-04-29 Clean-Code-/DI-Befund + +Aktueller Architektur- und DI-Zustand nach den letzten Umbauten: + +Gesamturteil: + +- die App ist deutlich besser strukturiert als zu Beginn +- die Grundarchitektur ist brauchbar bis gut und fuer pragmatischen produktiven Einsatz geeignet +- Dependency Injection wird grundsaetzlich sinnvoll genutzt +- Clean Code ist mittel bis gut, aber noch nicht durchgehend konsequent + +Was positiv ist: + +- Kernservices laufen weitgehend ueber Interfaces und DI +- `DataSourceAdapter`-Pattern trennt `HANA`, `SAP_GATEWAY` und `MANUAL_EXCEL` +- `SiteExportService` ist dadurch deutlich schlanker als frueher +- UI-nahe Page-Services wurden eingefuehrt +- viele Razor-Seiten sind nicht mehr direkt `DbContext`-lastig +- `Scoped` fuer Page-Services und `Singleton` fuer gemeinsame Infrastruktur/Orchestrierung ist bewusst gewaehlt +- Tests decken wichtige Fachlogik ab, u. a. Transformationen, ConfigTransfer, DatabaseInitialization und ManagementCockpit + +Was noch nicht ideal ist: + +- `DatabaseInitializationService` bleibt ein produktiver Reparatur-/Migrationsblock und ist kein sauberes versioniertes Migrationssystem +- `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel UI-/Workflow-Logik +- `ManagementCockpitService`, `ConfigTransferService` und Teile der Initialisierung sind noch sehr breit +- konsolidierter Export hat historisch noch Semantikreste zwischen Live-Snapshot und `CentralSalesRecords` +- Secrets/Zugangsdaten sind noch nicht ideal geloest +- zentraler Retry-/Resilience-Layer fuer SAP/HANA/SharePoint fehlt +- Auth ist jetzt pragmatisch mit User/Admin geschnitten, aber noch nicht fein nach `Viewer`, `Exporter`, `Admin`, `Finance` + +Sinnvolle spaetere Clean-Code-Schritte: + +1. `ManagementCockpitService` in kleinere Query-, Aggregation- und Currency-Komponenten teilen +2. `Settings.razor` und `Standorte.razor` weiter Richtung Page-/Application-Services entlasten +3. `DatabaseInitializationService` langfristig durch versionierte Migrationen ersetzen +4. Auth-Policies fachlich feiner schneiden, z. B. `Viewer`, `Exporter`, `Admin`, `Finance` +5. Retry/Timeout/Failure-Handling fuer externe Systeme zentralisieren +6. Secret-Store-Konzept umsetzen + +## Nachtrag 2026-04-29 Authentifizierung / AD + +Die App wurde nach IT-Rueckmeldung gegen anonymen Zugriff abgesichert. + +Neuer Stand: + +- globale Authentifizierungspflicht +- produktiv vorgesehen: Windows Authentication / Active Directory +- Zugriff und Adminrechte ueber AD-Gruppen +- kein eigener App-Login +- kein versteckter produktiver Backdoor +- lokaler Development-Bypass nur bei `ASPNETCORE_ENVIRONMENT=Development` + +Neue/angepasste Dateien: + +- `Program.cs` +- `Security/SecurityOptions.cs` +- `Security/SecurityPolicies.cs` +- `Security/DevelopmentAuthenticationHandler.cs` +- `Components/Routes.razor` +- `Components/_Imports.razor` +- `Components/Layout/NavMenu.razor` +- `Components/Layout/MainLayout.razor` +- `Components/Pages/Settings.razor` +- `Components/Pages/Standorte.razor` +- `Components/Pages/Transformations.razor` +- `appsettings.json` +- `appsettings.Development.json` + +Aktuelle Default-Gruppen: + +- `TRAFAG\TrafagSalesExporter-Users` +- `TRAFAG\TrafagSalesExporter-Admins` + +### Noch mit IT zu klaeren + +1. Exakte AD-Domain-/Gruppennamen bestaetigen +2. AD-Gruppen anlegen oder bestehende Gruppen verwenden +3. IIS-Zielumgebung festlegen +4. Auf IIS Windows Authentication aktivieren +5. Auf IIS Anonymous Authentication deaktivieren +6. Sicherstellen, dass produktiv nicht `ASPNETCORE_ENVIRONMENT=Development` gesetzt ist +7. Test mit einem normalen User und einem Admin-User durchfuehren + +### Fachliche Rollenentscheidung + +Aktuell: + +- Admin: + - `Settings` + - `Standorte` + - `Transformations` +- berechtigter User: + - Dashboard + - Management Cockpit + - Logs + +Noch zu entscheiden: + +- ob `Logs` ebenfalls Admin-only sein soll +- ob Export-Buttons im Dashboard nur fuer eine eigene Rolle `Exporter` sichtbar sein sollen +- ob Management Cockpit fuer alle berechtigten User oder nur fuer Management/Finance-Gruppen sichtbar sein soll + +### Verifikation + +Geprueft: + +```text +dotnet build .\TrafagSalesExporter.csproj --verbosity minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich +- `48/48` Tests gruen +- Auth-Policy-Tests fuer AccessGroup, AdminGroup und Development-Admin vorhanden +- lokaler Development-Auth-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200` + ## Nachtrag 2026-04-29 Management Cockpit Seit dem 2026-04-17 wurden im `Management Cockpit` weitere Auswertmoeglichkeiten umgesetzt und nachtraeglich aus dem aktuellen Code rekonstruiert. diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index d21e4d3..e8b85c5 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -1,6 +1,10 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Server.IISIntegration; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using TrafagSalesExporter.Data; +using TrafagSalesExporter.Security; using TrafagSalesExporter.Services; using TrafagSalesExporter.Services.DataSources; @@ -13,6 +17,29 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); + +var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get() ?? new SecurityOptions(); +var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass; + +if (useDevelopmentAuthentication) +{ + builder.Services + .AddAuthentication(DevelopmentAuthenticationHandler.SchemeName) + .AddScheme( + DevelopmentAuthenticationHandler.SchemeName, + options => { }); +} +else +{ + builder.Services.AddAuthentication(IISDefaults.AuthenticationScheme); +} + +builder.Services.AddAuthorization(options => +{ + options.FallbackPolicy = SecurityPolicyFactory.BuildAccessPolicy(securitySettings, useDevelopmentAuthentication); + options.AddPolicy(SecurityPolicies.AdminOnly, SecurityPolicyFactory.BuildAdminPolicy(securitySettings, useDevelopmentAuthentication)); +}); builder.Services.AddMudServices(); builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); @@ -87,6 +114,8 @@ if (!app.Environment.IsDevelopment()) } app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseAntiforgery(); app.MapRazorComponents() diff --git a/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs b/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs new file mode 100644 index 0000000..f5b71ec --- /dev/null +++ b/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace TrafagSalesExporter.Security; + +public sealed class DevelopmentAuthenticationHandler : AuthenticationHandler +{ + public const string SchemeName = "Development"; + public const string AdminClaimType = "TrafagSalesExporter.Admin"; + + private readonly IConfiguration _configuration; + + public DevelopmentAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : base(options, logger, encoder) + { + _configuration = configuration; + } + + protected override Task HandleAuthenticateAsync() + { + var settings = _configuration.GetSection(SecurityOptions.SectionName).Get() ?? new SecurityOptions(); + var claims = new List + { + new(ClaimTypes.Name, settings.DevelopmentUserName), + new(ClaimTypes.NameIdentifier, settings.DevelopmentUserName) + }; + + if (settings.DevelopmentUserIsAdmin) + claims.Add(new Claim(AdminClaimType, "true")); + + var identity = new ClaimsIdentity(claims, SchemeName); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, SchemeName); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/TrafagSalesExporter/Security/SecurityOptions.cs b/TrafagSalesExporter/Security/SecurityOptions.cs new file mode 100644 index 0000000..da003a4 --- /dev/null +++ b/TrafagSalesExporter/Security/SecurityOptions.cs @@ -0,0 +1,12 @@ +namespace TrafagSalesExporter.Security; + +public sealed class SecurityOptions +{ + public const string SectionName = "Security"; + + public bool DevelopmentBypass { get; set; } + public bool DevelopmentUserIsAdmin { get; set; } + public string DevelopmentUserName { get; set; } = "DEV\\TrafagDeveloper"; + public List AccessGroups { get; set; } = []; + public List AdminGroups { get; set; } = []; +} diff --git a/TrafagSalesExporter/Security/SecurityPolicies.cs b/TrafagSalesExporter/Security/SecurityPolicies.cs new file mode 100644 index 0000000..0be74e0 --- /dev/null +++ b/TrafagSalesExporter/Security/SecurityPolicies.cs @@ -0,0 +1,6 @@ +namespace TrafagSalesExporter.Security; + +public static class SecurityPolicies +{ + public const string AdminOnly = nameof(AdminOnly); +} diff --git a/TrafagSalesExporter/Security/SecurityPolicyFactory.cs b/TrafagSalesExporter/Security/SecurityPolicyFactory.cs new file mode 100644 index 0000000..f91d90d --- /dev/null +++ b/TrafagSalesExporter/Security/SecurityPolicyFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; + +namespace TrafagSalesExporter.Security; + +public static class SecurityPolicyFactory +{ + public static AuthorizationPolicy BuildAccessPolicy(SecurityOptions settings, bool useDevelopmentAuthentication) + { + var builder = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser(); + + if (!useDevelopmentAuthentication && settings.AccessGroups.Count > 0) + { + builder.RequireAssertion(context => + settings.AccessGroups.Any(group => context.User.IsInRole(group))); + } + + return builder.Build(); + } + + public static AuthorizationPolicy BuildAdminPolicy(SecurityOptions settings, bool useDevelopmentAuthentication) + { + var builder = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser(); + + builder.RequireAssertion(context => + useDevelopmentAuthentication && context.User.HasClaim(DevelopmentAuthenticationHandler.AdminClaimType, "true") || + settings.AdminGroups.Any(group => context.User.IsInRole(group))); + + return builder.Build(); + } +} diff --git a/TrafagSalesExporter/Services/CentralSalesRecordService.cs b/TrafagSalesExporter/Services/CentralSalesRecordService.cs index 48964e1..de8b7d3 100644 --- a/TrafagSalesExporter/Services/CentralSalesRecordService.cs +++ b/TrafagSalesExporter/Services/CentralSalesRecordService.cs @@ -82,6 +82,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService PurchaseOrderNumber = r.PurchaseOrderNumber, SalesPriceValue = r.SalesPriceValue, SalesCurrency = r.SalesCurrency, + DocumentCurrency = r.DocumentCurrency, + DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency, + DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency, + VatSumForeignCurrency = r.VatSumForeignCurrency, + VatSumLocalCurrency = r.VatSumLocalCurrency, + DocumentRate = r.DocumentRate, + CompanyCurrency = r.CompanyCurrency, Incoterms2020 = r.Incoterms2020, SalesResponsibleEmployee = r.SalesResponsibleEmployee, InvoiceDate = r.InvoiceDate, @@ -158,14 +165,16 @@ public class CentralSalesRecordService : ICentralSalesRecordService Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry, CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, - SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType + DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency, + VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType ) VALUES ( $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice, $material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry, $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, - $salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType + $documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency, + $vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType ); """; @@ -192,6 +201,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text); command.Parameters.Add("$salesPriceValue", SqliteType.Real); command.Parameters.Add("$salesCurrency", SqliteType.Text); + command.Parameters.Add("$documentCurrency", SqliteType.Text); + command.Parameters.Add("$documentTotalForeignCurrency", SqliteType.Real); + command.Parameters.Add("$documentTotalLocalCurrency", SqliteType.Real); + command.Parameters.Add("$vatSumForeignCurrency", SqliteType.Real); + command.Parameters.Add("$vatSumLocalCurrency", SqliteType.Real); + command.Parameters.Add("$documentRate", SqliteType.Real); + command.Parameters.Add("$companyCurrency", SqliteType.Text); command.Parameters.Add("$incoterms2020", SqliteType.Text); command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text); command.Parameters.Add("$invoiceDate", SqliteType.Text); @@ -227,6 +243,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty; command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue; command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty; + command.Parameters["$documentCurrency"].Value = record.DocumentCurrency ?? string.Empty; + command.Parameters["$documentTotalForeignCurrency"].Value = record.DocumentTotalForeignCurrency; + command.Parameters["$documentTotalLocalCurrency"].Value = record.DocumentTotalLocalCurrency; + command.Parameters["$vatSumForeignCurrency"].Value = record.VatSumForeignCurrency; + command.Parameters["$vatSumLocalCurrency"].Value = record.VatSumLocalCurrency; + command.Parameters["$documentRate"].Value = record.DocumentRate; + command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty; command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty; command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty; command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value; diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index 4dd5b25..4e2276b 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -332,6 +332,13 @@ public class ConfigTransferService : IConfigTransferService PurchaseOrderNumber = record.PurchaseOrderNumber, SalesPriceValue = record.SalesPriceValue, SalesCurrency = record.SalesCurrency, + DocumentCurrency = record.DocumentCurrency, + DocumentTotalForeignCurrency = record.DocumentTotalForeignCurrency, + DocumentTotalLocalCurrency = record.DocumentTotalLocalCurrency, + VatSumForeignCurrency = record.VatSumForeignCurrency, + VatSumLocalCurrency = record.VatSumLocalCurrency, + DocumentRate = record.DocumentRate, + CompanyCurrency = record.CompanyCurrency, Incoterms2020 = record.Incoterms2020, SalesResponsibleEmployee = record.SalesResponsibleEmployee, InvoiceDate = record.InvoiceDate, diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index 40af817..f4d8ff4 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -103,6 +103,13 @@ CREATE TABLE CentralSalesRecords ( PurchaseOrderNumber TEXT NOT NULL, SalesPriceValue TEXT NOT NULL, SalesCurrency TEXT NOT NULL, + DocumentCurrency TEXT NOT NULL DEFAULT '', + DocumentTotalForeignCurrency TEXT NOT NULL DEFAULT '0', + DocumentTotalLocalCurrency TEXT NOT NULL DEFAULT '0', + VatSumForeignCurrency TEXT NOT NULL DEFAULT '0', + VatSumLocalCurrency TEXT NOT NULL DEFAULT '0', + DocumentRate TEXT NOT NULL DEFAULT '0', + CompanyCurrency TEXT NOT NULL DEFAULT '', Incoterms2020 TEXT NOT NULL, SalesResponsibleEmployee TEXT NOT NULL, InvoiceDate TEXT NULL, diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index 32250fa..e3f338a 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -40,6 +40,13 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic EnsureSapJoinTable(db); EnsureSapFieldMappingTable(db); EnsureCentralSalesRecordTable(db); + AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''"); + AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'"); + AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'"); + AddColumnIfMissing(db, "CentralSalesRecords", "VatSumForeignCurrency", "TEXT NOT NULL DEFAULT '0'"); + AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'"); + AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'"); + AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''"); EnsureAppEventLogTable(db); } diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs index fe12bb5..af36a2c 100644 --- a/TrafagSalesExporter/Services/ExcelExportService.cs +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -60,6 +60,13 @@ public class ExcelExportService : IExcelExportService "Purchase Order number", "Sales Price/Value", "Sales Currency", + "Document Currency", + "Document Total FC", + "Document Total LC", + "VAT Sum FC", + "VAT Sum LC", + "Document Rate", + "Company Currency", "Incoterms 2020", "Sales responsible employee", "invoice date", @@ -97,12 +104,19 @@ public class ExcelExportService : IExcelExportService ws.Cell(row, 18).Value = record.PurchaseOrderNumber; ws.Cell(row, 19).Value = record.SalesPriceValue; ws.Cell(row, 20).Value = record.SalesCurrency; - ws.Cell(row, 21).Value = record.Incoterms2020; - ws.Cell(row, 22).Value = record.SalesResponsibleEmployee; - ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; - ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; - ws.Cell(row, 25).Value = record.Land; - ws.Cell(row, 26).Value = record.DocumentType; + ws.Cell(row, 21).Value = record.DocumentCurrency; + ws.Cell(row, 22).Value = record.DocumentTotalForeignCurrency; + ws.Cell(row, 23).Value = record.DocumentTotalLocalCurrency; + ws.Cell(row, 24).Value = record.VatSumForeignCurrency; + ws.Cell(row, 25).Value = record.VatSumLocalCurrency; + ws.Cell(row, 26).Value = record.DocumentRate; + ws.Cell(row, 27).Value = record.CompanyCurrency; + ws.Cell(row, 28).Value = record.Incoterms2020; + ws.Cell(row, 29).Value = record.SalesResponsibleEmployee; + ws.Cell(row, 30).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; + ws.Cell(row, 31).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; + ws.Cell(row, 32).Value = record.Land; + ws.Cell(row, 33).Value = record.DocumentType; row++; } diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs index b95a301..1d51899 100644 --- a/TrafagSalesExporter/Services/HanaQueryService.cs +++ b/TrafagSalesExporter/Services/HanaQueryService.cs @@ -177,6 +177,13 @@ public class HanaQueryService : IHanaQueryService PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty, SalesPriceValue = Convert.ToDecimal(reader["sales_value"]), SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty, + DocumentCurrency = reader["document_currency"]?.ToString() ?? string.Empty, + DocumentTotalForeignCurrency = Convert.ToDecimal(reader["document_total_fc"]), + DocumentTotalLocalCurrency = Convert.ToDecimal(reader["document_total_lc"]), + VatSumForeignCurrency = Convert.ToDecimal(reader["vat_sum_fc"]), + VatSumLocalCurrency = Convert.ToDecimal(reader["vat_sum_lc"]), + DocumentRate = Convert.ToDecimal(reader["document_rate"]), + CompanyCurrency = reader["company_currency"]?.ToString() ?? string.Empty, Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty, SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty, OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")), @@ -217,12 +224,19 @@ SELECT COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(ind.""IndName"", '') AS customer_industry, p.""StockPrice"" AS standard_cost, - COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, + COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, CASE WHEN p.""BaseType"" = 22 THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) ELSE '' END AS purchase_order_number, p.""LineTotal"" AS sales_value, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, + COALESCE(h.""DocCur"", '') AS document_currency, + COALESCE(h.""DocTotalFC"", 0) AS document_total_fc, + COALESCE(h.""DocTotal"", 0) AS document_total_lc, + COALESCE(h.""VatSumFC"", 0) AS vat_sum_fc, + COALESCE(h.""VatSum"", 0) AS vat_sum_lc, + COALESCE(h.""DocRate"", 0) AS document_rate, + COALESCE(adm.""MainCurncy"", '') AS company_currency, '' AS incoterms_2020, COALESCE(emp.""SlpName"", '') AS sales_responsible, CASE WHEN p.""BaseType"" = 17 @@ -232,6 +246,7 @@ SELECT 'INV' AS doc_type FROM {quotedSchema}.""OINV"" h INNER JOIN {quotedSchema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" +CROSS JOIN {quotedSchema}.""OADM"" adm LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" @@ -269,16 +284,24 @@ SELECT COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(ind.""IndName"", '') AS customer_industry, p.""StockPrice"" AS standard_cost, - COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, + COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, '' AS purchase_order_number, p.""LineTotal"" * -1 AS sales_value, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, + COALESCE(h.""DocCur"", '') AS document_currency, + COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc, + COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc, + COALESCE(h.""VatSumFC"", 0) * -1 AS vat_sum_fc, + COALESCE(h.""VatSum"", 0) * -1 AS vat_sum_lc, + COALESCE(h.""DocRate"", 0) AS document_rate, + COALESCE(adm.""MainCurncy"", '') AS company_currency, '' AS incoterms_2020, COALESCE(emp.""SlpName"", '') AS sales_responsible, NULL AS order_date, 'CRN' AS doc_type FROM {quotedSchema}.""ORIN"" h INNER JOIN {quotedSchema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" +CROSS JOIN {quotedSchema}.""OADM"" adm LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index 1accfc0..8f0f8b5 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -28,6 +28,17 @@ public class ManualExcelImportService : IManualExcelImportService ["purchaseordernumber"] = nameof(SalesRecord.PurchaseOrderNumber), ["salespricevalue"] = nameof(SalesRecord.SalesPriceValue), ["salescurrency"] = nameof(SalesRecord.SalesCurrency), + ["documentcurrency"] = nameof(SalesRecord.DocumentCurrency), + ["documenttotalfc"] = nameof(SalesRecord.DocumentTotalForeignCurrency), + ["documenttotalforeigncurrency"] = nameof(SalesRecord.DocumentTotalForeignCurrency), + ["documenttotallc"] = nameof(SalesRecord.DocumentTotalLocalCurrency), + ["documenttotallocalcurrency"] = nameof(SalesRecord.DocumentTotalLocalCurrency), + ["vatsumfc"] = nameof(SalesRecord.VatSumForeignCurrency), + ["vatsumforeigncurrency"] = nameof(SalesRecord.VatSumForeignCurrency), + ["vatsumlc"] = nameof(SalesRecord.VatSumLocalCurrency), + ["vatsumlocalcurrency"] = nameof(SalesRecord.VatSumLocalCurrency), + ["documentrate"] = nameof(SalesRecord.DocumentRate), + ["companycurrency"] = nameof(SalesRecord.CompanyCurrency), ["incoterms2020"] = nameof(SalesRecord.Incoterms2020), ["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee), ["invoicedate"] = nameof(SalesRecord.InvoiceDate), @@ -75,6 +86,13 @@ public class ManualExcelImportService : IManualExcelImportService PurchaseOrderNumber = ReadString(headerIndexes, row, nameof(SalesRecord.PurchaseOrderNumber)), SalesPriceValue = ReadDecimal(headerIndexes, row, nameof(SalesRecord.SalesPriceValue)), SalesCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.SalesCurrency)), + DocumentCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.DocumentCurrency)), + DocumentTotalForeignCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentTotalForeignCurrency)), + DocumentTotalLocalCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentTotalLocalCurrency)), + VatSumForeignCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.VatSumForeignCurrency)), + VatSumLocalCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.VatSumLocalCurrency)), + DocumentRate = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentRate)), + CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)), Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)), SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)), InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)), diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs new file mode 100644 index 0000000..73d671d --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs @@ -0,0 +1,116 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class CentralSalesRecordServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + + public CentralSalesRecordServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var db = new AppDbContext(options); + db.Database.EnsureCreated(); + + _dbFactory = new TestDbContextFactory(options); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public async Task ReplaceForSiteAsync_Persists_B1_Document_Currency_Fields() + { + var site = new Site + { + Id = 1, + Schema = "SBODEMO", + TSC = "TRCH", + Land = "Schweiz", + SourceSystem = "BI1", + IsActive = true + }; + + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.Sites.Add(site); + await db.SaveChangesAsync(); + } + + var service = new CentralSalesRecordService(_dbFactory, new NullAppEventLogService()); + await service.ReplaceForSiteAsync(site, [ + new SalesRecord + { + ExtractionDate = new DateTime(2026, 4, 29), + Tsc = "TRCH", + InvoiceNumber = "1001", + PositionOnInvoice = 1, + Material = "MAT", + Name = "Article", + ProductGroup = "PG", + Quantity = 2m, + StandardCost = 10m, + StandardCostCurrency = "CHF", + SalesPriceValue = 25m, + SalesCurrency = "EUR", + DocumentCurrency = "EUR", + DocumentTotalForeignCurrency = 100m, + DocumentTotalLocalCurrency = 95m, + VatSumForeignCurrency = 8m, + VatSumLocalCurrency = 7.6m, + DocumentRate = 0.95m, + CompanyCurrency = "CHF", + Land = "Schweiz", + DocumentType = "INV" + } + ]); + + var rows = await service.GetAllAsync(); + + var row = Assert.Single(rows); + Assert.Equal("EUR", row.DocumentCurrency); + Assert.Equal(100m, row.DocumentTotalForeignCurrency); + Assert.Equal(95m, row.DocumentTotalLocalCurrency); + Assert.Equal(8m, row.VatSumForeignCurrency); + Assert.Equal(7.6m, row.VatSumLocalCurrency); + Assert.Equal(0.95m, row.DocumentRate); + Assert.Equal("CHF", row.CompanyCurrency); + } + + private sealed class NullAppEventLogService : IAppEventLogService + { + public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + + public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs index 6424862..a31707f 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs @@ -38,12 +38,19 @@ public class ManualExcelImportServiceTests ws.Cell(2, 18).Value = "PO-1"; ws.Cell(2, 19).Value = 21.40m; ws.Cell(2, 20).Value = "EUR"; - ws.Cell(2, 21).Value = "DAP"; - ws.Cell(2, 22).Value = "Alice"; - ws.Cell(2, 23).Value = "14.04.2026"; - ws.Cell(2, 24).Value = "10.04.2026"; - ws.Cell(2, 25).Value = "Deutschland"; - ws.Cell(2, 26).Value = "Invoice"; + ws.Cell(2, 21).Value = "EUR"; + ws.Cell(2, 22).Value = 120.50m; + ws.Cell(2, 23).Value = 110.25m; + ws.Cell(2, 24).Value = 8.10m; + ws.Cell(2, 25).Value = 7.45m; + ws.Cell(2, 26).Value = 1.0925m; + ws.Cell(2, 27).Value = "CHF"; + ws.Cell(2, 28).Value = "DAP"; + ws.Cell(2, 29).Value = "Alice"; + ws.Cell(2, 30).Value = "14.04.2026"; + ws.Cell(2, 31).Value = "10.04.2026"; + ws.Cell(2, 32).Value = "Deutschland"; + ws.Cell(2, 33).Value = "Invoice"; }); try @@ -60,6 +67,13 @@ public class ManualExcelImportServiceTests Assert.Equal(2.5m, row.Quantity); Assert.Equal(10.25m, row.StandardCost); Assert.Equal(21.40m, row.SalesPriceValue); + Assert.Equal("EUR", row.DocumentCurrency); + Assert.Equal(120.50m, row.DocumentTotalForeignCurrency); + Assert.Equal(110.25m, row.DocumentTotalLocalCurrency); + Assert.Equal(8.10m, row.VatSumForeignCurrency); + Assert.Equal(7.45m, row.VatSumLocalCurrency); + Assert.Equal(1.0925m, row.DocumentRate); + Assert.Equal("CHF", row.CompanyCurrency); Assert.Equal("Deutschland", row.Land); Assert.Equal("Invoice", row.DocumentType); Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate); @@ -205,6 +219,13 @@ public class ManualExcelImportServiceTests "Purchase Order number", "Sales Price/Value", "Sales Currency", + "Document Currency", + "Document Total FC", + "Document Total LC", + "VAT Sum FC", + "VAT Sum LC", + "Document Rate", + "Company Currency", "Incoterms 2020", "Sales responsible employee", "invoice date", diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/SecurityPolicyFactoryTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/SecurityPolicyFactoryTests.cs new file mode 100644 index 0000000..622b828 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/SecurityPolicyFactoryTests.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using TrafagSalesExporter.Security; + +namespace TrafagSalesExporter.Tests; + +public class SecurityPolicyFactoryTests +{ + [Fact] + public async Task AccessPolicy_Allows_User_In_Configured_Access_Group() + { + var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions + { + AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"] + }, useDevelopmentAuthentication: false); + + var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Users"])); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AccessPolicy_Denies_User_Outside_Configured_Access_Group() + { + var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions + { + AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"] + }, useDevelopmentAuthentication: false); + + var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\OtherGroup"])); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task AccessPolicy_Allows_Authenticated_User_When_Development_Authentication_Is_Active() + { + var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions + { + AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"] + }, useDevelopmentAuthentication: true); + + var result = await AuthorizeAsync(policy, CreateUser()); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AdminPolicy_Allows_User_In_Admin_Group() + { + var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions + { + AdminGroups = ["TRAFAG\\TrafagSalesExporter-Admins"] + }, useDevelopmentAuthentication: false); + + var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Admins"])); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AdminPolicy_Denies_Normal_Access_User() + { + var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions + { + AdminGroups = ["TRAFAG\\TrafagSalesExporter-Admins"] + }, useDevelopmentAuthentication: false); + + var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Users"])); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task AdminPolicy_Allows_Development_Admin_Claim() + { + var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions(), useDevelopmentAuthentication: true); + + var result = await AuthorizeAsync(policy, CreateUser(claims: + [ + new Claim(DevelopmentAuthenticationHandler.AdminClaimType, "true") + ])); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AdminPolicy_Denies_Development_User_Without_Admin_Claim() + { + var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions(), useDevelopmentAuthentication: true); + + var result = await AuthorizeAsync(policy, CreateUser()); + + Assert.False(result.Succeeded); + } + + private static async Task AuthorizeAsync(AuthorizationPolicy policy, ClaimsPrincipal user) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthorization(); + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + return await service.AuthorizeAsync(user, resource: null, policy); + } + + private static ClaimsPrincipal CreateUser(IEnumerable? roles = null, IEnumerable? claims = null) + { + var allClaims = new List + { + new(ClaimTypes.Name, "TRAFAG\\tester") + }; + + allClaims.AddRange((roles ?? []).Select(role => new Claim(ClaimTypes.Role, role))); + allClaims.AddRange(claims ?? []); + + return new ClaimsPrincipal(new ClaimsIdentity(allClaims, "Test")); + } + +} diff --git a/TrafagSalesExporter/appsettings.Development.json b/TrafagSalesExporter/appsettings.Development.json new file mode 100644 index 0000000..d880929 --- /dev/null +++ b/TrafagSalesExporter/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Security": { + "DevelopmentBypass": true, + "DevelopmentUserIsAdmin": true, + "DevelopmentUserName": "DEV\\TrafagDeveloper", + "AccessGroups": [], + "AdminGroups": [] + } +} diff --git a/TrafagSalesExporter/appsettings.json b/TrafagSalesExporter/appsettings.json index 0c208ae..d6931f8 100644 --- a/TrafagSalesExporter/appsettings.json +++ b/TrafagSalesExporter/appsettings.json @@ -4,5 +4,17 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Security": { + "DevelopmentBypass": false, + "DevelopmentUserIsAdmin": false, + "DevelopmentUserName": "DEV\\TrafagDeveloper", + "AccessGroups": [ + "TRAFAG\\TrafagSalesExporter-Users", + "TRAFAG\\TrafagSalesExporter-Admins" + ], + "AdminGroups": [ + "TRAFAG\\TrafagSalesExporter-Admins" + ] } }