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"
+ ]
}
}