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);