zentraler export
This commit is contained in:
@@ -19,6 +19,10 @@
|
||||
OnClick="ExportAll" Disabled="_anyRunning">
|
||||
Alle exportieren
|
||||
</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">
|
||||
@if (TimerService.NextRun < DateTime.MaxValue)
|
||||
{
|
||||
@@ -109,8 +113,49 @@
|
||||
</RowTemplate>
|
||||
</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 {
|
||||
private List<DashboardRow> _dashboardRows = new();
|
||||
private List<ConsolidatedDashboardRow> _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<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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
</RowTemplate>
|
||||
</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
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
|
||||
@@ -365,13 +391,13 @@
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite">Abbrechen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets">Speichern</MudButton>
|
||||
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@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 List<HanaServer> _servers = new();
|
||||
private List<Site> _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<string> ParseSapEntitySets(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStr
|
||||
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
||||
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
|
||||
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ''),
|
||||
|
||||
@@ -14,6 +14,8 @@ public class ExportOrchestrationService
|
||||
public event Action? OnExportStatusChanged;
|
||||
|
||||
private readonly Dictionary<int, string> _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<string?> ExportConsolidatedOnlyAsync()
|
||||
{
|
||||
return await RunConsolidatedExportAsync(null);
|
||||
}
|
||||
|
||||
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||
@@ -112,4 +135,31 @@ public class ExportOrchestrationService
|
||||
{
|
||||
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 IRecordTransformationService _transformationService;
|
||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||
private readonly IManualExcelImportService _manualExcelImportService;
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
private readonly ILogger<SiteExportService> _logger;
|
||||
|
||||
@@ -27,6 +28,7 @@ public class SiteExportService : ISiteExportService
|
||||
ISharePointUploadService sharePointService,
|
||||
IRecordTransformationService transformationService,
|
||||
ICentralSalesRecordService centralSalesRecordService,
|
||||
IManualExcelImportService manualExcelImportService,
|
||||
IAppEventLogService appEventLogService,
|
||||
ILogger<SiteExportService> 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);
|
||||
|
||||
Reference in New Issue
Block a user