diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 3f07149..9fb496f 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -134,6 +134,46 @@ @T("Periode", "Period")@BuildPeriodLabel(_centralResult) + + @T("Cockpit Manometer", "Cockpit gauges") + + @T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.") + + + @foreach (var gauge in BuildCentralGauges(_centralResult)) + { + + + @gauge.Title +
+ + + + + + @gauge.DisplayValue + @gauge.Subtitle + +
+
+
+ } +
+
+ @T("Hinweise", "Notes") @foreach (var notice in _centralResult.Notices) @@ -248,9 +288,42 @@ } + + @code { private List _files = []; private List _centralYears = []; + private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110"; private string? _selectedFilePath; private ManagementCockpitResult? _result; private ManagementCockpitCentralResult? _centralResult; @@ -341,6 +414,181 @@ return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}"; } + + private List BuildCentralGauges(ManagementCockpitCentralResult result) + { + var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount; + var sourceDominance = result.SourceSystemTotals.Count == 0 + ? 0m + : result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount); + var countryDominance = result.CountryTotals.Count == 0 + ? 0m + : result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount); + var periodCoverage = BuildPeriodCoveragePercent(result); + var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals); + var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals); + var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m); + var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result); + + return + [ + new CentralGaugeModel + { + Title = T("Rechnungsdichte", "Invoice density"), + Percent = invoiceDensity, + DisplayValue = $"{invoiceDensity:F0}%", + Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"), + Color = "#1f8a70" + }, + new CentralGaugeModel + { + Title = T("Quellen-Dominanz", "Source dominance"), + Percent = sourceDominance, + DisplayValue = $"{sourceDominance:F0}%", + Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"), + Color = "#d9822b" + }, + new CentralGaugeModel + { + Title = T("Land-Dominanz", "Country dominance"), + Percent = countryDominance, + DisplayValue = $"{countryDominance:F0}%", + Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"), + Color = "#c4496b" + }, + new CentralGaugeModel + { + Title = T("Perioden-Abdeckung", "Period coverage"), + Percent = periodCoverage, + DisplayValue = $"{periodCoverage:F0}%", + Subtitle = BuildPeriodGaugeSubtitle(result), + Color = "#3d7ff0" + }, + new CentralGaugeModel + { + Title = T("Top-Land Umsatz", "Top country sales"), + Percent = topCountrySalesShare, + DisplayValue = $"{topCountrySalesShare:F0}%", + Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"), + Color = "#7f56d9" + }, + new CentralGaugeModel + { + Title = T("Top-Quelle Umsatz", "Top source sales"), + Percent = topSourceSalesShare, + DisplayValue = $"{topSourceSalesShare:F0}%", + Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"), + Color = "#0f9fb5" + }, + new CentralGaugeModel + { + Title = T("Waehrungs-Komplexitaet", "Currency complexity"), + Percent = currencyComplexity, + DisplayValue = result.Summary.CurrencyCount.ToString("N0"), + Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"), + Color = "#b54708" + }, + new CentralGaugeModel + { + Title = T("Monat gegen Peak", "Month vs peak"), + Percent = peakVsAverageMonth, + DisplayValue = $"{peakVsAverageMonth:F0}%", + Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"), + Color = "#d92d20" + } + ]; + } + + private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result) + { + if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null) + return 0m; + + if (result.Filter.Month.HasValue) + { + var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value); + var coveredDays = result.DailyTotals + .Select(x => x.Day) + .Where(x => x.HasValue) + .Distinct() + .Count(); + return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth; + } + + var coveredMonths = result.MonthlyTotals + .Select(x => x.Month) + .Where(x => x.HasValue) + .Distinct() + .Count(); + return coveredMonths * 100m / 12m; + } + + private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result) + => result.Filter.Month.HasValue + ? T("Tage mit Daten im Monat", "Days with data in month") + : T("Monate mit Daten im Jahr", "Months with data in year"); + + private static decimal BuildTopSalesSharePercent(IEnumerable rows) + { + var materialized = rows.ToList(); + if (materialized.Count == 0) + return 0m; + + var total = materialized.Sum(x => x.SalesValue); + if (total == 0) + return 0m; + + return materialized.Max(x => x.SalesValue) * 100m / total; + } + + private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result) + { + var monthRows = result.MonthlyTotals.ToList(); + if (monthRows.Count == 0) + return 0m; + + var groupedMonths = monthRows + .GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .Select(g => g.Sum(x => x.SalesValue)) + .ToList(); + + if (groupedMonths.Count == 0) + return 0m; + + var peak = groupedMonths.Max(); + if (peak == 0) + return 0m; + + var average = groupedMonths.Average(); + return Math.Min(100m, average * 100m / peak); + } + + private static string BuildGaugeDashArray(decimal percent) + => $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100"; + + private static string BuildGaugeNeedleX(decimal percent) + => GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + + private static string BuildGaugeNeedleY(decimal percent) + => GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + + private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d) + { + var clamped = Math.Clamp((double)percent, 0d, 100d); + var angle = Math.PI * (1d - clamped / 100d); + var x = 110d + radius * Math.Cos(angle); + var y = 110d - radius * Math.Sin(angle); + return (x, y); + } + + private sealed class CentralGaugeModel + { + public string Title { get; set; } = string.Empty; + public decimal Percent { get; set; } + public string DisplayValue { get; set; } = string.Empty; + public string Subtitle { get; set; } = string.Empty; + public string Color { get; set; } = "#3d7ff0"; + } } @code { diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index 4a34529..794b698 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -9,6 +9,7 @@ @inject IDbContextFactory DbFactory @inject IHanaQueryService HanaService @inject ISapGatewayService SapGatewayService +@inject ISharePointUploadService SharePointService @inject IAppEventLogService AppEventLogService @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -141,9 +142,44 @@ + @if (UsesHanaConnection()) + { + + + @if (_loadingSchemas) + { + + @("Lade Schemas...") + } + else + { + @("Schemas laden") + } + + @if (_availableSchemas.Count > 0) + { + + @foreach (var schema in _availableSchemas) + { + @schema + } + + } + + + Die Liste wird aus der zentralen HANA-Verbindung des Quellsystems gelesen und auf typische B1-Schemas eingeschraenkt. + + } - + @foreach (var system in GetAvailableSourceSystems()) { @GetSourceSystemLabel(system) @@ -351,6 +387,13 @@ Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. + + + Pfad pruefen + @if (_uploadingManualImport) { @@ -394,6 +437,7 @@ private List _sites = new(); private List _sourceSystemDefinitions = new(); private List _sapEntitySetsCache = []; + private List _availableSchemas = []; private List _sapAvailableSourceExpressions = []; private Dictionary> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); private List _sapSources = []; @@ -411,6 +455,7 @@ private bool _refreshingSapSourceFields; private bool _savingServer; private bool _savingSite; + private bool _loadingSchemas; private bool _uploadingManualImport; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; @@ -625,6 +670,7 @@ HanaServerId = null, ManualImportFilePath = string.Empty }; + _availableSchemas = []; _sapEntitySetsCache = []; _sapAvailableSourceExpressions = []; _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); @@ -657,6 +703,7 @@ SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, IsActive = site.IsActive }; + _availableSchemas = []; _sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache); using var db = DbFactory.CreateDbContext(); _sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList(); @@ -798,6 +845,19 @@ return centralServer.Id; } + private Task OnSchemaSelected(string schema) + { + _editingSite.Schema = schema; + return Task.CompletedTask; + } + + private Task OnSourceSystemChanged(string value) + { + _editingSite.SourceSystem = value; + _availableSchemas = []; + return Task.CompletedTask; + } + private IEnumerable GetAvailableSourceSystems() => _sourceSystemDefinitions .Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase)) @@ -871,6 +931,85 @@ return $"{centralServer.Name} | {GetServerNode(centralServer)}"; } + private async Task LoadAvailableSchemasAsync() + { + if (_loadingSchemas) + return; + + _loadingSchemas = true; + try + { + using var db = await DbFactory.CreateDbContextAsync(); + var sourceDefinition = await db.SourceSystemDefinitions + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem); + + if (sourceDefinition is null) + throw new InvalidOperationException($"Quellsystem '{_editingSite.SourceSystem}' nicht gefunden."); + + var centralServer = await db.HanaServers + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.SourceSystem == _editingSite.SourceSystem); + + if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host)) + throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} ist keine gueltige zentrale HANA-Konfiguration vorhanden."); + + var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) + ? sourceDefinition.CentralUsername ?? string.Empty + : _editingSite.UsernameOverride; + var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) + ? sourceDefinition.CentralPassword ?? string.Empty + : _editingSite.PasswordOverride; + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt."); + + var lookupServer = new HanaServer + { + Id = centralServer.Id, + SourceSystem = centralServer.SourceSystem, + Name = centralServer.Name, + Host = centralServer.Host, + Port = centralServer.Port, + Username = username.Trim(), + Password = password, + DatabaseName = centralServer.DatabaseName, + UseSsl = centralServer.UseSsl, + ValidateCertificate = centralServer.ValidateCertificate, + AdditionalParams = centralServer.AdditionalParams + }; + + var schemas = await Task.Run(() => HanaService.GetAvailableSchemas(lookupServer)); + _availableSchemas = schemas + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (_availableSchemas.Count == 0) + { + Snackbar.Add("Keine passenden Schemas gefunden.", Severity.Info); + return; + } + + if (string.IsNullOrWhiteSpace(_editingSite.Schema) || + !_availableSchemas.Contains(_editingSite.Schema, StringComparer.OrdinalIgnoreCase)) + { + _editingSite.Schema = _availableSchemas[0]; + } + + Snackbar.Add($"{_availableSchemas.Count} Schemas geladen.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Schemas laden fehlgeschlagen: {ex.Message}", Severity.Error); + } + finally + { + _loadingSchemas = false; + } + } + private async Task RefreshSapEntitySets() { if (_refreshingSapEntitySets) @@ -993,6 +1132,62 @@ } } + private async Task ValidateManualImportPathAsync() + { + try + { + _editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim(); + + if (string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath)) + throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen."); + + if (!string.Equals(Path.GetExtension(_editingSite.ManualImportFilePath), ".xlsx", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben."); + + if (File.Exists(_editingSite.ManualImportFilePath)) + { + _editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(_editingSite.ManualImportFilePath); + } + else if (LooksLikeSharePointReference(_editingSite.ManualImportFilePath)) + { + using var db = await DbFactory.CreateDbContextAsync(); + var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); + if (spConfig is null || + string.IsNullOrWhiteSpace(spConfig.TenantId) || + string.IsNullOrWhiteSpace(spConfig.ClientId) || + string.IsNullOrWhiteSpace(spConfig.ClientSecret) || + string.IsNullOrWhiteSpace(spConfig.SiteUrl)) + { + throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); + } + + var tempPath = await SharePointService.DownloadToTempFileAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, _editingSite.ManualImportFilePath); + try + { + _editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(tempPath); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + else + { + throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {_editingSite.ManualImportFilePath}"); + } + + Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success); + await AppEventLogService.WriteAsync("ManualImport", "Dateipfad erfolgreich geprueft", siteId: _editingSite.Id, land: _editingSite.Land, details: _editingSite.ManualImportFilePath); + } + catch (Exception ex) + { + Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error); + await AppEventLogService.WriteAsync("ManualImport", "Dateipfadpruefung fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString()); + } + } + private static List ParseSapEntitySets(string json) { if (string.IsNullOrWhiteSpace(json)) @@ -1011,6 +1206,12 @@ private static string SerializeSapEntitySets(List entitySets) => JsonSerializer.Serialize(entitySets); + private static bool LooksLikeSharePointReference(string path) + => path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); + private void AddSapSource() { _sapSources.Add(new SapSourceDefinition diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md new file mode 100644 index 0000000..869760e --- /dev/null +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -0,0 +1,521 @@ +# TrafagSalesExporter LLM System Guide + +Stand: 2026-04-17 + +Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen. + +## Zweck des Systems + +`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt. + +Quellsysteme: + +- `HANA`-basierte Systeme wie `BI1` und `SAGE` +- `SAP_GATEWAY` ueber OData +- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien + +Zielbild: + +- jede Quelle wird in `SalesRecord` normalisiert +- Standortdaten koennen lokal als Excel exportiert werden +- alle Datensaetze werden in `CentralSalesRecords` gespeichert +- eine zentrale konsolidierte Datei wird aus dem zentralen Datenbestand erzeugt +- ein `Management Cockpit` analysiert sowohl exportierte Dateien als auch zentrale Rohdaten + +## Technologie-Stack + +- UI: Blazor Server + MudBlazor +- Datenbank: SQLite (`trafag_exporter.db`) +- Excel lesen/schreiben: ClosedXML +- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll` +- SAP Gateway / OData: eigener Service ueber HTTP +- SharePoint Upload/Download: Microsoft Graph + Azure Identity +- Tests: xUnit + +## Einstiegspunkte + +Wichtige Dateien: + +- [Program.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Program.cs) +- [Data/AppDbContext.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Data/AppDbContext.cs) +- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor) + +`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus. + +## Hauptseiten + +Navigation: + +- `/` Dashboard +- `/standorte` +- `/transformations` +- `/management-cockpit` +- `/settings` +- `/logs` + +Dateien: + +- [Components/Pages/Dashboard.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Dashboard.razor) +- [Components/Pages/Standorte.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Standorte.razor) +- [Components/Pages/Transformations.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Transformations.razor) +- [Components/Pages/ManagementCockpit.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor) +- [Components/Pages/Settings.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Settings.razor) +- [Components/Pages/Logs.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Logs.razor) + +Kurzrollen: + +- `Dashboard`: Einzel-Export, Alle exportieren, zentrale Datei neu erzeugen, Live-Status +- `Standorte`: Standortpflege, zentrale HANA-Technik, SAP-Konfiguration pro Standort, manueller Excel-Import +- `Transformations`: feldweise und record-basierte Regeln +- `Management Cockpit`: Dateianalyse und Rohanalyse aus `CentralSalesRecords` +- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export +- `Logs`: technische Ereignisprotokolle + +## Kernmodelle + +Wichtige Entity-Klassen: + +- [Models/Site.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/Site.cs) +- [Models/SourceSystemDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SourceSystemDefinition.cs) +- [Models/HanaServer.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/HanaServer.cs) +- [Models/SalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SalesRecord.cs) +- [Models/CentralSalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CentralSalesRecord.cs) +- [Models/FieldTransformationRule.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/FieldTransformationRule.cs) +- [Models/SapSourceDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapSourceDefinition.cs) +- [Models/SapJoinDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapJoinDefinition.cs) +- [Models/SapFieldMapping.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapFieldMapping.cs) +- [Models/SharePointConfig.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SharePointConfig.cs) +- [Models/ExportSettings.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportSettings.cs) +- [Models/ExportLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportLog.cs) +- [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) + +Wichtige Relationen: + +- `Site -> HanaServer` optional +- `Site -> SapSourceDefinitions` +- `Site -> SapJoinDefinitions` +- `Site -> SapFieldMappings` +- `Site -> CentralSalesRecords` +- `SourceSystemDefinition` ist zentrale Stammdatenquelle fuer Quellsysteme + +## Datenbanktabellen + +`AppDbContext` enthaelt: + +- `HanaServers` +- `SourceSystemDefinitions` +- `Sites` +- `SharePointConfigs` +- `ExportSettings` +- `ExportLogs` +- `AppEventLogs` +- `FieldTransformationRules` +- `CurrencyExchangeRates` +- `SapSourceDefinitions` +- `SapJoinDefinitions` +- `SapFieldMappings` +- `CentralSalesRecords` + +## Architekturrollen der Services + +### Export / Orchestrierung + +- [Services/ExportOrchestrationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportOrchestrationService.cs) +- [Services/SiteExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SiteExportService.cs) +- [Services/ConsolidatedExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConsolidatedExportService.cs) +- [Services/CentralSalesRecordService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CentralSalesRecordService.cs) +- [Services/ExportLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportLogService.cs) + +Rollen: + +- `ExportOrchestrationService` steuert UI-nahe Exportlaeufe und Live-Status +- `SiteExportService` entscheidet anhand des Quellsystems, wie ein Standort gelesen wird +- `CentralSalesRecordService` ersetzt zentrale Saetze pro Standort +- `ConsolidatedExportService` erzeugt die zentrale Datei + +### Datenquellen + +- [Services/HanaQueryService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/HanaQueryService.cs) +- [Services/SapGatewayService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapGatewayService.cs) +- [Services/SapCompositionService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapCompositionService.cs) +- [Services/ManualExcelImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManualExcelImportService.cs) +- [Services/SharePointUploadService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SharePointUploadService.cs) + +Rollen: + +- `HanaQueryService`: SQL gegen SAP B1/HANA-nahe Schemata +- `SapGatewayService`: OData-Metadaten und Reads +- `SapCompositionService`: Mehrquellen-/Join-/Mapping-Aufbau fuer SAP +- `ManualExcelImportService`: Import im Exportformat aus `.xlsx` +- `SharePointUploadService`: Upload fuer Exportdateien und Download fuer manuelle Excel-Dateien + +### Transformation / Mapping + +- [Services/TransformationCatalog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationCatalog.cs) +- [Services/TransformationStrategies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationStrategies.cs) +- [Services/RecordTransformationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/RecordTransformationService.cs) +- [Services/CurrencyExchangeRateService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CurrencyExchangeRateService.cs) +- [Services/ExchangeRateImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExchangeRateImportService.cs) + +Rollen: + +- `Value`-Transformationen fuer einzelne Felder +- `Record`-Transformationen fuer zeilenweite Regeln +- Wechselkursimport und -umrechnung + +### Reporting / Monitoring / Infrastruktur + +- [Services/ManagementCockpitService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManagementCockpitService.cs) +- [Services/AppEventLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/AppEventLogService.cs) +- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs) +- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs) +- [Services/TimerBackgroundService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TimerBackgroundService.cs) + +## Der wichtigste technische Ablauf + +### 1. Standort-Export + +Pfad: + +`Dashboard/Standorte -> ExportOrchestrationService -> SiteExportService` + +`SiteExportService` unterscheidet drei Modi: + +1. `SAP_GATEWAY` + - SAP-Quellen lesen + - SAP-Joins anwenden + - SAP-Feldmappings auf `SalesRecord` + - Transformationen anwenden + - Standort-Excel erzeugen + - `CentralSalesRecords` ersetzen + - optional SharePoint-Upload + +2. `HANA` + - effektive zentrale HANA-Konfiguration laden + - optionale Standort-Credential-Overrides anwenden + - SQL in HANA ausfuehren + - `SalesRecord` erzeugen + - Transformationen anwenden + - Standort-Excel erzeugen + - `CentralSalesRecords` ersetzen + - optional SharePoint-Upload + +3. `MANUAL_EXCEL` + - `ManualImportFilePath` auswerten + - wenn lokal/UNC vorhanden: lokal lesen + - wenn SharePoint-Referenz: via Graph temp herunterladen + - Excel in `SalesRecord` lesen + - Transformationen anwenden + - keine neue Standortdatei erzeugen, bestehende Excel dient als Eingabe + - `CentralSalesRecords` ersetzen + +### 2. Konsolidierter Export + +Pfad: + +`Dashboard -> ExportOrchestrationService -> ConsolidatedExportService` + +Semantik aktuell: + +- die zentrale Datei basiert fachlich auf `CentralSalesRecords` +- `ExportAllAsync()` sammelt zwar auch `consolidatedRecords`, aber die zentrale Exportsemantik ist historisch noch nicht vollkommen bereinigt + +### 3. Management Cockpit + +Zwei Betriebsarten: + +1. Dateibasiert + - vorhandene `.xlsx` waehlen + - Datei mit ClosedXML lesen + - Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen + +2. Zentraldatenbasiert + - direkt aus `CentralSalesRecords` + - Jahr/Monat Filter + - Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik + +## Quellsystemlogik + +### SourceSystemDefinition + +`SourceSystemDefinition` ist die fuehrende Wahrheit fuer: + +- `Code` +- `DisplayName` +- `ConnectionKind` +- `IsActive` +- `CentralUsername` +- `CentralPassword` +- `CentralServiceUrl` fuer SAP + +Anschlussarten: + +- `HANA` +- `SAP_GATEWAY` +- `MANUAL_EXCEL` + +### HANA + +Fachliche Logik: + +- zentrale technische HANA-Konfiguration pro Quellsystem +- keine separaten Vollverbindungen pro Standort +- Standort speichert nur Fachdaten plus optionale Username-/Password-Overrides + +Schema-Lookup: + +- in `Standorte` gibt es jetzt `Schemas laden` +- Lookup fragt `sys.tables` in HANA ab +- eingeschraenkt auf typische B1-Schemas mit Tabellen wie `OINV`, `INV1`, `ORIN`, `RIN1`, `OCRD`, `OITM` + +### SAP + +Fachliche Logik: + +- zentrale SAP Service URL in `SourceSystemDefinition.CentralServiceUrl` +- Standort kann `SapServiceUrl` als Override pflegen +- pro Standort gibt es SAP-Quellen, Joins und Feldmappings + +### Manual Excel + +Fachliche Logik: + +- `Site.ManualImportFilePath` kann sein: + - lokaler Windows-Pfad + - UNC-Pfad + - SharePoint-URL + - SharePoint-Pfad unterhalb der konfigurierten Site +- Standortdaten werden aus der Excel eingelesen und in `CentralSalesRecords` uebernommen +- SharePoint dient hier als Eingangsquelle, nicht nur als Exportziel + +## Transformationen + +Das System unterscheidet: + +- `Value`-Transformationen +- `Record`-Transformationen + +Beispiele: + +- `Copy` +- `Uppercase` +- `Lowercase` +- `Prefix` +- `Suffix` +- `Replace` +- `Constant` +- `NormalizeCurrencyCode` +- `FirstNonEmpty` +- `ConvertCurrency` + +Technischer Ablauf: + +- Regeln liegen in `FieldTransformationRules` +- `TransformationCatalog` meldet verfuegbare Strategien an die UI +- `RecordTransformationService` wendet record-basierte Strategien an + +## Wechselkurse + +Vorhanden: + +- `CurrencyExchangeRates` +- `ExchangeRateImportService` fuer ECB-Tageskurse +- `NormalizeCurrencyCode` +- `ConvertCurrency` + +Wichtig: + +- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um +- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht + +## SharePoint-Rolle im Gesamtsystem + +`SharePointConfig` enthaelt: + +- `SiteUrl` +- `ExportFolder` +- `CentralExportFolder` +- `TenantId` +- `ClientId` +- `ClientSecret` + +Verwendung: + +- Upload von Standort-Exporten +- Upload der zentralen Datei +- Download von manuellen Excel-Dateien fuer `MANUAL_EXCEL` + +Wichtig: + +- die App arbeitet gegen dieselbe SharePoint-Site, die in `Settings` konfiguriert ist +- fuer `MANUAL_EXCEL` muessen Referenzen auf derselben Site aufloesbar sein + +## Startinitialisierung / Migrationen + +Kritische Datei: + +- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs) + +Aktuelle Rolle: + +- `EnsureCreated` +- Schema-Ergaenzungen per `ALTER TABLE` +- Tabellen-Rebuilds bei Legacy-Schemas +- FK-Reparaturen +- Stammdaten-Seeding +- empfohlene Transformationsregeln + +Bekannte Architekturrealitaet: + +- das ist funktional hilfreich, aber kein sauberes Migrationssystem +- die Startlogik traegt produktive Schema-Reparaturverantwortung +- das ist einer der wichtigsten technischen Risikobloecke + +Bereits gehaertete Fehlerbilder: + +- kaputte FK-Referenzen auf `Sites_old` +- kaputte FK-Referenzen auf `HanaServers_repair_old` +- Legacy-Credential-Spalten in `ExportSettings` +- Legacy-Credential-Spalten in `HanaServers` +- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad + +## Config Import / Export + +Dateien: + +- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs) +- [Models/ConfigTransferPackage.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ConfigTransferPackage.cs) + +Aktueller Stand: + +- JSON Export/Import fuer Konfiguration +- Secrets optional +- `SourceSystemDefinitions` im aktuellen Modell enthalten +- HANA-Technik ohne HANA-Credentials +- Standort-Overrides bleiben erhalten + +Wichtige Punkte: + +- Import laeuft jetzt transaktional +- alte `ConnectionKind`-lose Formate bekommen Fallbacks +- `CentralSalesRecords` werden nicht mehr blind geloescht +- bestehende zentrale Laufzeitdaten werden fuer weiterhin vorhandene Standorte remappt + +## Logging + +Es gibt zwei Log-Ebenen: + +- `ExportLogs` fuer fachliche Exporthistorie +- `AppEventLogs` fuer technische und UI-nahe Ereignisse + +Die `Logs`-Seite liest vor allem `AppEventLogs`. + +## Tests + +Testprojekt: + +- [TrafagSalesExporter.Tests](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/TrafagSalesExporter.Tests) + +Aktuell vorhandene Schwerpunkte: + +- Transformationen +- Record-Transformationen +- TransformationCatalog +- CurrencyExchangeRateService +- ExchangeRateImportService +- ManualExcelImportService +- ManagementCockpitService +- ConfigTransferService +- DatabaseInitializationService + +Wichtig: + +- es gibt aktuell keine echten UI-Komponententests mit `bUnit` +- es gibt keine Browser-E2E-Tests mit `Playwright` +- viele Button-Aktionen sind nur indirekt ueber Services und Persistenz getestet + +## Bekannte offene Architekturfragen + +Fuer andere LLMs wichtig, damit Visualisierungen nicht zu glatt oder zu idealisiert werden: + +1. `DatabaseInitializationService` ist ein produktiver Reparatur-/Migrationslayer, nicht nur Bootstrap. +2. `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel Anwendungslogik. +3. Die Semantik der konsolidierten Datei ist historisch teilweise doppelt angelegt. +4. Das `Management Cockpit` ist noch kein voll generalisierter Reporting-Layer. +5. SharePoint ist sowohl Exportziel als auch bei `MANUAL_EXCEL` mittlerweile moegliche Eingangsquelle. + +## Empfohlene Diagramme fuer andere LLMs + +### 1. Kontextdiagramm + +Zeige: + +- Benutzer +- Blazor App +- SQLite +- SAP HANA +- SAP Gateway +- lokale Dateisystempfade +- SharePoint + +### 2. Komponenten-/Service-Diagramm + +Gruppiere: + +- UI +- Orchestrierung +- Quelladapter +- Transformation +- Persistenz +- Reporting + +### 3. Datenflussdiagramm pro Quelltyp + +Je ein separater Flow fuer: + +- HANA +- SAP Gateway +- Manual Excel lokal +- Manual Excel SharePoint + +### 4. ER-Diagramm + +Fokussiere auf: + +- `SourceSystemDefinition` +- `HanaServer` +- `Site` +- `SapSourceDefinition` +- `SapJoinDefinition` +- `SapFieldMapping` +- `CentralSalesRecord` +- `FieldTransformationRule` + +### 5. Sequenzdiagramm fuer Export + +Wichtige Stationen: + +- Dashboard +- ExportOrchestrationService +- SiteExportService +- spezifischer Quellservice +- Transformation +- CentralSalesRecordService +- Excel/SharePoint +- ExportLog/AppEventLog + +## Prompt-Vorlage fuer ein anderes LLM + +Wenn ein anderes LLM daraus Visualisierungen erzeugen soll, funktioniert diese Anweisung gut: + +> Lies `LLM_SYSTEM_GUIDE.md` als primaeren Systemkontext. Erzeuge daraus ein Architekturdiagramm, ein Datenflussdiagramm fuer HANA/SAP/MANUAL_EXCEL, ein ER-Diagramm der wichtigsten Tabellen und ein Sequenzdiagramm fuer `ExportAsync`. Achte darauf, dass `DatabaseInitializationService` produktive Reparaturlogik enthaelt und dass `MANUAL_EXCEL` sowohl lokal als auch ueber SharePoint gelesen werden kann. + +## Weitere Kontextdateien + +Zusatzkontext fuer Verlauf und Risiken: + +- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md) +- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md) + +Diese beiden Dateien sind wichtig, wenn ein anderes LLM nicht nur Struktur, sondern auch historische Umbauten, Risiken und Prioritaeten verstehen soll. diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index bf0b279..4dd5b25 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -158,19 +158,21 @@ public class ConfigTransferService : IConfigTransferService { var package = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden."); + var importedSourceSystems = ResolveImportedSourceSystems(json, package); using var db = await _dbFactory.CreateDbContextAsync(); + await using var transaction = await db.Database.BeginTransactionAsync(); var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var existingSettings = await db.ExportSettings.FirstOrDefaultAsync(); var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync(); var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync(); var existingSites = await db.Sites.ToListAsync(); + var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync(); var existingRules = await db.FieldTransformationRules.ToListAsync(); var existingSapSources = await db.SapSourceDefinitions.ToListAsync(); var existingSapJoins = await db.SapJoinDefinitions.ToListAsync(); var existingSapMappings = await db.SapFieldMappings.ToListAsync(); - var existingCentralRecords = await db.CentralSalesRecords.ToListAsync(); var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty; var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary( @@ -180,13 +182,15 @@ public class ConfigTransferService : IConfigTransferService var preservedSiteSecrets = existingSites.ToDictionary( x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem), x => (x.UsernameOverride, x.PasswordOverride)); + var existingSiteSignaturesById = existingSites.ToDictionary( + x => x.Id, + x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem)); if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings); if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates); - if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems); @@ -217,10 +221,6 @@ public class ConfigTransferService : IConfigTransferService LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder }); - var importedSourceSystems = package.SourceSystemDefinitions.Count > 0 - ? package.SourceSystemDefinitions - : BuildDefaultSourceSystems(); - foreach (var sourceSystem in importedSourceSystems) { preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved); @@ -272,6 +272,7 @@ public class ConfigTransferService : IConfigTransferService } var siteIdMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var importedSiteIdBySignature = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var site in package.Sites) { preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved); @@ -298,8 +299,52 @@ public class ConfigTransferService : IConfigTransferService db.Sites.Add(entity); await db.SaveChangesAsync(); siteIdMap[site.Key] = entity.Id; + importedSiteIdBySignature[BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem)] = entity.Id; } + var centralRecordsToPreserve = existingCentralRecords + .Where(record => existingSiteSignaturesById.TryGetValue(record.SiteId, out var signature) && importedSiteIdBySignature.ContainsKey(signature)) + .Select(record => + { + var signature = existingSiteSignaturesById[record.SiteId]; + return new CentralSalesRecord + { + StoredAtUtc = record.StoredAtUtc, + SiteId = importedSiteIdBySignature[signature], + SourceSystem = record.SourceSystem, + ExtractionDate = record.ExtractionDate, + Tsc = record.Tsc, + InvoiceNumber = record.InvoiceNumber, + PositionOnInvoice = record.PositionOnInvoice, + Material = record.Material, + Name = record.Name, + ProductGroup = record.ProductGroup, + Quantity = record.Quantity, + SupplierNumber = record.SupplierNumber, + SupplierName = record.SupplierName, + SupplierCountry = record.SupplierCountry, + CustomerNumber = record.CustomerNumber, + CustomerName = record.CustomerName, + CustomerCountry = record.CustomerCountry, + CustomerIndustry = record.CustomerIndustry, + StandardCost = record.StandardCost, + StandardCostCurrency = record.StandardCostCurrency, + PurchaseOrderNumber = record.PurchaseOrderNumber, + SalesPriceValue = record.SalesPriceValue, + SalesCurrency = record.SalesCurrency, + Incoterms2020 = record.Incoterms2020, + SalesResponsibleEmployee = record.SalesResponsibleEmployee, + InvoiceDate = record.InvoiceDate, + OrderDate = record.OrderDate, + Land = record.Land, + DocumentType = record.DocumentType + }; + }) + .ToList(); + + if (centralRecordsToPreserve.Count > 0) + db.CentralSalesRecords.AddRange(centralRecordsToPreserve); + if (package.FieldTransformationRules.Count > 0) { db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule @@ -363,10 +408,53 @@ public class ConfigTransferService : IConfigTransferService } await db.SaveChangesAsync(); + await transaction.CommitAsync(); } + private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem) => $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant(); + private static List ResolveImportedSourceSystems(string json, ConfigTransferPackage package) + { + if (package.SourceSystemDefinitions.Count == 0) + return BuildDefaultSourceSystems(); + + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty(nameof(ConfigTransferPackage.SourceSystemDefinitions), out var sourceSystemsElement) || + sourceSystemsElement.ValueKind != JsonValueKind.Array) + { + return package.SourceSystemDefinitions; + } + + var imported = package.SourceSystemDefinitions + .Select((sourceSystem, index) => + { + var hasExplicitConnectionKind = + index < sourceSystemsElement.GetArrayLength() && + sourceSystemsElement[index].TryGetProperty(nameof(ConfigTransferSourceSystemDefinition.ConnectionKind), out _); + + if (hasExplicitConnectionKind) + return sourceSystem; + + sourceSystem.ConnectionKind = InferLegacyConnectionKind(sourceSystem.Code); + return sourceSystem; + }) + .ToList(); + + return imported; + } + + private static string InferLegacyConnectionKind(string code) + { + if (string.Equals(code, "SAP", StringComparison.OrdinalIgnoreCase)) + return SourceSystemConnectionKinds.SapGateway; + + if (string.Equals(code, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase)) + return SourceSystemConnectionKinds.ManualExcel; + + return SourceSystemConnectionKinds.Hana; + } + private static List BuildDefaultSourceSystems() { return diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs index 2c73a71..545025d 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs @@ -48,7 +48,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService EnsureSitesTableSupportsOptionalHanaServer(db); EnsureExportSettingsTableSupportsCurrentSchema(db); EnsureHanaServersTableSupportsCurrentSchema(db); - RepairBrokenSiteForeignKeys(db); + RepairBrokenForeignKeys(db); AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0"); @@ -166,26 +166,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService using (var create = conn.CreateCommand()) { create.Transaction = transaction; - create.CommandText = @" -CREATE TABLE Sites ( - Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT, - HanaServerId INTEGER NULL, - Schema TEXT NOT NULL, - TSC TEXT NOT NULL, - Land TEXT NOT NULL, - SourceSystem TEXT NOT NULL DEFAULT 'SAP', - UsernameOverride TEXT NOT NULL DEFAULT '', - PasswordOverride TEXT NOT NULL DEFAULT '', - LocalExportFolderOverride TEXT NOT NULL DEFAULT '', - ManualImportFilePath TEXT NOT NULL DEFAULT '', - ManualImportLastUploadedAtUtc TEXT NULL, - SapServiceUrl TEXT NOT NULL DEFAULT '', - SapEntitySet TEXT NOT NULL DEFAULT '', - SapEntitySetsCache TEXT NOT NULL DEFAULT '', - SapEntitySetsRefreshedAtUtc TEXT NULL, - IsActive INTEGER NOT NULL, - CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id) -);"; + create.CommandText = GetSitesCreateSql(); create.ExecuteNonQuery(); } @@ -195,8 +176,9 @@ CREATE TABLE Sites ( copy.CommandText = @" INSERT INTO Sites ( Id, HanaServerId, Schema, TSC, Land, SourceSystem, - UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet, - ManualImportFilePath, ManualImportLastUploadedAtUtc, SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive + UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath, + ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc, IsActive ) SELECT Id, HanaServerId, Schema, TSC, Land, @@ -229,13 +211,13 @@ FROM Sites_old;"; enableFk.ExecuteNonQuery(); } - private static void RepairBrokenSiteForeignKeys(AppDbContext db) + private static void RepairBrokenForeignKeys(AppDbContext db) { var conn = db.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) conn.Open(); - var tablesToRepair = new[] + var siteDependentTables = new[] { ("ExportLogs", GetExportLogsCreateSql()), ("AppEventLogs", GetAppEventLogsCreateSql()), @@ -245,14 +227,17 @@ FROM Sites_old;"; ("SapFieldMappings", GetSapFieldMappingsCreateSql()) }; - foreach (var (tableName, createSql) in tablesToRepair) + foreach (var (tableName, createSql) in siteDependentTables) { - if (TableReferencesSitesOld(conn, tableName)) + if (TableReferences(conn, tableName, "Sites_old")) RebuildTable(conn, tableName, createSql); } + + if (TableReferences(conn, "Sites", "HanaServers_repair_old")) + RebuildTable(conn, "Sites", GetSitesCreateSql()); } - private static bool TableReferencesSitesOld(System.Data.Common.DbConnection connection, string tableName) + private static bool TableReferences(System.Data.Common.DbConnection connection, string tableName, string referencedTableName) { using var command = connection.CreateCommand(); command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;"; @@ -263,7 +248,7 @@ FROM Sites_old;"; command.Parameters.Add(parameter); var sql = command.ExecuteScalar()?.ToString() ?? string.Empty; - return sql.Contains("Sites_old", StringComparison.OrdinalIgnoreCase); + return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase); } private static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql) @@ -383,6 +368,27 @@ CREATE TABLE HanaServers ( AdditionalParams TEXT NOT NULL DEFAULT '' );"; + private static string GetSitesCreateSql() => @" +CREATE TABLE Sites ( + Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT, + HanaServerId INTEGER NULL, + Schema TEXT NOT NULL, + TSC TEXT NOT NULL, + Land TEXT NOT NULL, + SourceSystem TEXT NOT NULL DEFAULT 'SAP', + UsernameOverride TEXT NOT NULL DEFAULT '', + PasswordOverride TEXT NOT NULL DEFAULT '', + LocalExportFolderOverride TEXT NOT NULL DEFAULT '', + ManualImportFilePath TEXT NOT NULL DEFAULT '', + ManualImportLastUploadedAtUtc TEXT NULL, + SapServiceUrl TEXT NOT NULL DEFAULT '', + SapEntitySet TEXT NOT NULL DEFAULT '', + SapEntitySetsCache TEXT NOT NULL DEFAULT '', + SapEntitySetsRefreshedAtUtc TEXT NULL, + IsActive INTEGER NOT NULL, + CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id) +);"; + private static string GetAppEventLogsCreateSql() => @" CREATE TABLE AppEventLogs ( Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, diff --git a/TrafagSalesExporter/Services/ISharePointUploadService.cs b/TrafagSalesExporter/Services/ISharePointUploadService.cs index dafa08b..b6b1731 100644 --- a/TrafagSalesExporter/Services/ISharePointUploadService.cs +++ b/TrafagSalesExporter/Services/ISharePointUploadService.cs @@ -3,5 +3,6 @@ namespace TrafagSalesExporter.Services; public interface ISharePointUploadService { Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); + Task DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference); Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl); } diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs index 1c00038..123cee7 100644 --- a/TrafagSalesExporter/Services/SharePointUploadService.cs +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -43,6 +43,45 @@ public class SharePointUploadService : ISharePointUploadService await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); } + public async Task DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference) + { + var normalizedTenantId = Normalize(tenantId); + var normalizedClientId = Normalize(clientId); + var normalizedClientSecret = Normalize(clientSecret); + var normalizedSiteUrl = Normalize(siteUrl); + var normalizedReference = Normalize(fileReference); + + if (string.IsNullOrWhiteSpace(normalizedReference)) + throw new InvalidOperationException("SharePoint-Dateireferenz fehlt."); + + var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret); + var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + + var siteUri = new Uri(normalizedSiteUrl); + var sitePath = siteUri.AbsolutePath.TrimEnd('/'); + var site = await graphClient.Sites[$"{siteUri.Host}:{sitePath}"].GetAsync(); + + if (site?.Id is null) + throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); + + var drive = await graphClient.Sites[site.Id].Drive.GetAsync(); + if (drive?.Id is null) + throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden."); + + var remotePath = ResolveRemotePath(normalizedReference, siteUri); + var fileName = Path.GetFileName(remotePath); + if (string.IsNullOrWhiteSpace(fileName)) + throw new InvalidOperationException("Aus der SharePoint-Dateireferenz konnte kein Dateiname gelesen werden."); + + await using var contentStream = await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.GetAsync() + ?? throw new InvalidOperationException("SharePoint-Datei konnte nicht gelesen werden."); + + var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}_{fileName}"); + await using var targetStream = File.Create(tempPath); + await contentStream.CopyToAsync(targetStream); + return tempPath; + } + public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) { var normalizedTenantId = Normalize(tenantId); @@ -86,6 +125,24 @@ public class SharePointUploadService : ISharePointUploadService private static string Normalize(string value) => value?.Trim() ?? string.Empty; + private static string ResolveRemotePath(string fileReference, Uri siteUri) + { + if (Uri.TryCreate(fileReference, UriKind.Absolute, out var fileUri)) + { + if (!string.Equals(fileUri.Host, siteUri.Host, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Die SharePoint-Datei muss auf derselben SharePoint-Site liegen wie die zentrale Konfiguration."); + + var sitePath = siteUri.AbsolutePath.TrimEnd('/'); + var absolutePath = Uri.UnescapeDataString(fileUri.AbsolutePath); + if (absolutePath.StartsWith(sitePath, StringComparison.OrdinalIgnoreCase)) + absolutePath = absolutePath[sitePath.Length..]; + + return absolutePath.Trim('/').Trim(); + } + + return fileReference.Trim('/').Trim(); + } + private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl) { var maskedSecret = string.IsNullOrEmpty(clientSecret) diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 586a18a..c68068e 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -110,13 +110,48 @@ public class SiteExportService : ISiteExportService { if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei."); - if (!File.Exists(site.ManualImportFilePath)) - throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {site.ManualImportFilePath}"); + string? tempManualImportPath = null; + try + { + var manualImportPath = site.ManualImportFilePath.Trim(); + if (File.Exists(manualImportPath)) + { + filePath = manualImportPath; + } + else if (LooksLikeSharePointReference(manualImportPath)) + { + if (spConfig is null || + string.IsNullOrWhiteSpace(spConfig.TenantId) || + string.IsNullOrWhiteSpace(spConfig.ClientId) || + string.IsNullOrWhiteSpace(spConfig.ClientSecret) || + string.IsNullOrWhiteSpace(spConfig.SiteUrl)) + { + throw new InvalidOperationException("Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); + } - updateStatus?.Invoke("Manuelle Excel lesen..."); - await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land, - details: site.ManualImportFilePath); - records = await _manualExcelImportService.ReadSalesRecordsAsync(site.ManualImportFilePath, site); + updateStatus?.Invoke("Manuelle Excel von SharePoint laden..."); + await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", siteId: site.Id, land: site.Land, + details: manualImportPath); + tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, manualImportPath); + filePath = manualImportPath; + } + else + { + throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); + } + + var readPath = tempManualImportPath ?? filePath; + updateStatus?.Invoke("Manuelle Excel lesen..."); + await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land, + details: filePath); + records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); + } + finally + { + if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) + File.Delete(tempManualImportPath); + } updateStatus?.Invoke("Transformationen anwenden..."); await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, @@ -127,7 +162,6 @@ public class SiteExportService : ISiteExportService .ToListAsync(); _transformationService.Apply(records, rules); - filePath = site.ManualImportFilePath; log.RowCount = records.Count; } else @@ -272,6 +306,12 @@ public class SiteExportService : ISiteExportService : configured; } + private static bool LooksLikeSharePointReference(string path) + => path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); + private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl) { return new Site diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs index 6484ad6..cd425ef 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs @@ -224,6 +224,96 @@ public class ConfigTransferServiceTests : IDisposable Assert.Equal("FirstNonEmpty", rule.TransformationType); } + [Fact] + public async Task ImportJsonAsync_Preserves_CentralSalesRecords() + { + await SeedExistingSecretsAsync(); + + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.CentralSalesRecords.Add(new CentralSalesRecord + { + StoredAtUtc = new DateTime(2026, 4, 17, 8, 0, 0, DateTimeKind.Utc), + SiteId = 1, + SourceSystem = "MANUAL_EXCEL", + ExtractionDate = new DateTime(2026, 4, 17), + Tsc = "TRCH", + InvoiceNumber = "INV-1", + PositionOnInvoice = 1, + Material = "MAT-1", + Name = "Material 1", + ProductGroup = "PG", + Quantity = 1m, + SupplierNumber = "SUP-1", + SupplierName = "Supplier 1", + SupplierCountry = "CH", + CustomerNumber = "CUS-1", + CustomerName = "Customer 1", + CustomerCountry = "CH", + CustomerIndustry = "Industry", + StandardCost = 10m, + StandardCostCurrency = "CHF", + PurchaseOrderNumber = "PO-1", + SalesPriceValue = 20m, + SalesCurrency = "CHF", + Incoterms2020 = "EXW", + SalesResponsibleEmployee = "Owner", + InvoiceDate = new DateTime(2026, 4, 17), + OrderDate = new DateTime(2026, 4, 16), + Land = "Schweiz", + DocumentType = "Invoice" + }); + await db.SaveChangesAsync(); + } + + var package = new ConfigTransferPackage + { + IncludesSecrets = false, + SourceSystemDefinitions = BuildStandardSourceSystems(), + Sites = + [ + new ConfigTransferSite + { + Key = "site-1", + Schema = "schema_a", + TSC = "TRCH", + Land = "Schweiz", + SourceSystem = "MANUAL_EXCEL", + IsActive = true + } + ] + }; + + await _service.ImportJsonAsync(JsonSerializer.Serialize(package)); + + await using var verifyDb = await _dbFactory.CreateDbContextAsync(); + Assert.Single(verifyDb.CentralSalesRecords); + } + + [Fact] + public async Task ImportJsonAsync_Uses_Legacy_ConnectionKind_Fallbacks() + { + var packageJson = """ +{ + "includesSecrets": false, + "sourceSystemDefinitions": [ + { "code": "SAP", "displayName": "SAP", "isActive": true }, + { "code": "BI1", "displayName": "BI1", "isActive": true }, + { "code": "MANUAL_EXCEL", "displayName": "Manual Excel", "isActive": true } + ] +} +"""; + + await _service.ImportJsonAsync(packageJson); + + await using var db = await _dbFactory.CreateDbContextAsync(); + var systems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); + + Assert.Equal(SourceSystemConnectionKinds.Hana, Assert.Single(systems, x => x.Code == "BI1").ConnectionKind); + Assert.Equal(SourceSystemConnectionKinds.ManualExcel, Assert.Single(systems, x => x.Code == "MANUAL_EXCEL").ConnectionKind); + Assert.Equal(SourceSystemConnectionKinds.SapGateway, Assert.Single(systems, x => x.Code == "SAP").ConnectionKind); + } + private async Task SeedExportConfigurationAsync() { await using var db = await _dbFactory.CreateDbContextAsync(); @@ -381,6 +471,41 @@ public class ConfigTransferServiceTests : IDisposable await db.SaveChangesAsync(); } + private static List BuildStandardSourceSystems() + { + return + [ + new ConfigTransferSourceSystemDefinition + { + Code = "SAP", + DisplayName = "SAP", + ConnectionKind = SourceSystemConnectionKinds.SapGateway, + IsActive = true + }, + new ConfigTransferSourceSystemDefinition + { + Code = "BI1", + DisplayName = "BI1", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true + }, + new ConfigTransferSourceSystemDefinition + { + Code = "SAGE", + DisplayName = "SAGE", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true + }, + new ConfigTransferSourceSystemDefinition + { + Code = "MANUAL_EXCEL", + DisplayName = "Manual Excel", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel, + IsActive = true + } + ]; + } + private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions _options; diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs new file mode 100644 index 0000000..950b889 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs @@ -0,0 +1,196 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class DatabaseInitializationServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + + public DatabaseInitializationServiceTests() + { + _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 InitializeAsync_Migrates_Sites_Without_Shifting_Columns() + { + await PrepareLegacySitesTableAsync(); + + var service = new DatabaseInitializationService(_dbFactory); + await service.InitializeAsync(); + + await using var db = await _dbFactory.CreateDbContextAsync(); + var site = await db.Sites.SingleAsync(); + + Assert.Equal("override-user", site.UsernameOverride); + Assert.Equal("override-password", site.PasswordOverride); + Assert.Equal("C:\\exports\\ch", site.LocalExportFolderOverride); + Assert.Equal("C:\\imports\\manual.xlsx", site.ManualImportFilePath); + Assert.Equal("https://sap.example.local/service", site.SapServiceUrl); + Assert.Equal("A_Sales", site.SapEntitySet); + Assert.Equal("[\"A_Sales\",\"A_Orders\"]", site.SapEntitySetsCache); + Assert.Equal(new DateTime(2026, 4, 17, 7, 30, 0, DateTimeKind.Utc), site.ManualImportLastUploadedAtUtc?.ToUniversalTime()); + Assert.Equal(new DateTime(2026, 4, 17, 8, 0, 0, DateTimeKind.Utc), site.SapEntitySetsRefreshedAtUtc?.ToUniversalTime()); + } + + [Fact] + public async Task InitializeAsync_Repairs_Sites_ForeignKey_To_HanaServersRepairOld() + { + await PrepareBrokenHanaServerForeignKeyAsync(); + + var service = new DatabaseInitializationService(_dbFactory); + await service.InitializeAsync(); + + await using var db = await _dbFactory.CreateDbContextAsync(); + var site = await db.Sites.SingleAsync(); + Assert.Null(await Record.ExceptionAsync(() => db.SaveChangesAsync())); + Assert.Equal("schema_a", site.Schema); + + var tableSql = await ReadTableSqlAsync("Sites"); + Assert.Contains("REFERENCES HanaServers (Id)", tableSql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("HanaServers_repair_old", tableSql, StringComparison.OrdinalIgnoreCase); + } + + private async Task PrepareLegacySitesTableAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + + await db.Database.ExecuteSqlRawAsync("DELETE FROM Sites;"); + await db.Database.ExecuteSqlRawAsync("DELETE FROM HanaServers;"); + await db.Database.ExecuteSqlRawAsync(""" +INSERT INTO HanaServers (Id, SourceSystem, Name, Host, Port, DatabaseName, UseSsl, ValidateCertificate, AdditionalParams) +VALUES (1, 'SAP', 'SAP', 'hana-host', 30015, 'DB1', 0, 0, ''); +"""); + + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;"); + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Sites RENAME TO Sites_current;"); + await db.Database.ExecuteSqlRawAsync(""" +CREATE TABLE Sites ( + Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT, + HanaServerId INTEGER NOT NULL, + Schema TEXT NOT NULL, + TSC TEXT NOT NULL, + Land TEXT NOT NULL, + SourceSystem TEXT NULL, + UsernameOverride TEXT NULL, + PasswordOverride TEXT NULL, + LocalExportFolderOverride TEXT NULL, + ManualImportFilePath TEXT NULL, + ManualImportLastUploadedAtUtc TEXT NULL, + SapServiceUrl TEXT NULL, + SapEntitySet TEXT NULL, + SapEntitySetsCache TEXT NULL, + SapEntitySetsRefreshedAtUtc TEXT NULL, + IsActive INTEGER NOT NULL, + CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id) +); +"""); + await db.Database.ExecuteSqlRawAsync(""" +INSERT INTO Sites ( + Id, HanaServerId, Schema, TSC, Land, SourceSystem, + UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath, + ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc, IsActive +) +VALUES ( + 1, 1, 'schema_a', 'TRCH', 'Schweiz', 'SAP', + 'override-user', 'override-password', 'C:\exports\ch', 'C:\imports\manual.xlsx', + '2026-04-17 07:30:00Z', 'https://sap.example.local/service', 'A_Sales', '["A_Sales","A_Orders"]', + '2026-04-17 08:00:00Z', 1 +); +"""); + await db.Database.ExecuteSqlRawAsync("DROP TABLE Sites_current;"); + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;"); + } + + private async Task PrepareBrokenHanaServerForeignKeyAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + + await db.Database.ExecuteSqlRawAsync("DELETE FROM Sites;"); + await db.Database.ExecuteSqlRawAsync("DELETE FROM HanaServers;"); + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;"); + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Sites RENAME TO Sites_current;"); + await db.Database.ExecuteSqlRawAsync(""" +CREATE TABLE Sites ( + Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT, + HanaServerId INTEGER NULL, + Schema TEXT NOT NULL, + TSC TEXT NOT NULL, + Land TEXT NOT NULL, + SourceSystem TEXT NOT NULL DEFAULT 'SAP', + UsernameOverride TEXT NOT NULL DEFAULT '', + PasswordOverride TEXT NOT NULL DEFAULT '', + LocalExportFolderOverride TEXT NOT NULL DEFAULT '', + ManualImportFilePath TEXT NOT NULL DEFAULT '', + ManualImportLastUploadedAtUtc TEXT NULL, + SapServiceUrl TEXT NOT NULL DEFAULT '', + SapEntitySet TEXT NOT NULL DEFAULT '', + SapEntitySetsCache TEXT NOT NULL DEFAULT '', + SapEntitySetsRefreshedAtUtc TEXT NULL, + IsActive INTEGER NOT NULL, + CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers_repair_old (Id) +); +"""); + await db.Database.ExecuteSqlRawAsync(""" +INSERT INTO Sites ( + Id, HanaServerId, Schema, TSC, Land, SourceSystem, + UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath, + ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc, IsActive +) +VALUES ( + 1, NULL, 'schema_a', 'TRUK', 'England', 'MANUAL_EXCEL', + '', '', '', '', + NULL, '', '', '', + NULL, 1 +); +"""); + await db.Database.ExecuteSqlRawAsync("DROP TABLE Sites_current;"); + await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;"); + } + + private async Task ReadTableSqlAsync(string tableName) + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;"; + command.Parameters.AddWithValue("$tableName", tableName); + return (await command.ExecuteScalarAsync())?.ToString() ?? string.Empty; + } + + 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/docs/architecture.mmd b/TrafagSalesExporter/docs/architecture.mmd new file mode 100644 index 0000000..6377030 --- /dev/null +++ b/TrafagSalesExporter/docs/architecture.mmd @@ -0,0 +1,66 @@ +flowchart TD + User[Benutzer] + UI[Blazor Server UI\nDashboard / Standorte / Settings / Cockpit / Logs] + Orch[ExportOrchestrationService] + SiteExport[SiteExportService] + Consolidated[ConsolidatedExportService] + Hana[HanaQueryService] + SapGateway[SapGatewayService] + SapComposition[SapCompositionService] + ManualExcel[ManualExcelImportService] + Transform[TransformationCatalog + RecordTransformationService] + Central[CentralSalesRecordService] + Cockpit[ManagementCockpitService] + Config[ConfigTransferService] + Init[DatabaseInitializationService] + Timer[TimerBackgroundService] + Logs[AppEventLogService + ExportLogService] + SQLite[(SQLite\ntrafag_exporter.db)] + HANA[(SAP HANA)] + SAP[(SAP Gateway / OData)] + LocalFS[(Lokales Dateisystem)] + SharePoint[(SharePoint)] + + User --> UI + UI --> Orch + UI --> Cockpit + UI --> Config + UI --> Init + + Timer --> Orch + Orch --> SiteExport + Orch --> Consolidated + + SiteExport --> Hana + SiteExport --> SapComposition + SiteExport --> ManualExcel + SiteExport --> Transform + SiteExport --> Central + SiteExport --> Logs + + SapComposition --> SapGateway + Consolidated --> LocalFS + Consolidated --> SharePoint + Cockpit --> SQLite + Cockpit --> LocalFS + Config --> SQLite + Init --> SQLite + Logs --> SQLite + Central --> SQLite + + Hana --> HANA + SapGateway --> SAP + ManualExcel --> LocalFS + ManualExcel --> SharePoint + SiteExport --> LocalFS + SiteExport --> SharePoint + SiteExport --> SQLite + UI --> SQLite + + classDef infra fill:#eef6ff,stroke:#336699,color:#102030; + classDef app fill:#f7f2ff,stroke:#6f42c1,color:#201030; + classDef ext fill:#eefaf0,stroke:#2d7a46,color:#102010; + + class UI,Orch,SiteExport,Consolidated,Hana,SapGateway,SapComposition,ManualExcel,Transform,Central,Cockpit,Config,Init,Timer,Logs app; + class SQLite,LocalFS infra; + class HANA,SAP,SharePoint,User ext; diff --git a/TrafagSalesExporter/docs/dataflow_export.mmd b/TrafagSalesExporter/docs/dataflow_export.mmd new file mode 100644 index 0000000..4d512a4 --- /dev/null +++ b/TrafagSalesExporter/docs/dataflow_export.mmd @@ -0,0 +1,41 @@ +flowchart TD + Start([Export gestartet]) + Decide{ConnectionKind} + + Start --> Decide + + Decide -->|HANA| H1[Zentrale HANA-Konfiguration laden] + H1 --> H2[Optionale Standort-Credentials anwenden] + H2 --> H3[Schema in HANA lesen] + H3 --> H4[SalesRecord-Liste erzeugen] + + Decide -->|SAP_GATEWAY| S1[Zentrale oder Override Service URL aufloesen] + S1 --> S2[SAP Quellen laden] + S2 --> S3[Joins anwenden] + S3 --> S4[Feldmappings auf SalesRecord] + + Decide -->|MANUAL_EXCEL| M1{ManualImportFilePath Typ} + M1 -->|lokal / UNC| M2[Excel lokal lesen] + M1 -->|SharePoint| M3[Excel via Graph temp herunterladen] + M3 --> M4[Excel aus Temp-Datei lesen] + M2 --> M5[SalesRecord-Liste erzeugen] + M4 --> M5 + + H4 --> T[Transformationen anwenden] + S4 --> T + M5 --> T + + T --> C1[CentralSalesRecords fuer Standort ersetzen] + C1 --> E1{Standortdatei erzeugen?} + + E1 -->|ja: HANA / SAP| E2[Excel-Datei lokal erzeugen] + E1 -->|nein: MANUAL_EXCEL| E3[Eingangsdatei bleibt Referenz] + + E2 --> SP{SharePoint konfiguriert?} + E3 --> SP + SP -->|ja| SP1[Datei nach SharePoint hochladen] + SP -->|nein| L1[Kein Upload] + + SP1 --> Log[ExportLog + AppEventLog schreiben] + L1 --> Log + Log --> Done([Export fertig]) diff --git a/TrafagSalesExporter/docs/er_model.mmd b/TrafagSalesExporter/docs/er_model.mmd new file mode 100644 index 0000000..6ff34ed --- /dev/null +++ b/TrafagSalesExporter/docs/er_model.mmd @@ -0,0 +1,184 @@ +erDiagram + HANA_SERVERS ||--o{ SITES : "default for HANA source system" + SITES ||--o{ CENTRAL_SALES_RECORDS : stores + SITES ||--o{ EXPORT_LOGS : writes + SITES ||--o{ APP_EVENT_LOGS : logs + SITES ||--o{ SAP_SOURCE_DEFINITIONS : configures + SITES ||--o{ SAP_JOIN_DEFINITIONS : configures + SITES ||--o{ SAP_FIELD_MAPPINGS : configures + + SOURCE_SYSTEM_DEFINITIONS { + int Id PK + string Code + string DisplayName + string ConnectionKind + bool IsActive + string CentralServiceUrl + string CentralUsername + string CentralPassword + } + + HANA_SERVERS { + int Id PK + string SourceSystem + string Name + string Host + int Port + string DatabaseName + bool UseSsl + bool ValidateCertificate + string AdditionalParams + } + + SITES { + int Id PK + int HanaServerId FK + string Schema + string TSC + string Land + string SourceSystem + string UsernameOverride + string PasswordOverride + string LocalExportFolderOverride + string ManualImportFilePath + datetime ManualImportLastUploadedAtUtc + string SapServiceUrl + string SapEntitySet + string SapEntitySetsCache + datetime SapEntitySetsRefreshedAtUtc + bool IsActive + } + + SHARE_POINT_CONFIGS { + int Id PK + string SiteUrl + string ExportFolder + string CentralExportFolder + string TenantId + string ClientId + string ClientSecret + } + + EXPORT_SETTINGS { + int Id PK + string DateFilter + int TimerHour + int TimerMinute + bool TimerEnabled + bool DebugLoggingEnabled + string LocalSiteExportFolder + string LocalConsolidatedExportFolder + } + + FIELD_TRANSFORMATION_RULES { + int Id PK + string SourceSystem + string SourceField + string TargetField + string TransformationType + string RuleScope + string Argument + int SortOrder + bool IsActive + } + + SAP_SOURCE_DEFINITIONS { + int Id PK + int SiteId FK + string Alias + string EntitySet + bool IsPrimary + bool IsActive + int SortOrder + } + + SAP_JOIN_DEFINITIONS { + int Id PK + int SiteId FK + string LeftAlias + string RightAlias + string LeftKeys + string RightKeys + string JoinType + bool IsActive + int SortOrder + } + + SAP_FIELD_MAPPINGS { + int Id PK + int SiteId FK + string TargetField + string SourceExpression + bool IsRequired + bool IsActive + int SortOrder + } + + CENTRAL_SALES_RECORDS { + int Id PK + datetime StoredAtUtc + int SiteId FK + string SourceSystem + datetime ExtractionDate + string Tsc + string InvoiceNumber + int PositionOnInvoice + string Material + string Name + string ProductGroup + decimal Quantity + string SupplierNumber + string SupplierName + string SupplierCountry + string CustomerNumber + string CustomerName + string CustomerCountry + string CustomerIndustry + decimal StandardCost + string StandardCostCurrency + string PurchaseOrderNumber + decimal SalesPriceValue + string SalesCurrency + string Incoterms2020 + string SalesResponsibleEmployee + datetime InvoiceDate + datetime OrderDate + string Land + string DocumentType + } + + EXPORT_LOGS { + int Id PK + datetime Timestamp + int SiteId FK + string Land + string TSC + string Status + int RowCount + string ErrorMessage + string FileName + string FilePath + double DurationSeconds + } + + APP_EVENT_LOGS { + int Id PK + datetime Timestamp + string Level + string Category + int SiteId FK + string Land + string Message + string Details + } + + CURRENCY_EXCHANGE_RATES { + int Id PK + string FromCurrency + string ToCurrency + decimal Rate + datetime ValidFrom + datetime ValidTo + string Notes + bool IsActive + } diff --git a/TrafagSalesExporter/erg.png b/TrafagSalesExporter/erg.png new file mode 100644 index 0000000..c0dad7c Binary files /dev/null and b/TrafagSalesExporter/erg.png differ diff --git a/TrafagSalesExporter/manometer.png b/TrafagSalesExporter/manometer.png new file mode 100644 index 0000000..7066efe Binary files /dev/null and b/TrafagSalesExporter/manometer.png differ