zentraler export

This commit is contained in:
2026-04-15 14:47:32 +02:00
parent 7891dfb3dd
commit 264e64bbf5
13 changed files with 610 additions and 154 deletions
@@ -19,6 +19,10 @@
OnClick="ExportAll" Disabled="_anyRunning"> OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren Alle exportieren
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
Zentrale Datei neu erzeugen
</MudButton>
<MudText Typo="Typo.body1"> <MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue) @if (TimerService.NextRun < DateTime.MaxValue)
{ {
@@ -109,8 +113,49 @@
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">Zentrale Datei</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>Datei</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>Status</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
Excel öffnen
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">Keine zentrale Excel-Datei gefunden.</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
@code { @code {
private List<DashboardRow> _dashboardRows = new(); private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private bool _loading = true; private bool _loading = true;
private bool _anyRunning; private bool _anyRunning;
private CancellationTokenSource? _pollingCts; private CancellationTokenSource? _pollingCts;
@@ -164,7 +209,9 @@
}; };
}).ToList(); }).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; _loading = false;
} }
@@ -185,6 +232,34 @@
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info); 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) private void ExportSingle(int siteId)
{ {
_anyRunning = true; _anyRunning = true;
@@ -217,7 +292,7 @@
{ {
await InvokeAsync(async () => 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) if (_anyRunning)
{ {
StartPolling(); StartPolling();
@@ -240,7 +315,12 @@
private void OpenExportFile(DashboardRow row) 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); Snackbar.Add("Exportdatei nicht gefunden.", Severity.Warning);
return; return;
@@ -250,7 +330,7 @@
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = row.FilePath, FileName = filePath,
UseShellExecute = true UseShellExecute = true
}); });
} }
@@ -284,7 +364,7 @@
{ {
while (await timer.WaitForNextTickAsync(cancellationToken)) 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) if (!anyRunning)
{ {
await InvokeAsync(async () => await InvokeAsync(async () =>
@@ -321,10 +401,41 @@
row.LiveDetails = string.Empty; 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; return Task.CompletedTask;
} }
private static List<ConsolidatedDashboardRow> 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 private class DashboardRow
{ {
public int SiteId { get; set; } public int SiteId { get; set; }
@@ -342,4 +453,13 @@
public string LiveDetails { get; set; } = ""; public string LiveDetails { get; set; } = "";
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath); 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);
}
} }
@@ -1,4 +1,5 @@
@page "/standorte" @page "/standorte"
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using System.Text.Json @using System.Text.Json
@using System.Reflection @using System.Reflection
@@ -341,6 +342,31 @@
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
} }
else if (IsManualExcelSite())
{
<MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-Import</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen.
</MudAlert>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
@if (_uploadingManualImport)
{
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
}
@if (!string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath))
{
<MudPaper Class="pa-3 mt-3" Elevation="0">
<MudText Typo="Typo.body2">Datei: @_editingSite.ManualImportFilePath</MudText>
<MudText Typo="Typo.caption">
Letzter Upload: @(_editingSite.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
</MudText>
</MudPaper>
}
else
{
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText>
}
}
else else
{ {
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText> <MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
@@ -365,13 +391,13 @@
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite">Abbrechen</MudButton> <MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets">Speichern</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@code { @code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"]; private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new(); private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new(); private List<HanaServer> _servers = new();
private List<Site> _sites = new(); private List<Site> _sites = new();
@@ -394,6 +420,7 @@
private bool _refreshingSapSourceFields; private bool _refreshingSapSourceFields;
private bool _savingServer; private bool _savingServer;
private bool _savingSite; private bool _savingSite;
private bool _uploadingManualImport;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -514,7 +541,8 @@
{ {
IsActive = true, IsActive = true,
SourceSystem = "SAP", SourceSystem = "SAP",
HanaServerId = null HanaServerId = null,
ManualImportFilePath = string.Empty
}; };
_sapEntitySetsCache = []; _sapEntitySetsCache = [];
_sapAvailableSourceExpressions = []; _sapAvailableSourceExpressions = [];
@@ -539,6 +567,8 @@
UsernameOverride = site.UsernameOverride, UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride, PasswordOverride = site.PasswordOverride,
LocalExportFolderOverride = site.LocalExportFolderOverride, LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl, SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet, SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache, SapEntitySetsCache = site.SapEntitySetsCache,
@@ -567,7 +597,7 @@
try try
{ {
using var db = await DbFactory.CreateDbContextAsync(); 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.HanaServerId = serverId;
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache); _editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache);
@@ -588,6 +618,8 @@
existing.UsernameOverride = _editingSite.UsernameOverride; existing.UsernameOverride = _editingSite.UsernameOverride;
existing.PasswordOverride = _editingSite.PasswordOverride; existing.PasswordOverride = _editingSite.PasswordOverride;
existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride; existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride;
existing.ManualImportFilePath = _editingSite.ManualImportFilePath;
existing.ManualImportLastUploadedAtUtc = _editingSite.ManualImportLastUploadedAtUtc;
existing.SapServiceUrl = _editingSite.SapServiceUrl; existing.SapServiceUrl = _editingSite.SapServiceUrl;
existing.SapEntitySet = _editingSite.SapEntitySet; existing.SapEntitySet = _editingSite.SapEntitySet;
existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache; existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
@@ -654,6 +686,8 @@
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem; var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase)) if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(site.SapServiceUrl) ? "-" : site.SapServiceUrl; 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); return GetServerNode(site.HanaServer);
} }
@@ -696,6 +730,7 @@
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim(); _editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim(); _editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
_editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim(); _editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim();
_editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim();
_editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim(); _editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
_editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim(); _editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
_editingSiteServer.Host = _editingSiteServer.Host.Trim(); _editingSiteServer.Host = _editingSiteServer.Host.Trim();
@@ -745,6 +780,8 @@
} }
private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase); 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() private async Task RefreshSapEntitySets()
{ {
@@ -804,12 +841,62 @@
private void CloseSiteDialog() private void CloseSiteDialog()
{ {
if (_savingSite || _refreshingSapEntitySets) if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport)
return; return;
_siteDialogVisible = false; _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<string> ParseSapEntitySets(string json) private static List<string> ParseSapEntitySets(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
+62 -79
View File
@@ -7,34 +7,34 @@ Stand: 2026-04-15
Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert: Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert:
- `BI1` und `SAGE` bleiben auf direktem HANA-Zugriff - `BI1` und `SAGE` bleiben auf direktem HANA-Zugriff
- `SAP` läuft separat über SAP Gateway / OData - `SAP` laeuft separat ueber SAP Gateway / OData
- SAP-Quellen können gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden - SAP-Quellen koennen gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden
- Standort-Exporte werden lokal als Excel geschrieben - Standort-Exporte werden lokal als Excel geschrieben
- Zusätzlich werden Datensätze in eine zentrale SQLite-Tabelle geschrieben - zusaetzlich werden Datensaetze in eine zentrale SQLite-Tabelle geschrieben
- Ein konsolidierter Export liest aus dieser zentralen Tabelle - ein konsolidierter Export liest aus dieser zentralen Tabelle
## Wichtigste umgesetzte Funktionen ## Wichtigste umgesetzte Funktionen
### 1. Zentrale Credentials pro Quellsystem ### 1. Zentrale Credentials pro Quellsystem
Es gibt zentrale Zugangsdaten in `ExportSettings` für: Es gibt zentrale Zugangsdaten in `ExportSettings` fuer:
- `SAP` - `SAP`
- `BI1` - `BI1`
- `SAGE` - `SAGE`
Zusätzlich gibt es pro Standort optionale Overrides: Zusaetzlich gibt es pro Standort optionale Overrides:
- `UsernameOverride` - `UsernameOverride`
- `PasswordOverride` - `PasswordOverride`
Auflösungsreihenfolge: Aufloesungsreihenfolge:
1. Standort-Override 1. Standort-Override
2. zentrale Credentials des Quellsystems 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. `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: Wichtig:
- Service URL immer nur bis zum Service - 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` - `SapSourceDefinition`
- `SapJoinDefinition` - `SapJoinDefinition`
- `SapFieldMapping` - `SapFieldMapping`
Unterstützt wird: Unterstuetzt wird:
- mehrere SAP-Quellen pro Standort - mehrere SAP-Quellen pro Standort
- Alias pro Quelle - Alias pro Quelle
- Primärquelle - Primaerquelle
- Join-Definitionen - Join-Definitionen
- Mapping von `Alias.Feldname` auf zentrales Schema - Mapping von `Alias.Feldname` auf zentrales Schema
@@ -79,9 +79,9 @@ UI-Erweiterungen:
- `Quellen refreshen` - `Quellen refreshen`
- `Felder aus Quellen laden` - `Felder aus Quellen laden`
- Join-Key-Auswahl aus Metadaten - 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: Neue Tabelle:
@@ -89,7 +89,7 @@ Neue Tabelle:
Verwendung: 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` - konsolidierte Excel liest aus `CentralSalesRecords`
Wichtig: Wichtig:
@@ -97,9 +97,9 @@ Wichtig:
- zentrale Excel wird nicht appendet - zentrale Excel wird nicht appendet
- sie wird aus dem aktuellen Zustand der zentralen Tabelle neu erstellt - sie wird aus dem aktuellen Zustand der zentralen Tabelle neu erstellt
## 5. Exportpfade ### 5. Exportpfade
Neue Konfigurationsmöglichkeiten: Neue Konfigurationsmoeglichkeiten:
Zentral in `Settings`: Zentral in `Settings`:
@@ -118,16 +118,16 @@ Fallback wenn leer:
relativ zum App-Verzeichnis. relativ zum App-Verzeichnis.
## 6. SharePoint ### 6. SharePoint
SharePoint-Upload ist optional. SharePoint-Upload ist optional.
Wenn keine vollständige SharePoint-Konfiguration vorhanden ist: Wenn keine vollstaendige SharePoint-Konfiguration vorhanden ist:
- Excel wird trotzdem lokal erzeugt - Excel wird trotzdem lokal erzeugt
- kein Upload nach SharePoint - kein Upload nach SharePoint
Benötigte SharePoint-Werte: Benoetigte SharePoint-Werte:
- `Tenant ID` - `Tenant ID`
- `Client ID` - `Client ID`
@@ -135,7 +135,7 @@ Benötigte SharePoint-Werte:
Das sind Entra App Registration Werte, nicht normale Benutzer-Credentials. 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: Es gibt JSON-Import/Export der Konfiguration mit Checkbox:
@@ -153,9 +153,9 @@ Enthalten sind u. a.:
- SAP-Joins - SAP-Joins
- SAP-Mappings - SAP-Mappings
## 8. Logging und Live-Status ### 8. Logging und Live-Status
Neue technische Logs über `AppEventLogs`. Neue technische Logs ueber `AppEventLogs`.
Sichtbar: Sichtbar:
@@ -172,11 +172,11 @@ Geloggt werden u. a.:
- zentrale Tabellenspeicherung - zentrale Tabellenspeicherung
- Export erfolgreich / fehlgeschlagen - Export erfolgreich / fehlgeschlagen
## 9. Excel öffnen ### 9. Excel oeffnen
Im Dashboard gibt es neben `Export` den Button: Im Dashboard gibt es neben `Export` den Button:
- `Excel öffnen` - `Excel oeffnen`
Dieser nutzt `ExportLogs.FilePath`. Dieser nutzt `ExportLogs.FilePath`.
@@ -186,9 +186,9 @@ Voraussetzungen:
- `FilePath` gespeichert - `FilePath` gespeichert
- Datei existiert lokal - Datei existiert lokal
## 10. Management Cockpit ### 10. Management Cockpit
Es gibt einen neuen Menüpunkt: Es gibt einen neuen Menuepunkt:
- `Management Cockpit` - `Management Cockpit`
@@ -196,19 +196,19 @@ Funktion:
- Auswahl vorhandener Excel-Dateien - Auswahl vorhandener Excel-Dateien
- Analyse einer exportierten Standort-Datei - Analyse einer exportierten Standort-Datei
- Kennzahlen für Geschäftsinhaber / Management - Kennzahlen fuer Geschaeftsinhaber / Management
Aktuell enthalten: Aktuell enthalten:
- Umsatz - Umsatz
- geschätzte Kosten - geschaetzte Kosten
- geschätzte Marge - geschaetzte Marge
- Rechnungsanzahl - Rechnungsanzahl
- Kundenanzahl - Kundenanzahl
- Top Kunden - Top Kunden
- Top Produktgruppen - Top Produktgruppen
- Top Sales Owner - Top Sales Owner
- Datenqualitätshinweise - Datenqualitaetshinweise
- automatische Management-Aussagen - automatische Management-Aussagen
## Wichtige Dateien ## Wichtige Dateien
@@ -249,7 +249,7 @@ Aktuell enthalten:
## Datenbank / Migrationen ## Datenbank / Migrationen
Viele Änderungen laufen über `DatabaseInitializationService`. Viele Aenderungen laufen ueber `DatabaseInitializationService`.
Wichtige neue oder erweiterte Tabellen/Felder: Wichtige neue oder erweiterte Tabellen/Felder:
@@ -273,52 +273,46 @@ Wichtige neue oder erweiterte Tabellen/Felder:
- `CentralSalesRecords` - `CentralSalesRecords`
- SAP-Konfigtabellen - 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` haengen.
- EF Batch-Speichern
- Dashboard-Polling reduziert
- SQLite WAL + busy timeout
- direkte SQLite-Inserts in einer großen Transaktion
- jetzt: kleine abgeschlossene Transaktionen pro Batch
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 - mindestens eine Tabelle referenzierte per FK noch `main.Sites_old`
- das Hängen wurde stark eingegrenzt - dadurch scheiterte `SaveChangesAsync()` spaeter beim Schreiben in `AppEventLogs` oder `ExportLogs`
- zuletzt wurde der Schreibpfad so umgebaut, dass: - die alte Tabelle `Sites_old` existierte nicht mehr
- Löschen in eigener kurzer Transaktion läuft
- Inserts batchweise mit Commit pro Batch laufen
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` - `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 1. Tritt beim App-Start die Schema-Reparatur sauber durch?
- nicht SharePoint 2. Gibt es noch weitere Tabellen mit FK-Referenz auf `Sites_old`?
- nicht mehr der große EF-Insert 3. Erst danach wieder Insert-/Commit-Batches der zentralen Speicherung untersuchen
- 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
## Build-Status ## Build-Status
@@ -334,14 +328,3 @@ Ergebnis:
- bekannte Warnungen bleiben: - bekannte Warnungen bleiben:
- SAP HANA Architekturwarnung `MSB3270` - SAP HANA Architekturwarnung `MSB3270`
- MudBlazor Analyzer `Dense` - 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
@@ -66,6 +66,8 @@ public class ConfigTransferSite
public string? UsernameOverride { get; set; } public string? UsernameOverride { get; set; }
public string? PasswordOverride { get; set; } public string? PasswordOverride { get; set; }
public string LocalExportFolderOverride { 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; public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty; public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty; public string SapEntitySetsCache { get; set; } = string.Empty;
+2
View File
@@ -28,6 +28,8 @@ public class Site
public string PasswordOverride { get; set; } = string.Empty; public string PasswordOverride { get; set; } = string.Empty;
public string LocalExportFolderOverride { 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; public string SapServiceUrl { get; set; } = string.Empty;
+40 -61
View File
@@ -2,63 +2,51 @@
Stand: 2026-04-15 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 - SQLite referenzierte in mindestens einer Tabelle noch `main.Sites_old`
- denselben Standort erneut exportieren - dadurch scheiterte `SaveChangesAsync()` beim Schreiben z. B. in `AppEventLogs` oder `ExportLogs`
- letzte sichtbare `Live-Status`-Meldung exakt notieren - 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...` Umgesetzt wurde:
- `Zentrale Tabelle: Batch x/y abschliessen...`
- `Zentrale Tabelle aktualisiert`
- `Export erfolgreich`
## 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: Betroffene Dateien:
- `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:
- `Program.cs`
- `Components/Pages/Dashboard.razor` - `Components/Pages/Dashboard.razor`
- `Services/SiteExportService.cs` - `Services/CentralSalesRecordService.cs`
- `Models/ExportLog.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: Zu testen:
@@ -73,12 +61,12 @@ Dateien:
- `Services/SapGatewayService.cs` - `Services/SapGatewayService.cs`
- `Services/SapCompositionService.cs` - `Services/SapCompositionService.cs`
## 6. Management Cockpit prüfen ## 6. Management Cockpit pruefen
Zu testen: Zu testen:
- vorhandene Excel-Datei auswählbar - vorhandene Excel-Datei auswaehlbar
- Analyse läuft - Analyse laeuft
- Kennzahlen plausibel - Kennzahlen plausibel
Dateien: Dateien:
@@ -86,17 +74,8 @@ Dateien:
- `Components/Pages/ManagementCockpit.razor` - `Components/Pages/ManagementCockpit.razor`
- `Services/ManagementCockpitService.cs` - `Services/ManagementCockpitService.cs`
## 7. Wenn Stabilität vor Funktion geht ## 7. Referenzdatei
Sinnvolle pragmatische Zwischenlösung: Fuer den vollstaendigen Kontext zuerst lesen:
- 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:
- `HANDOFF_2026-04-15.md` - `HANDOFF_2026-04-15.md`
+1
View File
@@ -28,6 +28,7 @@ builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStr
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>(); builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>(); builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>(); builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>(); builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>(); builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>(); builder.Services.AddSingleton<IExportLogService, ExportLogService>();
@@ -81,6 +81,8 @@ public class ConfigTransferService : IConfigTransferService
UsernameOverride = includeSecrets ? site.UsernameOverride : null, UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null, PasswordOverride = includeSecrets ? site.PasswordOverride : null,
LocalExportFolderOverride = site.LocalExportFolderOverride, LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl, SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet, SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache, SapEntitySetsCache = site.SapEntitySetsCache,
@@ -242,6 +244,8 @@ public class ConfigTransferService : IConfigTransferService
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty, UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty, PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
LocalExportFolderOverride = site.LocalExportFolderOverride, LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl, SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet, SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache, SapEntitySetsCache = site.SapEntitySetsCache,
@@ -54,6 +54,8 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "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", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
@@ -128,6 +130,8 @@ CREATE TABLE Sites (
UsernameOverride TEXT NOT NULL DEFAULT '', UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '', PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '', LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
SapServiceUrl TEXT NOT NULL DEFAULT '', SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '', SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '', SapEntitySetsCache TEXT NOT NULL DEFAULT '',
@@ -145,7 +149,7 @@ CREATE TABLE Sites (
INSERT INTO Sites ( INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem, Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet, UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive ManualImportFilePath, ManualImportLastUploadedAtUtc, SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
) )
SELECT SELECT
Id, HanaServerId, Schema, TSC, Land, Id, HanaServerId, Schema, TSC, Land,
@@ -153,6 +157,8 @@ SELECT
COALESCE(UsernameOverride, ''), COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''), COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''), COALESCE(LocalExportFolderOverride, ''),
COALESCE(ManualImportFilePath, ''),
ManualImportLastUploadedAtUtc,
COALESCE(SapServiceUrl, ''), COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''), COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''), COALESCE(SapEntitySetsCache, ''),
@@ -14,6 +14,8 @@ public class ExportOrchestrationService
public event Action? OnExportStatusChanged; public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new(); private readonly Dictionary<int, string> _runningExports = new();
private bool _consolidatedExportRunning;
private string _consolidatedExportStatus = string.Empty;
private readonly object _lock = new(); private readonly object _lock = new();
public ExportOrchestrationService( 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() public async Task ExportAllAsync()
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
@@ -57,7 +75,12 @@ public class ExportOrchestrationService
consolidatedRecords.AddRange(result.Records); consolidatedRecords.AddRange(result.Records);
} }
await _consolidatedExportService.ExportAsync(consolidatedRecords); await RunConsolidatedExportAsync(consolidatedRecords);
}
public async Task<string?> ExportConsolidatedOnlyAsync()
{
return await RunConsolidatedExportAsync(null);
} }
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId) public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
@@ -112,4 +135,31 @@ public class ExportOrchestrationService
{ {
OnExportStatusChanged?.Invoke(); OnExportStatusChanged?.Invoke();
} }
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? 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();
}
}
} }
@@ -0,0 +1,8 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IManualExcelImportService
{
Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site);
}
@@ -0,0 +1,187 @@
using System.Globalization;
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ManualExcelImportService : IManualExcelImportService
{
private static readonly Dictionary<string, string> 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<List<SalesRecord>> 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<SalesRecord>();
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<string, int> BuildHeaderIndexMap(IXLRangeRow headerRow)
{
var result = new Dictionary<string, int>(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<string, int> 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<string, int> headerIndexes, IXLRangeRow row, string fieldName)
{
if (!headerIndexes.TryGetValue(fieldName, out var index))
return 0m;
var cell = row.Cell(index);
if (cell.TryGetValue<decimal>(out var decimalValue))
return decimalValue;
if (cell.TryGetValue<double>(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<string, int> headerIndexes, IXLRangeRow row, string fieldName)
{
if (!headerIndexes.TryGetValue(fieldName, out var index))
return null;
var cell = row.Cell(index);
if (cell.TryGetValue<DateTime>(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);
}
}
@@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService; private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService; private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService; private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IManualExcelImportService _manualExcelImportService;
private readonly IAppEventLogService _appEventLogService; private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger; private readonly ILogger<SiteExportService> _logger;
@@ -27,6 +28,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService, ISharePointUploadService sharePointService,
IRecordTransformationService transformationService, IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService, ICentralSalesRecordService centralSalesRecordService,
IManualExcelImportService manualExcelImportService,
IAppEventLogService appEventLogService, IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger) ILogger<SiteExportService> logger)
{ {
@@ -38,6 +40,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService; _sharePointService = sharePointService;
_transformationService = transformationService; _transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService; _centralSalesRecordService = centralSalesRecordService;
_manualExcelImportService = manualExcelImportService;
_appEventLogService = appEventLogService; _appEventLogService = appEventLogService;
_logger = logger; _logger = logger;
} }
@@ -96,6 +99,30 @@ public class SiteExportService : ISiteExportService
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count; 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 else
{ {
var exportServer = BuildEffectiveServer(site, settings, sourceSystem); var exportServer = BuildEffectiveServer(site, settings, sourceSystem);