diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index 800ee7f..ca1cc93 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -19,6 +19,10 @@ OnClick="ExportAll" Disabled="_anyRunning"> Alle exportieren + + Zentrale Datei neu erzeugen + @if (TimerService.NextRun < DateTime.MaxValue) { @@ -109,8 +113,49 @@ + + Zentrale Datei + + + Datei + Pfad + Letzte Änderung + Status + Aktion + + + @context.Label + @context.DisplayPath + @(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") + + @if (Orchestrator.IsConsolidatedExporting()) + { + + @Orchestrator.GetConsolidatedExportStatus() + } + else + { + - + } + + + + Excel öffnen + + + + + Keine zentrale Excel-Datei gefunden. + + + + @code { private List _dashboardRows = new(); + private List _consolidatedRows = new(); private bool _loading = true; private bool _anyRunning; private CancellationTokenSource? _pollingCts; @@ -164,7 +209,9 @@ }; }).ToList(); - _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); + _consolidatedRows = BuildConsolidatedRows(settings: await db.ExportSettings.FirstOrDefaultAsync() ?? new()); + + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); _loading = false; } @@ -185,6 +232,34 @@ Snackbar.Add("Export für alle Standorte gestartet", Severity.Info); } + private async Task ExportConsolidatedOnly() + { + _anyRunning = true; + await LoadDataAsync(); + StartPolling(); + _ = Task.Run(async () => + { + var filePath = await Orchestrator.ExportConsolidatedOnlyAsync(); + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + + if (!string.IsNullOrWhiteSpace(filePath)) + { + await InvokeAsync(() => + Snackbar.Add($"Zentrale Datei erzeugt: {filePath}", Severity.Success)); + } + else + { + await InvokeAsync(() => + Snackbar.Add("Zentrale Datei konnte nicht erzeugt werden.", Severity.Warning)); + } + }); + Snackbar.Add("Zentrale Datei wird erzeugt", Severity.Info); + } + private void ExportSingle(int siteId) { _anyRunning = true; @@ -217,7 +292,7 @@ { await InvokeAsync(async () => { - _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || _dashboardRows.Count == 0; + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0; if (_anyRunning) { StartPolling(); @@ -240,7 +315,12 @@ private void OpenExportFile(DashboardRow row) { - if (string.IsNullOrWhiteSpace(row.FilePath) || !File.Exists(row.FilePath)) + OpenFile(row.FilePath); + } + + private void OpenFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) { Snackbar.Add("Exportdatei nicht gefunden.", Severity.Warning); return; @@ -250,7 +330,7 @@ { Process.Start(new ProcessStartInfo { - FileName = row.FilePath, + FileName = filePath, UseShellExecute = true }); } @@ -284,7 +364,7 @@ { while (await timer.WaitForNextTickAsync(cancellationToken)) { - var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); + var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); if (!anyRunning) { await InvokeAsync(async () => @@ -321,10 +401,41 @@ row.LiveDetails = string.Empty; } - _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); return Task.CompletedTask; } + private static List BuildConsolidatedRows(ExportSettings settings) + { + var outputDirectory = ResolveConsolidatedOutputDirectory(settings); + if (!Directory.Exists(outputDirectory)) + return []; + + return Directory.GetFiles(outputDirectory, "Sales_All_*.xlsx") + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTime) + .Take(1) + .Select(file => new ConsolidatedDashboardRow + { + Label = "Konsolidierter Export", + FilePath = file.FullName, + DisplayPath = file.FullName, + LastModified = file.LastWriteTime + }) + .ToList(); + } + + private static string ResolveConsolidatedOutputDirectory(ExportSettings settings) + { + if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder)) + return settings.LocalConsolidatedExportFolder.Trim(); + + if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder)) + return settings.LocalSiteExportFolder.Trim(); + + return Path.Combine(AppContext.BaseDirectory, "output"); + } + private class DashboardRow { public int SiteId { get; set; } @@ -342,4 +453,13 @@ public string LiveDetails { get; set; } = ""; public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath); } + + private class ConsolidatedDashboardRow + { + public string Label { get; set; } = ""; + public string FilePath { get; set; } = ""; + public string DisplayPath { get; set; } = ""; + public DateTime? LastModified { get; set; } + public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath); + } } diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index e75b0fc..95a91b9 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -1,4 +1,5 @@ @page "/standorte" +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore @using System.Text.Json @using System.Reflection @@ -341,6 +342,31 @@ } + else if (IsManualExcelSite()) + { + Manueller Excel-Import + + Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. + + + @if (_uploadingManualImport) + { + Datei wird hochgeladen... + } + @if (!string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath)) + { + + Datei: @_editingSite.ManualImportFilePath + + Letzter Upload: @(_editingSite.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-") + + + } + else + { + Noch keine Datei hinterlegt. + } + } else { HANA-Verbindung @@ -365,13 +391,13 @@ } - Abbrechen - Speichern + Abbrechen + Speichern @code { - private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"]; + private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"]; private readonly Dictionary _connectionStatus = new(); private List _servers = new(); private List _sites = new(); @@ -394,6 +420,7 @@ private bool _refreshingSapSourceFields; private bool _savingServer; private bool _savingSite; + private bool _uploadingManualImport; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override async Task OnInitializedAsync() @@ -514,7 +541,8 @@ { IsActive = true, SourceSystem = "SAP", - HanaServerId = null + HanaServerId = null, + ManualImportFilePath = string.Empty }; _sapEntitySetsCache = []; _sapAvailableSourceExpressions = []; @@ -539,6 +567,8 @@ UsernameOverride = site.UsernameOverride, PasswordOverride = site.PasswordOverride, LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, SapServiceUrl = site.SapServiceUrl, SapEntitySet = site.SapEntitySet, SapEntitySetsCache = site.SapEntitySetsCache, @@ -567,7 +597,7 @@ try { using var db = await DbFactory.CreateDbContextAsync(); - var serverId = IsSapSite() ? (int?)null : await SaveOrCreateSiteServerAsync(db); + var serverId = UsesHanaConnection() ? await SaveOrCreateSiteServerAsync(db) : (int?)null; _editingSite.HanaServerId = serverId; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache); @@ -588,6 +618,8 @@ existing.UsernameOverride = _editingSite.UsernameOverride; existing.PasswordOverride = _editingSite.PasswordOverride; existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride; + existing.ManualImportFilePath = _editingSite.ManualImportFilePath; + existing.ManualImportLastUploadedAtUtc = _editingSite.ManualImportLastUploadedAtUtc; existing.SapServiceUrl = _editingSite.SapServiceUrl; existing.SapEntitySet = _editingSite.SapEntitySet; existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache; @@ -654,6 +686,8 @@ var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem; if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase)) return string.IsNullOrWhiteSpace(site.SapServiceUrl) ? "-" : site.SapServiceUrl; + if (string.Equals(sourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(site.ManualImportFilePath) ? "-" : Path.GetFileName(site.ManualImportFilePath); return GetServerNode(site.HanaServer); } @@ -696,6 +730,7 @@ _editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim(); _editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim(); _editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim(); + _editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim(); _editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim(); _editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim(); _editingSiteServer.Host = _editingSiteServer.Host.Trim(); @@ -745,6 +780,8 @@ } private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase); + private bool IsManualExcelSite() => string.Equals(_editingSite.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase); + private bool UsesHanaConnection() => !IsSapSite() && !IsManualExcelSite(); private async Task RefreshSapEntitySets() { @@ -804,12 +841,62 @@ private void CloseSiteDialog() { - if (_savingSite || _refreshingSapEntitySets) + if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport) return; _siteDialogVisible = false; } + private async Task UploadManualImportFileAsync(InputFileChangeEventArgs args) + { + if (_uploadingManualImport) + return; + + var file = args.File; + if (file is null) + return; + + _uploadingManualImport = true; + try + { + var extension = Path.GetExtension(file.Name); + if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen."); + } + + var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); + Directory.CreateDirectory(uploadDirectory); + + var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name).Select(ch => + char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_')); + if (string.IsNullOrWhiteSpace(safeBaseName)) + safeBaseName = "manual_import"; + + var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}"); + + await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024)) + await using (var targetStream = File.Create(targetPath)) + { + await sourceStream.CopyToAsync(targetStream); + } + + _editingSite.ManualImportFilePath = targetPath; + _editingSite.ManualImportLastUploadedAtUtc = DateTime.UtcNow; + Snackbar.Add("Excel-Datei hochgeladen.", Severity.Success); + await AppEventLogService.WriteAsync("ManualImport", "Excel-Datei hochgeladen", siteId: _editingSite.Id, land: _editingSite.Land, details: targetPath); + } + catch (Exception ex) + { + Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error); + await AppEventLogService.WriteAsync("ManualImport", "Excel-Upload fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString()); + } + finally + { + _uploadingManualImport = false; + } + } + private static List ParseSapEntitySets(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index da30fd3..414568f 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -7,34 +7,34 @@ Stand: 2026-04-15 Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert: - `BI1` und `SAGE` bleiben auf direktem HANA-Zugriff -- `SAP` läuft separat über SAP Gateway / OData -- SAP-Quellen können gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden +- `SAP` laeuft separat ueber SAP Gateway / OData +- SAP-Quellen koennen gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden - Standort-Exporte werden lokal als Excel geschrieben -- Zusätzlich werden Datensätze in eine zentrale SQLite-Tabelle geschrieben -- Ein konsolidierter Export liest aus dieser zentralen Tabelle +- zusaetzlich werden Datensaetze in eine zentrale SQLite-Tabelle geschrieben +- ein konsolidierter Export liest aus dieser zentralen Tabelle ## Wichtigste umgesetzte Funktionen ### 1. Zentrale Credentials pro Quellsystem -Es gibt zentrale Zugangsdaten in `ExportSettings` für: +Es gibt zentrale Zugangsdaten in `ExportSettings` fuer: - `SAP` - `BI1` - `SAGE` -Zusätzlich gibt es pro Standort optionale Overrides: +Zusaetzlich gibt es pro Standort optionale Overrides: - `UsernameOverride` - `PasswordOverride` -Auflösungsreihenfolge: +Aufloesungsreihenfolge: 1. Standort-Override 2. zentrale Credentials des Quellsystems -3. bei HANA zusätzlich Fallback auf alten `HanaServer.Username/Password` +3. bei HANA zusaetzlich Fallback auf alten `HanaServer.Username/Password` -## 2. SAP von BI1/HANA getrennt +### 2. SAP von BI1/HANA getrennt `SAP` nutzt nicht mehr den HANA-Pfad, sondern eine eigene Gateway/OData-Strecke. @@ -56,21 +56,21 @@ http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/ Wichtig: - Service URL immer nur bis zum Service -- Entity Set separat auswählen +- Entity Set separat auswaehlen -## 3. SAP-Quellen, Joins und Feldmappings +### 3. SAP-Quellen, Joins und Feldmappings -Für SAP gibt es mehrere neue Modelle: +Fuer SAP gibt es mehrere neue Modelle: - `SapSourceDefinition` - `SapJoinDefinition` - `SapFieldMapping` -Unterstützt wird: +Unterstuetzt wird: - mehrere SAP-Quellen pro Standort - Alias pro Quelle -- Primärquelle +- Primaerquelle - Join-Definitionen - Mapping von `Alias.Feldname` auf zentrales Schema @@ -79,9 +79,9 @@ UI-Erweiterungen: - `Quellen refreshen` - `Felder aus Quellen laden` - Join-Key-Auswahl aus Metadaten -- `Auto-Match` für gleiche Feldnamen zwischen Primärquelle und anderen Quellen +- `Auto-Match` fuer gleiche Feldnamen zwischen Primaerquelle und anderen Quellen -## 4. Zentrale Datenspeicherung +### 4. Zentrale Datenspeicherung Neue Tabelle: @@ -89,7 +89,7 @@ Neue Tabelle: Verwendung: -- pro Standort werden alte zentrale Sätze dieses Standorts ersetzt +- pro Standort werden alte zentrale Saetze dieses Standorts ersetzt - konsolidierte Excel liest aus `CentralSalesRecords` Wichtig: @@ -97,9 +97,9 @@ Wichtig: - zentrale Excel wird nicht appendet - sie wird aus dem aktuellen Zustand der zentralen Tabelle neu erstellt -## 5. Exportpfade +### 5. Exportpfade -Neue Konfigurationsmöglichkeiten: +Neue Konfigurationsmoeglichkeiten: Zentral in `Settings`: @@ -118,16 +118,16 @@ Fallback wenn leer: relativ zum App-Verzeichnis. -## 6. SharePoint +### 6. SharePoint SharePoint-Upload ist optional. -Wenn keine vollständige SharePoint-Konfiguration vorhanden ist: +Wenn keine vollstaendige SharePoint-Konfiguration vorhanden ist: - Excel wird trotzdem lokal erzeugt - kein Upload nach SharePoint -Benötigte SharePoint-Werte: +Benoetigte SharePoint-Werte: - `Tenant ID` - `Client ID` @@ -135,7 +135,7 @@ Benötigte SharePoint-Werte: Das sind Entra App Registration Werte, nicht normale Benutzer-Credentials. -## 7. Config Import/Export +### 7. Config Import/Export Es gibt JSON-Import/Export der Konfiguration mit Checkbox: @@ -153,9 +153,9 @@ Enthalten sind u. a.: - SAP-Joins - SAP-Mappings -## 8. Logging und Live-Status +### 8. Logging und Live-Status -Neue technische Logs über `AppEventLogs`. +Neue technische Logs ueber `AppEventLogs`. Sichtbar: @@ -172,11 +172,11 @@ Geloggt werden u. a.: - zentrale Tabellenspeicherung - Export erfolgreich / fehlgeschlagen -## 9. Excel öffnen +### 9. Excel oeffnen Im Dashboard gibt es neben `Export` den Button: -- `Excel öffnen` +- `Excel oeffnen` Dieser nutzt `ExportLogs.FilePath`. @@ -186,9 +186,9 @@ Voraussetzungen: - `FilePath` gespeichert - Datei existiert lokal -## 10. Management Cockpit +### 10. Management Cockpit -Es gibt einen neuen Menüpunkt: +Es gibt einen neuen Menuepunkt: - `Management Cockpit` @@ -196,19 +196,19 @@ Funktion: - Auswahl vorhandener Excel-Dateien - Analyse einer exportierten Standort-Datei -- Kennzahlen für Geschäftsinhaber / Management +- Kennzahlen fuer Geschaeftsinhaber / Management Aktuell enthalten: - Umsatz -- geschätzte Kosten -- geschätzte Marge +- geschaetzte Kosten +- geschaetzte Marge - Rechnungsanzahl - Kundenanzahl - Top Kunden - Top Produktgruppen - Top Sales Owner -- Datenqualitätshinweise +- Datenqualitaetshinweise - automatische Management-Aussagen ## Wichtige Dateien @@ -249,7 +249,7 @@ Aktuell enthalten: ## Datenbank / Migrationen -Viele Änderungen laufen über `DatabaseInitializationService`. +Viele Aenderungen laufen ueber `DatabaseInitializationService`. Wichtige neue oder erweiterte Tabellen/Felder: @@ -273,52 +273,46 @@ Wichtige neue oder erweiterte Tabellen/Felder: - `CentralSalesRecords` - SAP-Konfigtabellen -## Aktuell offenes Hauptproblem +## Letztes Hauptproblem und Loesung -### Zentrale Speicherung hängt noch +### Export hing nach zentraler Speicherung -Die große Problemstelle war die zentrale SQLite-Speicherung. +Der Export blieb zuletzt nach -Bereits probiert: +- `Zentrale Tabelle: 20106 Datensaetze gespeichert.` -- EF `RemoveRange + SaveChanges` -- EF Batch-Speichern -- Dashboard-Polling reduziert -- SQLite WAL + busy timeout -- direkte SQLite-Inserts in einer großen Transaktion -- jetzt: kleine abgeschlossene Transaktionen pro Batch +haengen. -Aktueller Stand: +Die eigentliche Ursache war am Ende nicht mehr der Batch-Insert selbst, sondern ein kaputter SQLite-Schemazustand: -- zentrale Excel ist jetzt sehr schnell -- das Hängen wurde stark eingegrenzt -- zuletzt wurde der Schreibpfad so umgebaut, dass: - - Löschen in eigener kurzer Transaktion läuft - - Inserts batchweise mit Commit pro Batch laufen +- mindestens eine Tabelle referenzierte per FK noch `main.Sites_old` +- dadurch scheiterte `SaveChangesAsync()` spaeter beim Schreiben in `AppEventLogs` oder `ExportLogs` +- die alte Tabelle `Sites_old` existierte nicht mehr -Datei: +Beobachteter Fehler: +- `SQLite Error 1: 'no such table: main.Sites_old'` + +## Umgesetzte Korrekturen + +- `Components/Pages/Dashboard.razor` + - Live-Status pollt waehrend laufendem Export nicht mehr permanent `AppEventLogs` + - stattdessen Anzeige ueber den In-Memory-Status aus `ExportOrchestrationService` +- `Program.cs` + - SQLite `Default Timeout` von `10` auf `60` erhoeht - `Services/CentralSalesRecordService.cs` + - nach abgeschlossenem Batch-Insert wird explizit `Zentrale Tabelle aktualisiert` gesetzt +- `Services/DatabaseInitializationService.cs` + - automatische Reparaturlogik fuer Tabellen, deren `CREATE TABLE`-SQL noch `Sites_old` referenziert + - betroffene Tabellen werden beim Start neu aufgebaut und Daten rueberkopiert -Die nächste Session sollte genau dort weiter debuggen, falls es noch hängt. +Danach wurde der Export erfolgreich getestet und geht jetzt wieder durch. -Wichtig: +## Was bei einer naechsten Stoerung zuerst zu pruefen ist -- Das Problem ist nicht SAP -- nicht SharePoint -- nicht mehr der große EF-Insert -- sondern sehr wahrscheinlich SQLite-Commit/Lock-Verhalten rund um die zentrale Tabelle - -## Letzte bekannte Beobachtung - -Der User meldete zuletzt: - -- vorher Hänger bei `Zentrale Tabelle: Abschluss speichern...` -- danach wurde auf Commit pro Batch umgestellt -- neue Session soll testen, ob es jetzt bei - - `Batch x/y speichern...` - - `Batch x/y abschliessen...` - - oder gar nicht mehr hängt +1. Tritt beim App-Start die Schema-Reparatur sauber durch? +2. Gibt es noch weitere Tabellen mit FK-Referenz auf `Sites_old`? +3. Erst danach wieder Insert-/Commit-Batches der zentralen Speicherung untersuchen ## Build-Status @@ -334,14 +328,3 @@ Ergebnis: - bekannte Warnungen bleiben: - SAP HANA Architekturwarnung `MSB3270` - MudBlazor Analyzer `Dense` - -## Hinweise für nächste Session - -1. Zuerst aktuellen Export testen -2. Genaue letzte Live-Status-Meldung notieren -3. `Services/CentralSalesRecordService.cs` prüfen -4. Falls nötig: - - SQLite pragmas weiter anpassen - - zentrale Tabelle temporär ganz abschaltbar machen - - oder Schreiben über separate DB / Queue entkoppeln - diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs index cf0600c..b90feb2 100644 --- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -66,6 +66,8 @@ public class ConfigTransferSite public string? UsernameOverride { get; set; } public string? PasswordOverride { get; set; } public string LocalExportFolderOverride { get; set; } = string.Empty; + public string ManualImportFilePath { get; set; } = string.Empty; + public DateTime? ManualImportLastUploadedAtUtc { get; set; } public string SapServiceUrl { get; set; } = string.Empty; public string SapEntitySet { get; set; } = string.Empty; public string SapEntitySetsCache { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs index 20d3be0..68a49f5 100644 --- a/TrafagSalesExporter/Models/Site.cs +++ b/TrafagSalesExporter/Models/Site.cs @@ -28,6 +28,8 @@ public class Site public string PasswordOverride { get; set; } = string.Empty; public string LocalExportFolderOverride { get; set; } = string.Empty; + public string ManualImportFilePath { get; set; } = string.Empty; + public DateTime? ManualImportLastUploadedAtUtc { get; set; } public string SapServiceUrl { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 3572d96..702fc72 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,63 +2,51 @@ Stand: 2026-04-15 -## 1. Erstes Ziel +## 1. Status -Prüfen, ob die aktuelle Version beim Standort-Export noch in der zentralen SQLite-Speicherung hängen bleibt. +Der Export geht jetzt wieder durch. -Wichtig: +Die zuletzt gefundene Hauptursache war nicht mehr ein reiner SQLite-Lock beim Batch-Insert, sondern ein kaputter FK-Schemazustand in der bestehenden DB: -- App neu starten -- denselben Standort erneut exportieren -- letzte sichtbare `Live-Status`-Meldung exakt notieren +- SQLite referenzierte in mindestens einer Tabelle noch `main.Sites_old` +- dadurch scheiterte `SaveChangesAsync()` beim Schreiben z. B. in `AppEventLogs` oder `ExportLogs` +- sichtbarer Effekt: Export blieb nach `Zentrale Tabelle: ... Datensaetze gespeichert.` haengen -Interessant sind vor allem diese Fälle: +## 2. Umgesetzter Fix -- `Zentrale Tabelle: Batch x/y speichern...` -- `Zentrale Tabelle: Batch x/y abschliessen...` -- `Zentrale Tabelle aktualisiert` -- `Export erfolgreich` +Umgesetzt wurde: -## 2. Hauptverdächtiger +- Dashboard-Live-Status liest waehrend laufendem Export nicht mehr staendig aus `AppEventLogs`, sondern nutzt den In-Memory-Status des `ExportOrchestrationService` +- SQLite `Default Timeout` in `Program.cs` auf `60` erhoeht +- `CentralSalesRecordService` setzt nach den Batches explizit `Zentrale Tabelle aktualisiert` +- `DatabaseInitializationService` repariert beim App-Start automatisch Tabellen, deren FK-SQL noch `Sites_old` referenziert -Datei: - -- `Services/CentralSalesRecordService.cs` - -Aktueller Stand: - -- alte Sätze werden in eigener Transaktion gelöscht -- Inserts laufen in Batches von 25 -- jeder Batch wird separat committed - -Wenn es noch hängt, dort zuerst ansetzen. - -## 3. Falls es weiter hängt - -In dieser Reihenfolge prüfen: - -1. Batchgröße weiter reduzieren - - z. B. `10` statt `25` -2. Direkt vor und direkt nach `transaction.CommitAsync()` zusätzlich technische Logs setzen -3. Prüfen, ob parallel noch andere SQLite-Zugriffe laufen -4. Optional zentrale Speicherung vorübergehend per Setting deaktivierbar machen -5. Falls nötig zentrale Speicherung in separate DB-Datei auslagern - -## 4. Dashboard / UI prüfen - -Zu testen: - -- `Excel öffnen` wird nach neuem erfolgreichen Export aktiv -- `Export erfolgreich` zeigt `Pfad=...` -- Dashboard-Live-Status setzt sich nach Abschluss sauber zurück - -Dateien: +Betroffene Dateien: +- `Program.cs` - `Components/Pages/Dashboard.razor` -- `Services/SiteExportService.cs` -- `Models/ExportLog.cs` +- `Services/CentralSalesRecordService.cs` +- `Services/DatabaseInitializationService.cs` -## 5. SAP-Funktionalität kurz gegenprüfen +## 3. Was noch getestet werden sollte + +Kurz gegenpruefen: + +- Export eines Standorts erneut +- `Excel oeffnen` nach erfolgreichem Export +- `Export erfolgreich` inkl. `Pfad=...` +- Dashboard-Live-Status setzt sich nach Abschluss sauber zurueck + +## 4. Falls wieder ein Fehler auftritt + +In dieser Reihenfolge pruefen: + +1. Exakte Fehlermeldung aus `AppEventLogs` bzw. Console notieren +2. Pruefen, ob die Reparaturlogik beim Start gelaufen ist +3. Pruefen, ob noch weitere Tabellen mit veralteter FK-Referenz existieren +4. Erst danach wieder am Batch-/Commit-Pfad der zentralen Speicherung arbeiten + +## 5. SAP-Funktionalitaet kurz gegenpruefen Zu testen: @@ -73,12 +61,12 @@ Dateien: - `Services/SapGatewayService.cs` - `Services/SapCompositionService.cs` -## 6. Management Cockpit prüfen +## 6. Management Cockpit pruefen Zu testen: -- vorhandene Excel-Datei auswählbar -- Analyse läuft +- vorhandene Excel-Datei auswaehlbar +- Analyse laeuft - Kennzahlen plausibel Dateien: @@ -86,17 +74,8 @@ Dateien: - `Components/Pages/ManagementCockpit.razor` - `Services/ManagementCockpitService.cs` -## 7. Wenn Stabilität vor Funktion geht +## 7. Referenzdatei -Sinnvolle pragmatische Zwischenlösung: - -- zentrale SQLite-Speicherung per Setting abschaltbar machen -- Export lokal und zentral Excel weiter erlauben -- zentrale DB erst wieder aktivieren, wenn der Commit-Pfad stabil ist - -## 8. Referenzdatei - -Für den vollständigen Kontext zuerst lesen: +Fuer den vollstaendigen Kontext zuerst lesen: - `HANDOFF_2026-04-15.md` - diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 7496499..bebbb07 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -28,6 +28,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index bd3da95..5bc6f11 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -81,6 +81,8 @@ public class ConfigTransferService : IConfigTransferService UsernameOverride = includeSecrets ? site.UsernameOverride : null, PasswordOverride = includeSecrets ? site.PasswordOverride : null, LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, SapServiceUrl = site.SapServiceUrl, SapEntitySet = site.SapEntitySet, SapEntitySetsCache = site.SapEntitySetsCache, @@ -242,6 +244,8 @@ public class ConfigTransferService : IConfigTransferService UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty, PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty, LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, SapServiceUrl = site.SapServiceUrl, SapEntitySet = site.SapEntitySet, SapEntitySetsCache = site.SapEntitySetsCache, diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs index 40e1e53..a00e415 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs @@ -54,6 +54,8 @@ public class DatabaseInitializationService : IDatabaseInitializationService AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "TEXT NOT NULL DEFAULT ''"); + AddColumnIfMissing(db, "Sites", "ManualImportFilePath", "TEXT NOT NULL DEFAULT ''"); + AddColumnIfMissing(db, "Sites", "ManualImportLastUploadedAtUtc", "TEXT NULL"); AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''"); @@ -128,6 +130,8 @@ CREATE TABLE Sites ( 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 '', @@ -145,7 +149,7 @@ CREATE TABLE Sites ( INSERT INTO Sites ( Id, HanaServerId, Schema, TSC, Land, SourceSystem, UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet, - SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive + ManualImportFilePath, ManualImportLastUploadedAtUtc, SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive ) SELECT Id, HanaServerId, Schema, TSC, Land, @@ -153,6 +157,8 @@ SELECT COALESCE(UsernameOverride, ''), COALESCE(PasswordOverride, ''), COALESCE(LocalExportFolderOverride, ''), + COALESCE(ManualImportFilePath, ''), + ManualImportLastUploadedAtUtc, COALESCE(SapServiceUrl, ''), COALESCE(SapEntitySet, ''), COALESCE(SapEntitySetsCache, ''), diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs index 994d1ea..9b21c30 100644 --- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -14,6 +14,8 @@ public class ExportOrchestrationService public event Action? OnExportStatusChanged; private readonly Dictionary _runningExports = new(); + private bool _consolidatedExportRunning; + private string _consolidatedExportStatus = string.Empty; private readonly object _lock = new(); public ExportOrchestrationService( @@ -44,6 +46,22 @@ public class ExportOrchestrationService } } + public bool IsConsolidatedExporting() + { + lock (_lock) + { + return _consolidatedExportRunning; + } + } + + public string GetConsolidatedExportStatus() + { + lock (_lock) + { + return _consolidatedExportStatus; + } + } + public async Task ExportAllAsync() { using var db = await _dbFactory.CreateDbContextAsync(); @@ -57,7 +75,12 @@ public class ExportOrchestrationService consolidatedRecords.AddRange(result.Records); } - await _consolidatedExportService.ExportAsync(consolidatedRecords); + await RunConsolidatedExportAsync(consolidatedRecords); + } + + public async Task ExportConsolidatedOnlyAsync() + { + return await RunConsolidatedExportAsync(null); } public async Task ExportSiteByIdAsync(int siteId) @@ -112,4 +135,31 @@ public class ExportOrchestrationService { OnExportStatusChanged?.Invoke(); } + + private async Task RunConsolidatedExportAsync(List? records) + { + lock (_lock) + { + if (_consolidatedExportRunning) + return null; + + _consolidatedExportRunning = true; + _consolidatedExportStatus = "Zentrale Datei erzeugen..."; + } + NotifyChanged(); + + try + { + return await _consolidatedExportService.ExportAsync(records ?? []); + } + finally + { + lock (_lock) + { + _consolidatedExportRunning = false; + _consolidatedExportStatus = string.Empty; + } + NotifyChanged(); + } + } } diff --git a/TrafagSalesExporter/Services/IManualExcelImportService.cs b/TrafagSalesExporter/Services/IManualExcelImportService.cs new file mode 100644 index 0000000..9fbb6f6 --- /dev/null +++ b/TrafagSalesExporter/Services/IManualExcelImportService.cs @@ -0,0 +1,8 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IManualExcelImportService +{ + Task> ReadSalesRecordsAsync(string filePath, Site site); +} diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs new file mode 100644 index 0000000..cb28db5 --- /dev/null +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -0,0 +1,187 @@ +using System.Globalization; +using ClosedXML.Excel; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class ManualExcelImportService : IManualExcelImportService +{ + private static readonly Dictionary HeaderMap = new(StringComparer.OrdinalIgnoreCase) + { + ["extractiondate"] = nameof(SalesRecord.ExtractionDate), + ["tsc"] = nameof(SalesRecord.Tsc), + ["invoicenumber"] = nameof(SalesRecord.InvoiceNumber), + ["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice), + ["material"] = nameof(SalesRecord.Material), + ["name"] = nameof(SalesRecord.Name), + ["productgroup"] = nameof(SalesRecord.ProductGroup), + ["quantity"] = nameof(SalesRecord.Quantity), + ["suppliernumber"] = nameof(SalesRecord.SupplierNumber), + ["suppliername"] = nameof(SalesRecord.SupplierName), + ["suppliercountry"] = nameof(SalesRecord.SupplierCountry), + ["customernumber"] = nameof(SalesRecord.CustomerNumber), + ["customername"] = nameof(SalesRecord.CustomerName), + ["customercountry"] = nameof(SalesRecord.CustomerCountry), + ["customerindustry"] = nameof(SalesRecord.CustomerIndustry), + ["standardcost"] = nameof(SalesRecord.StandardCost), + ["standardcostcurrency"] = nameof(SalesRecord.StandardCostCurrency), + ["purchaseordernumber"] = nameof(SalesRecord.PurchaseOrderNumber), + ["salespricevalue"] = nameof(SalesRecord.SalesPriceValue), + ["salescurrency"] = nameof(SalesRecord.SalesCurrency), + ["incoterms2020"] = nameof(SalesRecord.Incoterms2020), + ["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee), + ["invoicedate"] = nameof(SalesRecord.InvoiceDate), + ["orderdate"] = nameof(SalesRecord.OrderDate), + ["land"] = nameof(SalesRecord.Land), + ["documenttype"] = nameof(SalesRecord.DocumentType) + }; + + public Task> ReadSalesRecordsAsync(string filePath, Site site) + { + using var workbook = new XLWorkbook(filePath); + var worksheet = workbook.Worksheets.FirstOrDefault() + ?? throw new InvalidOperationException("Die Excel-Datei enthält kein Arbeitsblatt."); + var usedRange = worksheet.RangeUsed() + ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten."); + + var headerRow = usedRange.FirstRow(); + var headerIndexes = BuildHeaderIndexMap(headerRow); + var rows = new List(); + + foreach (var row in usedRange.RowsUsed().Skip(1)) + { + if (IsRowEmpty(row)) + continue; + + rows.Add(new SalesRecord + { + ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, + Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC), + InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)), + PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))), + Material = ReadString(headerIndexes, row, nameof(SalesRecord.Material)), + Name = ReadString(headerIndexes, row, nameof(SalesRecord.Name)), + ProductGroup = ReadString(headerIndexes, row, nameof(SalesRecord.ProductGroup)), + Quantity = ReadDecimal(headerIndexes, row, nameof(SalesRecord.Quantity)), + SupplierNumber = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierNumber)), + SupplierName = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierName)), + SupplierCountry = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierCountry)), + CustomerNumber = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerNumber)), + CustomerName = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerName)), + CustomerCountry = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerCountry)), + CustomerIndustry = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerIndustry)), + StandardCost = ReadDecimal(headerIndexes, row, nameof(SalesRecord.StandardCost)), + StandardCostCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.StandardCostCurrency)), + PurchaseOrderNumber = ReadString(headerIndexes, row, nameof(SalesRecord.PurchaseOrderNumber)), + SalesPriceValue = ReadDecimal(headerIndexes, row, nameof(SalesRecord.SalesPriceValue)), + SalesCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.SalesCurrency)), + Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)), + SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)), + InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)), + OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)), + Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land), + DocumentType = ReadString(headerIndexes, row, nameof(SalesRecord.DocumentType)) + }); + } + + return Task.FromResult(rows); + } + + private static Dictionary BuildHeaderIndexMap(IXLRangeRow headerRow) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var cell in headerRow.CellsUsed()) + { + var normalizedHeader = NormalizeHeader(cell.GetString()); + if (string.IsNullOrWhiteSpace(normalizedHeader)) + continue; + + if (HeaderMap.TryGetValue(normalizedHeader, out var targetField)) + result[targetField] = cell.Address.ColumnNumber; + } + + if (!result.ContainsKey(nameof(SalesRecord.InvoiceNumber))) + throw new InvalidOperationException("Die Excel-Datei hat nicht das erwartete Exportformat. Spalte 'Invoice Number' fehlt."); + + return result; + } + + private static bool IsRowEmpty(IXLRangeRow row) + => row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())); + + private static string ReadString(Dictionary headerIndexes, IXLRangeRow row, string fieldName, string fallback = "") + { + if (!headerIndexes.TryGetValue(fieldName, out var index)) + return fallback; + + var value = row.Cell(index).GetFormattedString().Trim(); + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + + private static decimal ReadDecimal(Dictionary headerIndexes, IXLRangeRow row, string fieldName) + { + if (!headerIndexes.TryGetValue(fieldName, out var index)) + return 0m; + + var cell = row.Cell(index); + if (cell.TryGetValue(out var decimalValue)) + return decimalValue; + if (cell.TryGetValue(out var doubleValue)) + return Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture); + + var text = cell.GetFormattedString().Trim(); + if (string.IsNullOrWhiteSpace(text)) + return 0m; + + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue)) + return decimalValue; + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out decimalValue)) + return decimalValue; + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out decimalValue)) + return decimalValue; + + return 0m; + } + + private static DateTime? ReadDate(Dictionary headerIndexes, IXLRangeRow row, string fieldName) + { + if (!headerIndexes.TryGetValue(fieldName, out var index)) + return null; + + var cell = row.Cell(index); + if (cell.TryGetValue(out var dateValue)) + return dateValue; + + var text = cell.GetFormattedString().Trim(); + if (string.IsNullOrWhiteSpace(text)) + return null; + + var formats = new[] + { + "dd.MM.yyyy HH:mm:ss", + "dd.MM.yyyy", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd", + "O" + }; + + if (DateTime.TryParseExact(text, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dateValue)) + return dateValue; + if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out dateValue)) + return dateValue; + if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.AssumeLocal, out dateValue)) + return dateValue; + + return null; + } + + private static string NormalizeHeader(string value) + { + var chars = value + .Where(char.IsLetterOrDigit) + .Select(char.ToLowerInvariant) + .ToArray(); + return new string(chars); + } +} diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 8368e54..ed51cdf 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService private readonly ISharePointUploadService _sharePointService; private readonly IRecordTransformationService _transformationService; private readonly ICentralSalesRecordService _centralSalesRecordService; + private readonly IManualExcelImportService _manualExcelImportService; private readonly IAppEventLogService _appEventLogService; private readonly ILogger _logger; @@ -27,6 +28,7 @@ public class SiteExportService : ISiteExportService ISharePointUploadService sharePointService, IRecordTransformationService transformationService, ICentralSalesRecordService centralSalesRecordService, + IManualExcelImportService manualExcelImportService, IAppEventLogService appEventLogService, ILogger logger) { @@ -38,6 +40,7 @@ public class SiteExportService : ISiteExportService _sharePointService = sharePointService; _transformationService = transformationService; _centralSalesRecordService = centralSalesRecordService; + _manualExcelImportService = manualExcelImportService; _appEventLogService = appEventLogService; _logger = logger; } @@ -96,6 +99,30 @@ public class SiteExportService : ISiteExportService filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); log.RowCount = records.Count; } + else if (sourceSystem == "MANUAL_EXCEL") + { + 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}"); + + 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("Transformationen anwenden..."); + await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, + details: $"Records vor Transformation={records.Count}"); + var rules = await db.FieldTransformationRules + .Where(r => r.IsActive && r.SourceSystem == sourceSystem) + .OrderBy(r => r.SortOrder) + .ToListAsync(); + _transformationService.Apply(records, rules); + + filePath = site.ManualImportFilePath; + log.RowCount = records.Count; + } else { var exportServer = BuildEffectiveServer(site, settings, sourceSystem);