Add manual Excel column mapping

This commit is contained in:
2026-05-04 16:08:56 +02:00
parent 749a3209d9
commit c862a559f6
23 changed files with 1523 additions and 182 deletions
@@ -12,6 +12,63 @@
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudSpacer />
<MudText Typo="Typo.caption">check.xlsx / Power BI Stand 29.04.2026</MudText>
</MudStack>
<MudTable Items="_netSalesReferenceRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Firma", "Company")</MudTh>
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
<MudTh>@T("IC-Abzug", "IC deduction")</MudTh>
<MudTh>@T("Ist exkl. IC", "Actual excl. IC")</MudTh>
<MudTh>@T("Referenz", "Reference")</MudTh>
<MudTh>@T("Summenfeld", "Value field")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Diff exkl. IC", "Diff excl. IC")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
<MudTd>@FormatAmount(context.IntercompanyDeduction)</MudTd>
<MudTd>@FormatAmount(context.ActualValueExcludingIntercompany)</MudTd>
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudTd>
<MudTd>@context.ReferenceSource</MudTd>
<MudTd>@FormatAmount(context.Difference)</MudTd>
<MudTd>@FormatAmount(context.DifferenceExcludingIntercompany)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.Currencies) ? "-" : context.Currencies)</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">OK</MudChip>
}
else if (context.Status == "Pruefen")
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">@T("Pruefen", "Check")</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@T("Keine Daten", "No data")</MudChip>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
</NoRecordsContent>
</MudTable>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Vergleich: Jahr 2025 aus Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Trafag-IT-Abgleich und veraendert die Originaldaten nicht.", "Comparison: year 2025 from Invoice Date, otherwise Extraction Date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current Trafag IT reconciliation and does not change the original data.")
</MudAlert>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
@@ -155,6 +212,7 @@
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _loading = true;
private bool _anyRunning;
private CancellationTokenSource? _pollingCts;
@@ -171,6 +229,7 @@
var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows;
_netSalesReferenceRows = state.NetSalesReferenceRows;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
@@ -183,12 +242,25 @@
StartPolling();
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
try
{
await LoadDataAsync();
StateHasChanged();
});
await Orchestrator.ExportAllAsync();
await InvokeAsync(() =>
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
}
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
}
@@ -200,22 +272,33 @@
StartPolling();
_ = Task.Run(async () =>
{
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
await InvokeAsync(async () =>
try
{
await LoadDataAsync();
StateHasChanged();
});
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
if (!string.IsNullOrWhiteSpace(filePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
if (!string.IsNullOrWhiteSpace(filePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
}
else
{
await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
}
}
else
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning));
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
@@ -228,22 +311,33 @@
StartPolling();
_ = Task.Run(async () =>
{
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
try
{
await LoadDataAsync();
StateHasChanged();
});
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
}
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
}
}
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
catch (Exception ex)
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
}
finally
{
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}
});
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
@@ -366,6 +460,12 @@
return Task.CompletedTask;
}
private static string FormatAmount(decimal? value)
=> value.HasValue ? value.Value.ToString("N2") : "-";
private static string FormatException(Exception ex)
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
}
@code {
@@ -408,6 +408,63 @@
{
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText>
}
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">Excel-Spaltenmapping</MudText>
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
OnClick="LoadManualExcelHeadersAsync" Disabled="_loadingManualExcelHeaders">
@if (_loadingManualExcelHeaders)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade Spalten...")
}
else
{
@("Spalten aus Excel laden")
}
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
OnClick="AutoMatchManualExcelMappings">
Auto-Match
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddManualExcelMapping">Mapping hinzufügen</MudButton>
</MudStack>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
Wenn hier Mappings gepflegt sind, werden diese vor dem Standardformat verwendet. Konstanten sind mit `=Wert` moeglich, z. B. `=Manual Excel`.
</MudText>
<MudTable Items="_manualExcelMappings" Dense Hover Striped>
<HeaderContent>
<MudTh>Zielfeld</MudTh>
<MudTh>Excel-Spalte / Konstante</MudTh>
<MudTh>Pflicht</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.TargetField" Dense>
@foreach (var field in _salesRecordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.SourceHeader" Dense>
@foreach (var header in GetAvailableManualExcelHeaders(context.SourceHeader))
{
<MudSelectItem Value="@header">@header</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveManualExcelMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
}
else
{
@@ -422,8 +479,8 @@
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport || _loadingManualExcelHeaders">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders">Speichern</MudButton>
</DialogActions>
</MudDialog>
@@ -439,6 +496,8 @@
private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _sapMappings = [];
private List<ManualExcelColumnMapping> _manualExcelMappings = [];
private List<string> _manualExcelHeaders = [];
private readonly string[] _salesRecordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
@@ -453,6 +512,7 @@
private bool _savingSite;
private bool _loadingSchemas;
private bool _uploadingManualImport;
private bool _loadingManualExcelHeaders;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
@@ -565,6 +625,8 @@
_sapSources = [];
_sapJoins = [];
_sapMappings = [];
_manualExcelMappings = [];
_manualExcelHeaders = [];
_siteDialogVisible = true;
}
@@ -582,6 +644,8 @@
_sapSources = editorState.SapSources;
_sapJoins = editorState.SapJoins;
_sapMappings = editorState.SapMappings;
_manualExcelMappings = editorState.ManualExcelMappings;
_manualExcelHeaders = BuildHeadersFromManualExcelMappings();
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
_sapSourceFieldMap = BuildSourceFieldMapFromJoins();
_siteDialogVisible = true;
@@ -595,7 +659,7 @@
_savingSite = true;
try
{
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache);
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), IsManualExcelSite(), _sapSources, _sapJoins, _sapMappings, _manualExcelMappings, _sapEntitySetsCache);
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -811,7 +875,7 @@
private void CloseSiteDialog()
{
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport)
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders)
return;
_siteDialogVisible = false;
@@ -878,6 +942,170 @@
}
}
private async Task LoadManualExcelHeadersAsync()
{
if (_loadingManualExcelHeaders)
return;
_loadingManualExcelHeaders = true;
try
{
_manualExcelHeaders = await StandortePageService.LoadManualExcelHeadersAsync(_editingSite.ManualImportFilePath);
Snackbar.Add($"{_manualExcelHeaders.Count} Excel-Spalten geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Spalten laden fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_loadingManualExcelHeaders = false;
}
}
private void AddManualExcelMapping()
{
_manualExcelMappings.Add(new ManualExcelColumnMapping
{
TargetField = _salesRecordFields.First(),
SourceHeader = GetAvailableManualExcelHeaders(null).FirstOrDefault() ?? string.Empty,
IsActive = true,
SortOrder = _manualExcelMappings.Count
});
}
private void RemoveManualExcelMapping(ManualExcelColumnMapping mapping)
=> _manualExcelMappings.Remove(mapping);
private void AutoMatchManualExcelMappings()
{
if (_manualExcelHeaders.Count == 0)
{
Snackbar.Add("Bitte zuerst 'Spalten aus Excel laden' ausfuehren.", Severity.Warning);
return;
}
var suggestions = BuildManualExcelAutoMatchSuggestions();
var addedOrUpdated = 0;
foreach (var (targetField, sourceHeader) in suggestions)
{
var existing = _manualExcelMappings.FirstOrDefault(m =>
string.Equals(m.TargetField, targetField, StringComparison.OrdinalIgnoreCase));
if (existing is null)
{
_manualExcelMappings.Add(new ManualExcelColumnMapping
{
TargetField = targetField,
SourceHeader = sourceHeader,
IsActive = true,
IsRequired = IsImportantManualExcelField(targetField),
SortOrder = _manualExcelMappings.Count
});
}
else
{
existing.SourceHeader = sourceHeader;
existing.IsActive = true;
}
addedOrUpdated++;
}
Snackbar.Add(
addedOrUpdated == 0 ? "Keine passenden Spalten gefunden." : $"{addedOrUpdated} Mapping-Vorschlaege gesetzt.",
addedOrUpdated == 0 ? Severity.Info : Severity.Success);
}
private List<(string TargetField, string SourceHeader)> BuildManualExcelAutoMatchSuggestions()
{
var headerByNormalized = _manualExcelHeaders
.GroupBy(NormalizeHeader, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var aliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[nameof(SalesRecord.ExtractionDate)] = ["Export-Datum", "Extraction Date"],
[nameof(SalesRecord.InvoiceNumber)] = ["Belegnummer", "Invoice Number"],
[nameof(SalesRecord.PositionOnInvoice)] = ["Position", "Position on invoice"],
[nameof(SalesRecord.Material)] = ["ArtikelNummer", "Material", "Groesse"],
[nameof(SalesRecord.Name)] = ["ArtikelBezeichnung", "Name"],
[nameof(SalesRecord.ProductGroup)] = ["Warengruppen-Bezeichnung", "Product Group"],
[nameof(SalesRecord.Quantity)] = ["Anz. VE", "Quantity"],
[nameof(SalesRecord.SupplierNumber)] = ["Lieferanten Nummer", "Supplier number"],
[nameof(SalesRecord.SupplierName)] = ["Name Lieferant", "Supplier name"],
[nameof(SalesRecord.SupplierCountry)] = ["Land Lieferant", "Supplier country"],
[nameof(SalesRecord.CustomerNumber)] = ["AdressNummer-Kunde", "Customer number"],
[nameof(SalesRecord.CustomerName)] = ["Name Kunde", "Customer name"],
[nameof(SalesRecord.CustomerCountry)] = ["Land Kunde", "Customer country"],
[nameof(SalesRecord.CustomerIndustry)] = ["Branche", "Customer Industry"],
[nameof(SalesRecord.StandardCost)] = ["EinstandsPreis", "Standard cost"],
[nameof(SalesRecord.StandardCostCurrency)] = ["Währung", "Waehrung", "Standard Cost Currency"],
[nameof(SalesRecord.PurchaseOrderNumber)] = ["BestellNummer", "Purchase Order number"],
[nameof(SalesRecord.SalesPriceValue)] = ["NettoPreisGesamtX", "Sales Price/Value"],
[nameof(SalesRecord.SalesCurrency)] = ["Währung", "Waehrung", "Sales Currency"],
[nameof(SalesRecord.DocumentCurrency)] = ["Währung", "Waehrung", "Document Currency"],
[nameof(SalesRecord.CompanyCurrency)] = ["Währung", "Waehrung", "Company Currency"],
[nameof(SalesRecord.Incoterms2020)] = ["Versandbedingung", "Incoterms 2020"],
[nameof(SalesRecord.SalesResponsibleEmployee)] = ["AdressNummer_V", "Sales responsible employee"],
[nameof(SalesRecord.InvoiceDate)] = ["Belegdatum-Rechnung", "invoice date"],
[nameof(SalesRecord.OrderDate)] = ["BelegDatum Auftrag", "order date"]
};
var result = new List<(string TargetField, string SourceHeader)>();
foreach (var (targetField, sourceAliases) in aliases)
{
foreach (var alias in sourceAliases)
{
if (headerByNormalized.TryGetValue(NormalizeHeader(alias), out var actualHeader))
{
result.Add((targetField, actualHeader));
break;
}
}
}
result.Add((nameof(SalesRecord.DocumentType), "=Manual Excel"));
return result;
}
private IEnumerable<string> GetAvailableManualExcelHeaders(string? currentValue)
{
var values = new List<string>(_manualExcelHeaders);
values.Add("=Manual Excel");
if (!string.IsNullOrWhiteSpace(currentValue) && !values.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
values.Insert(0, currentValue);
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x.StartsWith('=') ? 1 : 0)
.ThenBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private List<string> BuildHeadersFromManualExcelMappings()
=> _manualExcelMappings
.Select(m => m.SourceHeader)
.Where(x => !string.IsNullOrWhiteSpace(x) && !x.Trim().StartsWith('='))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsImportantManualExcelField(string targetField)
=> targetField is nameof(SalesRecord.InvoiceNumber) or
nameof(SalesRecord.SalesPriceValue) or
nameof(SalesRecord.InvoiceDate);
private static string NormalizeHeader(string value)
{
var chars = value
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray();
return new string(chars);
}
private static List<string> ParseSapEntitySets(string json)
{
if (string.IsNullOrWhiteSpace(json))