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> <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"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow" <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
@@ -155,6 +212,7 @@
@code { @code {
private List<DashboardRow> _dashboardRows = new(); private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new(); private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _loading = true; private bool _loading = true;
private bool _anyRunning; private bool _anyRunning;
private CancellationTokenSource? _pollingCts; private CancellationTokenSource? _pollingCts;
@@ -171,6 +229,7 @@
var state = await DashboardPageActions.LoadAsync(); var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows; _dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows; _consolidatedRows = state.ConsolidatedRows;
_netSalesReferenceRows = state.NetSalesReferenceRows;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false; _loading = false;
@@ -182,13 +241,26 @@
await LoadDataAsync(); await LoadDataAsync();
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{
try
{ {
await Orchestrator.ExportAllAsync(); 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 InvokeAsync(async () =>
{ {
await LoadDataAsync(); await LoadDataAsync();
StateHasChanged(); StateHasChanged();
}); });
}
}); });
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info); Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
} }
@@ -200,12 +272,9 @@
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync(); try
await InvokeAsync(async () =>
{ {
await LoadDataAsync(); var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
StateHasChanged();
});
if (!string.IsNullOrWhiteSpace(filePath)) if (!string.IsNullOrWhiteSpace(filePath))
{ {
@@ -215,7 +284,21 @@
else else
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning)); 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));
}
}
catch (Exception ex)
{
await InvokeAsync(() =>
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); Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
@@ -228,12 +311,9 @@
StartPolling(); StartPolling();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var result = await Orchestrator.ExportSiteByIdAsync(siteId); try
await InvokeAsync(async () =>
{ {
await LoadDataAsync(); var result = await Orchestrator.ExportSiteByIdAsync(siteId);
StateHasChanged();
});
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath)) if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{ {
@@ -245,6 +325,20 @@
await InvokeAsync(() => 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}"), result.Log.ErrorMessage), Severity.Error));
} }
}
catch (Exception ex)
{
await InvokeAsync(() =>
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); Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
} }
@@ -366,6 +460,12 @@
return Task.CompletedTask; 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 { @code {
@@ -408,6 +408,63 @@
{ {
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText> <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 else
{ {
@@ -422,8 +479,8 @@
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton> <MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport || _loadingManualExcelHeaders">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders">Speichern</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -439,6 +496,8 @@
private List<SapSourceDefinition> _sapSources = []; private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = []; private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _sapMappings = []; private List<SapFieldMapping> _sapMappings = [];
private List<ManualExcelColumnMapping> _manualExcelMappings = [];
private List<string> _manualExcelHeaders = [];
private readonly string[] _salesRecordFields = typeof(SalesRecord) private readonly string[] _salesRecordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance) .GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name) .Select(p => p.Name)
@@ -453,6 +512,7 @@
private bool _savingSite; private bool _savingSite;
private bool _loadingSchemas; private bool _loadingSchemas;
private bool _uploadingManualImport; private bool _uploadingManualImport;
private bool _loadingManualExcelHeaders;
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()
@@ -565,6 +625,8 @@
_sapSources = []; _sapSources = [];
_sapJoins = []; _sapJoins = [];
_sapMappings = []; _sapMappings = [];
_manualExcelMappings = [];
_manualExcelHeaders = [];
_siteDialogVisible = true; _siteDialogVisible = true;
} }
@@ -582,6 +644,8 @@
_sapSources = editorState.SapSources; _sapSources = editorState.SapSources;
_sapJoins = editorState.SapJoins; _sapJoins = editorState.SapJoins;
_sapMappings = editorState.SapMappings; _sapMappings = editorState.SapMappings;
_manualExcelMappings = editorState.ManualExcelMappings;
_manualExcelHeaders = BuildHeadersFromManualExcelMappings();
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings(); _sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
_sapSourceFieldMap = BuildSourceFieldMapFromJoins(); _sapSourceFieldMap = BuildSourceFieldMapFromJoins();
_siteDialogVisible = true; _siteDialogVisible = true;
@@ -595,7 +659,7 @@
_savingSite = true; _savingSite = true;
try 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; _siteDialogVisible = false;
await LoadDataAsync(); await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success); Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -811,7 +875,7 @@
private void CloseSiteDialog() private void CloseSiteDialog()
{ {
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport) if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders)
return; return;
_siteDialogVisible = false; _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) private static List<string> ParseSapEntitySets(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
+1
View File
@@ -19,5 +19,6 @@ public class AppDbContext : DbContext
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>(); public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>(); public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>(); public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>(); public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
} }
+110
View File
@@ -2,6 +2,108 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-29 Dashboard-Referenzcheck Net Sales 2025
Das Dashboard zeigt jetzt oberhalb der Exportaktionen einen Referenzcheck fuer `Net Sales Actuals 2025`.
Zweck:
- schnelle Gegenpruefung, ob die gezogenen Werte gegen `check.xlsx` / Power-BI-Referenz plausibel sind
- automatische Ermittlung des Summenfelds, das am besten zum Referenzwert passt
- sichtbar machen, ob aktuell `SalesPriceValue`, `DocTotalFC - VatSumFC` oder `DocTotal - VatSum` als Vergleichsbasis genutzt wird
- `DocumentTotal*` wird nur dedupliziert pro Beleg verwendet, weil es ein Belegkopfwert ist und in der positionsbasierten Datei pro Position wiederholt wird
Logik:
- Ist-Wert = bester Kandidat aus:
- Summe `CentralSalesRecords.SalesPriceValue`
- Summe `DocumentTotalForeignCurrency - VatSumForeignCurrency`
- Summe `DocumentTotalLocalCurrency - VatSumLocalCurrency`
- Belegkopfwerte werden vor dem Summieren per `TSC` + `DocumentType` + `DocumentEntry` dedupliziert; falls `DocumentEntry` fehlt, per `InvoiceNumber`
- Jahr = `InvoiceDate`, falls vorhanden, sonst `ExtractionDate`
- Vergleichsjahr = `2025`
- Referenzwerte sind aus `check.xlsx` / Power BI Stand 2026-04-29 im Code hinterlegt
- wenn ein Power-BI-Referenzwert vorhanden ist, wird dieser als Vergleich verwendet
- sonst wird der LC-Referenzwert verwendet
Angezeigt werden:
- Firma
- Ist 2025
- Referenz
- Summenfeld
- Referenzquelle (`Power BI` oder `LC`)
- Differenz
- Waehrungen
- Zeilen
- Status `OK`, `Pruefen` oder `Keine Daten`
Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
- lokaler Dashboard-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200`
Naechster Bedienablauf, damit die korrekten Summen kommen:
1. App starten bzw. offen lassen: `http://localhost:55416`
2. Im Dashboard neue Daten ziehen:
- entweder `Alle exportieren`
- oder einzelne Standorte per `Export`
3. Danach `Zentrale Datei neu erzeugen` ausfuehren.
4. Oben im Dashboard den Block `Net Sales Actuals 2025 Referenz` pruefen.
5. Entscheidend ist die Spalte `Summenfeld`:
- `Sales Price/Value` = Positionssumme
- `DocTotalFC - VatSumFC` = Netto-Belegsumme in Belegwaehrung, dedupliziert pro Beleg
- `DocTotal - VatSum` = Netto-Belegsumme in Hauswaehrung, dedupliziert pro Beleg
6. `Status = OK` bedeutet: Abweichung zur Referenz maximal 1.
7. `Status = Pruefen` bedeutet: Feld, Datenquelle, Zeitraum oder Standortkonfiguration fachlich kontrollieren.
Wichtig:
- Mit alten zentralen Daten bleiben die neuen B1-Felder leer bzw. `0`.
- Fuer die echte Pruefung von `DocEntry`, `DocTotal*`, `VatSum*`, `DocRate` und `OADM.MainCurncy` muss zuerst neu exportiert werden.
- Fuer neue Jahre ist aktuell noch kein dynamischer Referenzjahres-Schalter eingebaut; der harte Referenzcheck ist Stand jetzt auf `2025`, weil `check.xlsx` die 2025-Referenzen enthaelt.
## Nachtrag 2026-04-29 Export-all-Abbruch / SQLite-FK-Reparatur
Beim Klick auf `Export all` kam:
- `An error occurred while saving the entity changes. See the inner exception for details.`
Ursache:
- die bestehende SQLite-DB hatte in `ExportLogs`, `AppEventLogs` und `CentralSalesRecords` noch Foreign Keys auf `"Sites_repair_old"`
- diese Reparatur-Zwischentabelle existiert nicht mehr
- beim Speichern neuer Logs oder zentraler Datensaetze konnte SQLite deshalb nicht mehr korrekt speichern
Korrektur:
- `DatabaseSchemaMaintenanceService` erkennt jetzt nicht nur `Sites_old`, sondern auch alte Reparaturtabellen wie `Sites_repair_old`
- betroffene Tabellen werden beim App-Start automatisch neu aufgebaut
- `AppEventLogService` und `ExportLogService` fangen eigene Log-Speicherfehler ab, damit Logging-Probleme nicht den ganzen Export abbrechen
- Dashboard-Fehlerausgaben zeigen jetzt auch die Inner Exception, falls vorhanden
Verifikation:
- App neu gestartet
- DB-Schema direkt geprueft:
- `AppEventLogs` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `ExportLogs` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `CentralSalesRecords` -> `FOREIGN KEY (SiteId) REFERENCES Sites (Id)`
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
Direkt danach beobachtete Exportfehler:
- Frankreich/Italien/USA: `invalid schema name ... line 40` durch HANA-Query-Quoting
- Ursache: Query nutzte `"schema"."Tabelle"`
- Korrektur: wieder `schema."Tabelle"` wie im alten funktionierenden Stand
- Indien: `authentication failed`
- Konfiguration/Credentials pruefen, kein Codefehler aus dieser Aenderung
- England/Spanien/Deutschland: `MANUAL_EXCEL`, aber keine manuelle Excel-Datei hinterlegt
- entweder Datei hinterlegen oder Standort deaktivieren/Quellsystem korrigieren
## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder aus HANA ## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder aus HANA
Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert. Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert.
@@ -15,6 +117,7 @@ Grund:
Neue Felder in `SalesRecord` und `CentralSalesRecord`: Neue Felder in `SalesRecord` und `CentralSalesRecord`:
- `DocumentEntry`
- `DocumentCurrency` - `DocumentCurrency`
- `DocumentTotalForeignCurrency` - `DocumentTotalForeignCurrency`
- `DocumentTotalLocalCurrency` - `DocumentTotalLocalCurrency`
@@ -25,6 +128,7 @@ Neue Felder in `SalesRecord` und `CentralSalesRecord`:
B1-Feldmapping: B1-Feldmapping:
- `DocumentEntry` = `OINV/ORIN.DocEntry`
- `DocumentCurrency` = `OINV/ORIN.DocCur` - `DocumentCurrency` = `OINV/ORIN.DocCur`
- `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC` - `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC`
- `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal` - `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal`
@@ -52,8 +156,14 @@ Wichtig fuer Power BI:
- sie werden in der positionsbasierten Excel pro Positionszeile wiederholt - sie werden in der positionsbasierten Excel pro Positionszeile wiederholt
- diese Felder duerfen daher nicht blind positionsweise summiert werden - diese Felder duerfen daher nicht blind positionsweise summiert werden
- fuer Belegkopfsummen in Power BI zuerst nach `DocumentType`, `Invoice Number`, `TSC` und ggf. `Land` deduplizieren - fuer Belegkopfsummen in Power BI zuerst nach `DocumentType`, `Invoice Number`, `TSC` und ggf. `Land` deduplizieren
- besser: nach `TSC` + `DocumentType` + `DocumentEntry` deduplizieren, weil `DocEntry` aus B1 jetzt mitgezogen wird
- positionsbasierte Auswertungen sollen weiterhin mit positionsbezogenen Feldern wie `Sales Price/Value`, `Quantity` oder `Standard cost` arbeiten - positionsbasierte Auswertungen sollen weiterhin mit positionsbezogenen Feldern wie `Sales Price/Value`, `Quantity` oder `Standard cost` arbeiten
Wichtig zum aktuellen Datenbestand:
- alte zentrale Daten wurden vor der Erweiterung exportiert und haben fuer die neuen B1-Felder noch `0`
- nach einem neuen Export/Rebuild der zentralen Daten koennen `DocEntry`, `DocTotal*`, `VatSum*`, `DocRate` und `OADM.MainCurncy` fachlich verglichen werden
Verifikation: Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich - `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
@@ -14,6 +14,7 @@ public class CentralSalesRecord
public string SourceSystem { get; set; } = string.Empty; public string SourceSystem { get; set; } = string.Empty;
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; } public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty; public string Material { get; set; } = string.Empty;
@@ -15,6 +15,7 @@ public class ConfigTransferPackage
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = []; public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = []; public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = []; public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
public List<ConfigTransferManualExcelColumnMapping> ManualExcelColumnMappings { get; set; } = [];
} }
public class ConfigTransferSourceSystemDefinition public class ConfigTransferSourceSystemDefinition
@@ -124,3 +125,13 @@ public class ConfigTransferSapFieldMapping
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
public class ConfigTransferManualExcelColumnMapping
{
public string SiteKey { get; set; } = string.Empty;
public string TargetField { get; set; } = string.Empty;
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ManualExcelColumnMapping
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string SourceHeader { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -4,6 +4,7 @@ public class SalesRecord
{ {
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; } public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty; public string Material { get; set; } = string.Empty;
+62 -1
View File
@@ -2,10 +2,70 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-29 Dashboard-Referenzcheck
Das Dashboard enthaelt jetzt oben einen `Net Sales Actuals 2025`-Referenzcheck gegen die Zahlen aus `check.xlsx` / Power BI Stand 2026-04-29.
Technischer Stand:
- Ist-Wert wird automatisch aus dem besten Kandidaten gegen die Referenz gewaehlt:
- `SalesPriceValue`
- `DocumentTotalForeignCurrency - VatSumForeignCurrency`
- `DocumentTotalLocalCurrency - VatSumLocalCurrency`
- Belegkopfwerte werden per `TSC` + `DocumentType` + `DocumentEntry` dedupliziert; Fallback ist `InvoiceNumber`
- Jahr 2025 ueber `InvoiceDate`, fallback `ExtractionDate`
- Vergleich gegen Power-BI-Wert, falls vorhanden, sonst LC-Referenz
- Dashboard zeigt das verwendete `Summenfeld`
Noch fachlich zu pruefen:
- IT bleibt als bekannter `not ok`-Fall offen
- UK/US bleiben offen, bis die richtige Quelle bzw. Config geklaert ist
- bei weiteren Standorten erst Referenzwert und Datenquelle bestaetigen
- bestehende zentrale Altdaten enthalten fuer die neuen B1-Felder noch `0`; fuer den echten Feldvergleich ist ein neuer Export/Rebuild noetig
Konkreter Ablauf nach Neustart/PC-Absturz:
1. App starten und Dashboard oeffnen: `http://localhost:55416`
2. `Alle exportieren` ausfuehren oder betroffene Standorte einzeln exportieren.
3. Danach `Zentrale Datei neu erzeugen` ausfuehren.
4. Im oberen Dashboard-Block `Net Sales Actuals 2025 Referenz` die Spalte `Summenfeld` kontrollieren.
5. Wenn `Status = OK`, passt die Summe zur hinterlegten Referenz.
6. Wenn `Status = Pruefen`, zuerst kontrollieren:
- richtige Standortquelle/Config
- richtiges Jahr
- ob nach der Codeaenderung wirklich neu exportiert wurde
- ob das gewaehlte Summenfeld fachlich Sinn macht
Naechster technischer Schritt fuer neue Jahre:
- Jahresauswahl im Dashboard einbauen.
- Fuer Jahre ohne Referenz trotzdem Ist-Summen und verwendetes Summenfeld anzeigen.
- Sobald eine neue Referenzdatei fuer 2026/2027 vorliegt, Referenzwerte ergaenzen.
Export-all-Abbruch am 2026-04-29:
- Fehler war SQLite-Schema: `ExportLogs`, `AppEventLogs`, `CentralSalesRecords` zeigten noch auf `"Sites_repair_old"`
- Schema-Reparatur wurde erweitert und beim App-Start erfolgreich angewendet
- gepruefter Zustand danach: alle drei Tabellen referenzieren wieder `Sites`
- Export kann jetzt erneut getestet werden
- falls erneut Fehler kommt, sollte die Snackbar die Inner Exception anzeigen und die Logs sollten nicht mehr selbst den Export abbrechen
Nachtest Export all:
- HANA-Schema-Fehler fuer Frankreich/Italien/USA wurde auf HANA-Quoting zurueckgefuehrt und korrigiert
- Indien bleibt Auth-/Credential-Thema
- England, Spanien und Deutschland sind aktuell `MANUAL_EXCEL` ohne hinterlegte Datei
- Fuer einen sauberen Export-all-Lauf:
- HANA-Standorte mit korrigierter Query nochmals testen
- Indien Credentials pruefen
- manuelle Standorte entweder Datei hinterlegen oder deaktivieren, falls sie nicht im Export-all laufen sollen
## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder ## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder
Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder: Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder:
- `DocEntry`
- `DocCur` - `DocCur`
- `DocTotalFC` - `DocTotalFC`
- `DocTotal` - `DocTotal`
@@ -16,6 +76,7 @@ Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder:
Neue Zielfelder: Neue Zielfelder:
- `DocumentEntry`
- `DocumentCurrency` - `DocumentCurrency`
- `DocumentTotalForeignCurrency` - `DocumentTotalForeignCurrency`
- `DocumentTotalLocalCurrency` - `DocumentTotalLocalCurrency`
@@ -39,7 +100,7 @@ Die neuen `DocumentTotal*`- und `VatSum*`-Werte sind Belegkopfwerte und werden i
Power BI: Power BI:
- nicht positionsweise summieren - nicht positionsweise summieren
- zuerst nach Beleg deduplizieren, z. B. `TSC` + `DocumentType` + `Invoice Number` - zuerst nach Beleg deduplizieren, bevorzugt `TSC` + `DocumentType` + `DocumentEntry`
- danach Belegkopfwerte summieren - danach Belegkopfwerte summieren
Positionswerte wie `Sales Price/Value`, `Quantity` und `Standard cost` bleiben fuer positionsbasierte Summen geeignet. Positionswerte wie `Sales Price/Value`, `Quantity` und `Standard cost` bleiben fuer positionsbasierte Summen geeignet.
@@ -7,13 +7,17 @@ namespace TrafagSalesExporter.Services;
public class AppEventLogService : IAppEventLogService public class AppEventLogService : IAppEventLogService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ILogger<AppEventLogService> _logger;
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory) public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory, ILogger<AppEventLogService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_logger = logger;
} }
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null) public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
{
try
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
db.AppEventLogs.Add(new AppEventLog db.AppEventLogs.Add(new AppEventLog
@@ -28,8 +32,15 @@ public class AppEventLogService : IAppEventLogService
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
catch (Exception ex)
{
_logger.LogError(ex, "AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
}
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null) public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
{
try
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync(); var settings = await db.ExportSettings.FirstOrDefaultAsync();
@@ -48,4 +59,9 @@ public class AppEventLogService : IAppEventLogService
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
catch (Exception ex)
{
_logger.LogError(ex, "Debug-AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
}
} }
@@ -64,6 +64,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
{ {
ExtractionDate = r.ExtractionDate, ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc, Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber, InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice, PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material, Material = r.Material,
@@ -161,7 +162,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Transaction = transaction; command.Transaction = transaction;
command.CommandText = """ command.CommandText = """
INSERT INTO CentralSalesRecords ( INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice, StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, DocumentEntry, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry, Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
@@ -169,7 +170,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
) )
VALUES ( VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice, $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry, $material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
@@ -183,6 +184,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$sourceSystem", SqliteType.Text); command.Parameters.Add("$sourceSystem", SqliteType.Text);
command.Parameters.Add("$extractionDate", SqliteType.Text); command.Parameters.Add("$extractionDate", SqliteType.Text);
command.Parameters.Add("$tsc", SqliteType.Text); command.Parameters.Add("$tsc", SqliteType.Text);
command.Parameters.Add("$documentEntry", SqliteType.Integer);
command.Parameters.Add("$invoiceNumber", SqliteType.Text); command.Parameters.Add("$invoiceNumber", SqliteType.Text);
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer); command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
command.Parameters.Add("$material", SqliteType.Text); command.Parameters.Add("$material", SqliteType.Text);
@@ -225,6 +227,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$sourceSystem"].Value = sourceSystem; command.Parameters["$sourceSystem"].Value = sourceSystem;
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O"); command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty; command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
command.Parameters["$documentEntry"].Value = record.DocumentEntry;
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty; command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice; command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
command.Parameters["$material"].Value = record.Material ?? string.Empty; command.Parameters["$material"].Value = record.Material ?? string.Empty;
@@ -32,6 +32,7 @@ public class ConfigTransferService : IConfigTransferService
var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var manualExcelMappings = await db.ManualExcelColumnMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N")); var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N")); var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
@@ -148,6 +149,15 @@ public class ConfigTransferService : IConfigTransferService
IsRequired = m.IsRequired, IsRequired = m.IsRequired,
IsActive = m.IsActive, IsActive = m.IsActive,
SortOrder = m.SortOrder SortOrder = m.SortOrder
}).ToList(),
ManualExcelColumnMappings = manualExcelMappings.Select(m => new ConfigTransferManualExcelColumnMapping
{
SiteKey = siteKeyMap[m.SiteId],
TargetField = m.TargetField,
SourceHeader = m.SourceHeader,
IsRequired = m.IsRequired,
IsActive = m.IsActive,
SortOrder = m.SortOrder
}).ToList() }).ToList()
}; };
@@ -173,6 +183,7 @@ public class ConfigTransferService : IConfigTransferService
var existingSapSources = await db.SapSourceDefinitions.ToListAsync(); var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync(); var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
var existingSapMappings = await db.SapFieldMappings.ToListAsync(); var existingSapMappings = await db.SapFieldMappings.ToListAsync();
var existingManualExcelMappings = await db.ManualExcelColumnMappings.ToListAsync();
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty; var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary( var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
@@ -187,6 +198,7 @@ public class ConfigTransferService : IConfigTransferService
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem)); x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings); if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
if (existingManualExcelMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(existingManualExcelMappings);
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
@@ -314,6 +326,7 @@ public class ConfigTransferService : IConfigTransferService
SourceSystem = record.SourceSystem, SourceSystem = record.SourceSystem,
ExtractionDate = record.ExtractionDate, ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc, Tsc = record.Tsc,
DocumentEntry = record.DocumentEntry,
InvoiceNumber = record.InvoiceNumber, InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice, PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material, Material = record.Material,
@@ -414,6 +427,21 @@ public class ConfigTransferService : IConfigTransferService
})); }));
} }
if (package.ManualExcelColumnMappings.Count > 0)
{
db.ManualExcelColumnMappings.AddRange(package.ManualExcelColumnMappings
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new ManualExcelColumnMapping
{
SiteId = siteIdMap[x.SiteKey],
TargetField = x.TargetField,
SourceHeader = x.SourceHeader,
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
@@ -85,6 +85,7 @@ CREATE TABLE CentralSalesRecords (
SourceSystem TEXT NOT NULL, SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL, ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL, Tsc TEXT NOT NULL,
DocumentEntry INTEGER NOT NULL DEFAULT 0,
InvoiceNumber TEXT NOT NULL, InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL, PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL, Material TEXT NOT NULL,
@@ -156,4 +157,16 @@ CREATE TABLE SapFieldMappings (
SortOrder INTEGER NOT NULL DEFAULT 0, SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id) FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);"; );";
internal static string GetManualExcelColumnMappingsCreateSql() => @"
CREATE TABLE ManualExcelColumnMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceHeader TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
} }
@@ -39,7 +39,9 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureSapSourceTable(db); EnsureSapSourceTable(db);
EnsureSapJoinTable(db); EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db); EnsureSapFieldMappingTable(db);
EnsureManualExcelColumnMappingTable(db);
EnsureCentralSalesRecordTable(db); EnsureCentralSalesRecordTable(db);
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
@@ -191,16 +193,19 @@ FROM Sites_old;";
("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()), ("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()),
("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()), ("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()),
("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()), ("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()),
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql()) ("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql()),
("ManualExcelColumnMappings", DatabaseSchemaSql.GetManualExcelColumnMappingsCreateSql())
}; };
foreach (var (tableName, createSql) in siteDependentTables) foreach (var (tableName, createSql) in siteDependentTables)
{ {
if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old")) if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old") ||
DatabaseSchemaTools.TableReferencesObsoleteTable(conn, tableName, "Sites"))
DatabaseSchemaTools.RebuildTable(conn, tableName, createSql); DatabaseSchemaTools.RebuildTable(conn, tableName, createSql);
} }
if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old")) if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old") ||
DatabaseSchemaTools.TableReferencesObsoleteTable(conn, "Sites", "HanaServers"))
DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql()); DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql());
} }
@@ -309,6 +314,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureManualExcelColumnMappingTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetManualExcelColumnMappingsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureCentralSalesRecordTable(AppDbContext db) private static void EnsureCentralSalesRecordTable(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -369,6 +385,25 @@ internal static class DatabaseSchemaTools
return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase); return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
} }
internal static bool TableReferencesObsoleteTable(System.Data.Common.DbConnection connection, string tableName, string currentTableName)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
var parameter = command.CreateParameter();
parameter.ParameterName = "$tableName";
parameter.Value = tableName;
command.Parameters.Add(parameter);
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
var obsoletePrefix = $"{currentTableName}_";
return sql.Contains($"REFERENCES {obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES \"{obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES [{obsoletePrefix}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"REFERENCES `{obsoletePrefix}", StringComparison.OrdinalIgnoreCase);
}
internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql) internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
{ {
using var disableFk = connection.CreateCommand(); using var disableFk = connection.CreateCommand();
@@ -42,6 +42,7 @@ public class ExcelExportService : IExcelExportService
{ {
"extraction date", "extraction date",
"TSC", "TSC",
"Document Entry",
"Invoice Number", "Invoice Number",
"Position on invoice", "Position on invoice",
"Material", "Material",
@@ -86,37 +87,38 @@ public class ExcelExportService : IExcelExportService
{ {
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc; ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber; ws.Cell(row, 3).Value = record.DocumentEntry;
ws.Cell(row, 4).Value = record.PositionOnInvoice; ws.Cell(row, 4).Value = record.InvoiceNumber;
ws.Cell(row, 5).Value = record.Material; ws.Cell(row, 5).Value = record.PositionOnInvoice;
ws.Cell(row, 6).Value = record.Name; ws.Cell(row, 6).Value = record.Material;
ws.Cell(row, 7).Value = record.ProductGroup; ws.Cell(row, 7).Value = record.Name;
ws.Cell(row, 8).Value = record.Quantity; ws.Cell(row, 8).Value = record.ProductGroup;
ws.Cell(row, 9).Value = record.SupplierNumber; ws.Cell(row, 9).Value = record.Quantity;
ws.Cell(row, 10).Value = record.SupplierName; ws.Cell(row, 10).Value = record.SupplierNumber;
ws.Cell(row, 11).Value = record.SupplierCountry; ws.Cell(row, 11).Value = record.SupplierName;
ws.Cell(row, 12).Value = record.CustomerNumber; ws.Cell(row, 12).Value = record.SupplierCountry;
ws.Cell(row, 13).Value = record.CustomerName; ws.Cell(row, 13).Value = record.CustomerNumber;
ws.Cell(row, 14).Value = record.CustomerCountry; ws.Cell(row, 14).Value = record.CustomerName;
ws.Cell(row, 15).Value = record.CustomerIndustry; ws.Cell(row, 15).Value = record.CustomerCountry;
ws.Cell(row, 16).Value = record.StandardCost; ws.Cell(row, 16).Value = record.CustomerIndustry;
ws.Cell(row, 17).Value = record.StandardCostCurrency; ws.Cell(row, 17).Value = record.StandardCost;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber; ws.Cell(row, 18).Value = record.StandardCostCurrency;
ws.Cell(row, 19).Value = record.SalesPriceValue; ws.Cell(row, 19).Value = record.PurchaseOrderNumber;
ws.Cell(row, 20).Value = record.SalesCurrency; ws.Cell(row, 20).Value = record.SalesPriceValue;
ws.Cell(row, 21).Value = record.DocumentCurrency; ws.Cell(row, 21).Value = record.SalesCurrency;
ws.Cell(row, 22).Value = record.DocumentTotalForeignCurrency; ws.Cell(row, 22).Value = record.DocumentCurrency;
ws.Cell(row, 23).Value = record.DocumentTotalLocalCurrency; ws.Cell(row, 23).Value = record.DocumentTotalForeignCurrency;
ws.Cell(row, 24).Value = record.VatSumForeignCurrency; ws.Cell(row, 24).Value = record.DocumentTotalLocalCurrency;
ws.Cell(row, 25).Value = record.VatSumLocalCurrency; ws.Cell(row, 25).Value = record.VatSumForeignCurrency;
ws.Cell(row, 26).Value = record.DocumentRate; ws.Cell(row, 26).Value = record.VatSumLocalCurrency;
ws.Cell(row, 27).Value = record.CompanyCurrency; ws.Cell(row, 27).Value = record.DocumentRate;
ws.Cell(row, 28).Value = record.Incoterms2020; ws.Cell(row, 28).Value = record.CompanyCurrency;
ws.Cell(row, 29).Value = record.SalesResponsibleEmployee; ws.Cell(row, 29).Value = record.Incoterms2020;
ws.Cell(row, 30).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 31).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 31).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.Land; ws.Cell(row, 32).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.DocumentType; ws.Cell(row, 33).Value = record.Land;
ws.Cell(row, 34).Value = record.DocumentType;
row++; row++;
} }
@@ -7,16 +7,25 @@ namespace TrafagSalesExporter.Services;
public class ExportLogService : IExportLogService public class ExportLogService : IExportLogService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ILogger<ExportLogService> _logger;
public ExportLogService(IDbContextFactory<AppDbContext> dbFactory) public ExportLogService(IDbContextFactory<AppDbContext> dbFactory, ILogger<ExportLogService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_logger = logger;
} }
public async Task WriteAsync(ExportLog log) public async Task WriteAsync(ExportLog log)
{
try
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log); db.ExportLogs.Add(log);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
catch (Exception ex)
{
_logger.LogError(ex, "ExportLog konnte nicht gespeichert werden: {Land} ({TSC})", log.Land, log.TSC);
}
}
} }
@@ -10,6 +10,7 @@ public class ExportOrchestrationService
private readonly ISiteExportService _siteExportService; private readonly ISiteExportService _siteExportService;
private readonly IConsolidatedExportService _consolidatedExportService; private readonly IConsolidatedExportService _consolidatedExportService;
private readonly IExportLogService _exportLogService; private readonly IExportLogService _exportLogService;
private readonly IAppEventLogService _appEventLogService;
public event Action? OnExportStatusChanged; public event Action? OnExportStatusChanged;
@@ -22,12 +23,14 @@ public class ExportOrchestrationService
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
ISiteExportService siteExportService, ISiteExportService siteExportService,
IConsolidatedExportService consolidatedExportService, IConsolidatedExportService consolidatedExportService,
IExportLogService exportLogService) IExportLogService exportLogService,
IAppEventLogService appEventLogService)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_siteExportService = siteExportService; _siteExportService = siteExportService;
_consolidatedExportService = consolidatedExportService; _consolidatedExportService = consolidatedExportService;
_exportLogService = exportLogService; _exportLogService = exportLogService;
_appEventLogService = appEventLogService;
} }
public bool IsExporting(int siteId) public bool IsExporting(int siteId)
@@ -152,6 +155,11 @@ public class ExportOrchestrationService
{ {
return await _consolidatedExportService.ExportAsync(records ?? []); return await _consolidatedExportService.ExportAsync(records ?? []);
} }
catch (Exception ex)
{
await _appEventLogService.WriteAsync("Export", "Zentrale Datei fehlgeschlagen", "Error", details: ex.ToString());
return null;
}
finally finally
{ {
lock (_lock) lock (_lock)
@@ -158,6 +158,7 @@ public class HanaQueryService : IHanaQueryService
{ {
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")), ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")), Tsc = reader.GetString(reader.GetOrdinal("tsc")),
DocumentEntry = Convert.ToInt32(reader["document_entry"]),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
@@ -204,11 +205,12 @@ public class HanaQueryService : IHanaQueryService
private static string GetInvoiceQuery(string schema) private static string GetInvoiceQuery(string schema)
{ {
var quotedSchema = QuoteIdentifier(schema); var schemaPrefix = BuildSchemaPrefix(schema);
return $@" return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
:{TscParameterName} AS tsc, :{TscParameterName} AS tsc,
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS invoice_date,
@@ -240,35 +242,36 @@ SELECT
'' AS incoterms_2020, '' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17 CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {quotedSchema}.""ORDR"" o THEN (SELECT o.""DocDate"" FROM {schemaPrefix}""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"") WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date, ELSE NULL END AS order_date,
'INV' AS doc_type 'INV' AS doc_type
FROM {quotedSchema}.""OINV"" h FROM {schemaPrefix}""OINV"" h
INNER JOIN {quotedSchema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {schemaPrefix}""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
CROSS JOIN {quotedSchema}.""OADM"" adm CROSS JOIN {schemaPrefix}""OADM"" adm
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {quotedSchema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {quotedSchema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {quotedSchema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S' AND sup.""CardType"" = 'S'
LEFT JOIN {quotedSchema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {quotedSchema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
} }
private static string GetCreditNoteQuery(string schema) private static string GetCreditNoteQuery(string schema)
{ {
var quotedSchema = QuoteIdentifier(schema); var schemaPrefix = BuildSchemaPrefix(schema);
return $@" return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
:{TscParameterName} AS tsc, :{TscParameterName} AS tsc,
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS invoice_date,
@@ -299,20 +302,20 @@ SELECT
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date, NULL AS order_date,
'CRN' AS doc_type 'CRN' AS doc_type
FROM {quotedSchema}.""ORIN"" h FROM {schemaPrefix}""ORIN"" h
INNER JOIN {quotedSchema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {schemaPrefix}""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
CROSS JOIN {quotedSchema}.""OADM"" adm CROSS JOIN {schemaPrefix}""OADM"" adm
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {quotedSchema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {quotedSchema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {quotedSchema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S' AND sup.""CardType"" = 'S'
LEFT JOIN {quotedSchema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {quotedSchema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
} }
@@ -328,7 +331,7 @@ ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
private static string BuildQueryLogDetails(string query, string schema, string tsc, DateTime dateFilter) private static string BuildQueryLogDetails(string query, string schema, string tsc, DateTime dateFilter)
=> $"{query}{Environment.NewLine}-- schema={schema}; tsc={tsc}; dateFilter={dateFilter:yyyy-MM-dd}"; => $"{query}{Environment.NewLine}-- schema={schema}; tsc={tsc}; dateFilter={dateFilter:yyyy-MM-dd}";
private static string QuoteIdentifier(string identifier) private static string BuildSchemaPrefix(string identifier)
{ {
var value = identifier?.Trim() ?? string.Empty; var value = identifier?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -340,7 +343,7 @@ ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'."); throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'.");
} }
return $@"""{value}"""; return $"{value}.";
} }
} }
@@ -1,15 +1,23 @@
using System.Globalization; using System.Globalization;
using System.Reflection;
using ClosedXML.Excel; using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class ManualExcelImportService : IManualExcelImportService public class ManualExcelImportService : IManualExcelImportService
{ {
private static readonly Dictionary<string, PropertyInfo> SalesRecordProperties = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, string> HeaderMap = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> HeaderMap = new(StringComparer.OrdinalIgnoreCase)
{ {
["extractiondate"] = nameof(SalesRecord.ExtractionDate), ["extractiondate"] = nameof(SalesRecord.ExtractionDate),
["tsc"] = nameof(SalesRecord.Tsc), ["tsc"] = nameof(SalesRecord.Tsc),
["documententry"] = nameof(SalesRecord.DocumentEntry),
["invoicenumber"] = nameof(SalesRecord.InvoiceNumber), ["invoicenumber"] = nameof(SalesRecord.InvoiceNumber),
["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice), ["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice),
["material"] = nameof(SalesRecord.Material), ["material"] = nameof(SalesRecord.Material),
@@ -47,15 +55,62 @@ public class ManualExcelImportService : IManualExcelImportService
["documenttype"] = nameof(SalesRecord.DocumentType) ["documenttype"] = nameof(SalesRecord.DocumentType)
}; };
public Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site) private readonly IDbContextFactory<AppDbContext>? _dbFactory;
public ManualExcelImportService()
{
}
public ManualExcelImportService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site)
{
var mappings = await LoadMappingsAsync(site.Id);
return ReadSalesRecords(filePath, site, mappings);
}
public Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site, IReadOnlyList<ManualExcelColumnMapping> mappings)
=> Task.FromResult(ReadSalesRecords(filePath, site, mappings));
private async Task<List<ManualExcelColumnMapping>> LoadMappingsAsync(int siteId)
{
if (_dbFactory is null || siteId <= 0)
return [];
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.ManualExcelColumnMappings
.AsNoTracking()
.Where(m => m.SiteId == siteId && m.IsActive)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Id)
.ToListAsync();
}
private static List<SalesRecord> ReadSalesRecords(string filePath, Site site, IReadOnlyList<ManualExcelColumnMapping> mappings)
{ {
using var workbook = new XLWorkbook(filePath); using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.FirstOrDefault() var worksheet = workbook.Worksheets.FirstOrDefault()
?? throw new InvalidOperationException("Die Excel-Datei enthält kein Arbeitsblatt."); ?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
var usedRange = worksheet.RangeUsed() var usedRange = worksheet.RangeUsed()
?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten."); ?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
var headerRow = usedRange.FirstRow(); var headerRow = usedRange.FirstRow();
var activeMappings = mappings
.Where(m => m.IsActive && !string.IsNullOrWhiteSpace(m.TargetField) && !string.IsNullOrWhiteSpace(m.SourceHeader))
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Id)
.ToList();
return activeMappings.Count > 0
? ReadMappedRows(usedRange, headerRow, site, activeMappings)
: ReadDefaultRows(usedRange, headerRow, site);
}
private static List<SalesRecord> ReadDefaultRows(IXLRange usedRange, IXLRangeRow headerRow, Site site)
{
var headerIndexes = BuildHeaderIndexMap(headerRow); var headerIndexes = BuildHeaderIndexMap(headerRow);
var rows = new List<SalesRecord>(); var rows = new List<SalesRecord>();
@@ -68,6 +123,7 @@ public class ManualExcelImportService : IManualExcelImportService
{ {
ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow,
Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC), Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC),
DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentEntry))),
InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)), InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)),
PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))), PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))),
Material = ReadString(headerIndexes, row, nameof(SalesRecord.Material)), Material = ReadString(headerIndexes, row, nameof(SalesRecord.Material)),
@@ -102,7 +158,64 @@ public class ManualExcelImportService : IManualExcelImportService
}); });
} }
return Task.FromResult(rows); return rows;
}
private static List<SalesRecord> ReadMappedRows(
IXLRange usedRange,
IXLRangeRow headerRow,
Site site,
IReadOnlyList<ManualExcelColumnMapping> mappings)
{
var headerIndexes = BuildRawHeaderIndexMap(headerRow);
foreach (var mapping in mappings.Where(m => m.IsRequired))
{
if (mapping.SourceHeader.Trim().StartsWith('='))
continue;
if (!TryResolveHeaderIndex(headerIndexes, mapping.SourceHeader, out _))
throw new InvalidOperationException($"Pflichtspalte '{mapping.SourceHeader}' fuer Zielfeld '{mapping.TargetField}' fehlt.");
}
var rows = new List<SalesRecord>();
foreach (var row in usedRange.RowsUsed().Skip(1))
{
if (IsRowEmpty(row))
continue;
var record = new SalesRecord
{
ExtractionDate = DateTime.UtcNow,
Tsc = site.TSC,
Land = site.Land,
DocumentType = "Manual Excel"
};
foreach (var mapping in mappings)
{
if (!SalesRecordProperties.TryGetValue(mapping.TargetField, out var property))
continue;
var value = ReadMappedValue(headerIndexes, row, mapping.SourceHeader);
SetPropertyValue(record, property, value);
}
if (record.ExtractionDate == default)
record.ExtractionDate = DateTime.UtcNow;
if (string.IsNullOrWhiteSpace(record.Tsc))
record.Tsc = site.TSC;
if (string.IsNullOrWhiteSpace(record.Land))
record.Land = site.Land;
if (string.IsNullOrWhiteSpace(record.DocumentType))
record.DocumentType = "Manual Excel";
if (!IsMeaningfulMappedRecord(record))
continue;
rows.Add(record);
}
return rows;
} }
private static Dictionary<string, int> BuildHeaderIndexMap(IXLRangeRow headerRow) private static Dictionary<string, int> BuildHeaderIndexMap(IXLRangeRow headerRow)
@@ -125,6 +238,41 @@ public class ManualExcelImportService : IManualExcelImportService
return result; return result;
} }
private static Dictionary<string, int> BuildRawHeaderIndexMap(IXLRangeRow headerRow)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var cell in headerRow.CellsUsed())
{
var header = cell.GetString().Trim();
if (string.IsNullOrWhiteSpace(header))
continue;
result[header] = cell.Address.ColumnNumber;
result[NormalizeHeader(header)] = cell.Address.ColumnNumber;
}
return result;
}
private static bool TryResolveHeaderIndex(Dictionary<string, int> headerIndexes, string sourceHeader, out int index)
{
var trimmed = sourceHeader.Trim();
return headerIndexes.TryGetValue(trimmed, out index) ||
headerIndexes.TryGetValue(NormalizeHeader(trimmed), out index);
}
private static object? ReadMappedValue(Dictionary<string, int> headerIndexes, IXLRangeRow row, string sourceHeader)
{
var trimmed = sourceHeader.Trim();
if (trimmed.StartsWith('='))
return trimmed[1..];
return TryResolveHeaderIndex(headerIndexes, trimmed, out var index)
? row.Cell(index).GetFormattedString().Trim()
: null;
}
private static bool IsRowEmpty(IXLRangeRow row) private static bool IsRowEmpty(IXLRangeRow row)
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())); => row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
@@ -148,18 +296,7 @@ public class ManualExcelImportService : IManualExcelImportService
if (cell.TryGetValue<double>(out var doubleValue)) if (cell.TryGetValue<double>(out var doubleValue))
return Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture); return Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture);
var text = cell.GetFormattedString().Trim(); return ParseDecimal(cell.GetFormattedString().Trim());
if (string.IsNullOrWhiteSpace(text))
return 0m;
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;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue))
return decimalValue;
return 0m;
} }
private static DateTime? ReadDate(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName) private static DateTime? ReadDate(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName)
@@ -171,7 +308,65 @@ public class ManualExcelImportService : IManualExcelImportService
if (cell.TryGetValue<DateTime>(out var dateValue)) if (cell.TryGetValue<DateTime>(out var dateValue))
return dateValue; return dateValue;
var text = cell.GetFormattedString().Trim(); return ParseDate(cell.GetFormattedString().Trim());
}
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
{
try
{
var text = value?.ToString()?.Trim() ?? string.Empty;
if (property.PropertyType == typeof(string))
{
property.SetValue(record, text);
return;
}
if (property.PropertyType == typeof(int))
{
property.SetValue(record, (int)Math.Round(ParseDecimal(text)));
return;
}
if (property.PropertyType == typeof(decimal))
{
property.SetValue(record, ParseDecimal(text));
return;
}
if (property.PropertyType == typeof(DateTime?))
{
property.SetValue(record, ParseDate(text));
return;
}
if (property.PropertyType == typeof(DateTime))
property.SetValue(record, ParseDate(text) ?? default);
}
catch
{
// Einzelne fehlerhafte Zellen duerfen den kompletten manuellen Import nicht abbrechen.
}
}
private static decimal ParseDecimal(string text)
{
if (string.IsNullOrWhiteSpace(text))
return 0m;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out var decimalValue))
return decimalValue;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out decimalValue))
return decimalValue;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue))
return decimalValue;
return 0m;
}
private static DateTime? ParseDate(string text)
{
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
return null; return null;
@@ -184,7 +379,7 @@ public class ManualExcelImportService : IManualExcelImportService
"O" "O"
}; };
if (DateTime.TryParseExact(text, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dateValue)) if (DateTime.TryParseExact(text, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dateValue))
return dateValue; return dateValue;
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out dateValue)) if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out dateValue))
return dateValue; return dateValue;
@@ -194,6 +389,12 @@ public class ManualExcelImportService : IManualExcelImportService
return null; return null;
} }
private static bool IsMeaningfulMappedRecord(SalesRecord record)
=> record.PositionOnInvoice != 0 ||
record.Quantity != 0m ||
record.SalesPriceValue != 0m ||
!string.IsNullOrWhiteSpace(record.Material);
private static string NormalizeHeader(string value) private static string NormalizeHeader(string value)
{ {
var chars = value var chars = value
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
@@ -12,11 +13,12 @@ public interface IStandortePageService
Task DeleteServerAsync(HanaServer server); Task DeleteServerAsync(HanaServer server);
Task<ConnectionTestResult> TestServerConnectionAsync(HanaServer server); Task<ConnectionTestResult> TestServerConnectionAsync(HanaServer server);
Task<StandortEditorState> LoadSiteEditorAsync(Site site, IEnumerable<SourceSystemDefinition> sourceSystems); Task<StandortEditorState> LoadSiteEditorAsync(Site site, IEnumerable<SourceSystemDefinition> sourceSystems);
Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<string> sapEntitySetsCache); Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, bool isManualExcelSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<ManualExcelColumnMapping> manualExcelMappings, List<string> sapEntitySetsCache);
Task DeleteSiteAsync(Site site); Task DeleteSiteAsync(Site site);
Task<List<string>> LoadAvailableSchemasAsync(Site site); Task<List<string>> LoadAvailableSchemasAsync(Site site);
Task<SapEntitySetRefreshResult> RefreshSapEntitySetsAsync(Site site); Task<SapEntitySetRefreshResult> RefreshSapEntitySetsAsync(Site site);
Task<SapSourceFieldRefreshResult> RefreshSapSourceFieldsAsync(Site site, List<SapSourceDefinition> sapSources, List<SapFieldMapping> sapMappings); Task<SapSourceFieldRefreshResult> RefreshSapSourceFieldsAsync(Site site, List<SapSourceDefinition> sapSources, List<SapFieldMapping> sapMappings);
Task<List<string>> LoadManualExcelHeadersAsync(string manualImportFilePath);
Task<DateTime> ValidateManualImportPathAsync(string manualImportFilePath); Task<DateTime> ValidateManualImportPathAsync(string manualImportFilePath);
} }
@@ -163,6 +165,7 @@ public sealed class StandortePageService : IStandortePageService
var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToListAsync(); var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToListAsync(); var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToListAsync(); var sapMappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToListAsync();
var manualExcelMappings = await db.ManualExcelColumnMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToListAsync();
return new StandortEditorState return new StandortEditorState
{ {
@@ -188,11 +191,12 @@ public sealed class StandortePageService : IStandortePageService
SapEntitySets = ParseSapEntitySets(site.SapEntitySetsCache), SapEntitySets = ParseSapEntitySets(site.SapEntitySetsCache),
SapSources = sapSources, SapSources = sapSources,
SapJoins = sapJoins, SapJoins = sapJoins,
SapMappings = sapMappings SapMappings = sapMappings,
ManualExcelMappings = manualExcelMappings
}; };
} }
public async Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<string> sapEntitySetsCache) public async Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, bool isManualExcelSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<ManualExcelColumnMapping> manualExcelMappings, List<string> sapEntitySetsCache)
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var serverId = usesHanaConnection ? await ResolveCentralHanaServerIdAsync(db, site) : (int?)null; var serverId = usesHanaConnection ? await ResolveCentralHanaServerIdAsync(db, site) : (int?)null;
@@ -212,6 +216,7 @@ public sealed class StandortePageService : IStandortePageService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await SaveSapConfigurationAsync(db, site.Id, isSapSite, sapSources, sapJoins, sapMappings); await SaveSapConfigurationAsync(db, site.Id, isSapSite, sapSources, sapJoins, sapMappings);
await SaveManualExcelConfigurationAsync(db, site.Id, isManualExcelSite, manualExcelMappings);
} }
public async Task DeleteSiteAsync(Site site) public async Task DeleteSiteAsync(Site site)
@@ -224,10 +229,12 @@ public sealed class StandortePageService : IStandortePageService
var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync(); var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync(); var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync(); var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
var manualMappings = await db.ManualExcelColumnMappings.Where(m => m.SiteId == site.Id).ToListAsync();
var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync(); var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources); if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources);
if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins); if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins);
if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings); if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings);
if (manualMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(manualMappings);
if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows); if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows);
db.Sites.Remove(entity); db.Sites.Remove(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -381,6 +388,59 @@ public sealed class StandortePageService : IStandortePageService
} }
} }
public async Task<List<string>> LoadManualExcelHeadersAsync(string manualImportFilePath)
{
var filePath = await ResolveManualImportFilePathAsync(manualImportFilePath);
var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase);
try
{
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.FirstOrDefault()
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
var usedRange = worksheet.RangeUsed()
?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
return usedRange.FirstRow().CellsUsed()
.Select(cell => cell.GetString().Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
finally
{
if (deleteAfterRead && File.Exists(filePath))
File.Delete(filePath);
}
}
private async Task<string> ResolveManualImportFilePathAsync(string manualImportFilePath)
{
var trimmedPath = manualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(trimmedPath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (File.Exists(trimmedPath))
return trimmedPath;
if (!LooksLikeSharePointReference(trimmedPath))
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {trimmedPath}");
await using var db = await _dbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
return await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath);
}
private static void ApplyServer(HanaServer target, HanaServer source) private static void ApplyServer(HanaServer target, HanaServer source)
{ {
target.SourceSystem = source.SourceSystem; target.SourceSystem = source.SourceSystem;
@@ -452,6 +512,12 @@ public sealed class StandortePageService : IStandortePageService
sapSources[0].IsPrimary = true; sapSources[0].IsPrimary = true;
} }
private static void NormalizeManualExcelMappings(List<ManualExcelColumnMapping> manualExcelMappings)
{
for (var i = 0; i < manualExcelMappings.Count; i++)
manualExcelMappings[i].SortOrder = i;
}
private static async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings) private static async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
{ {
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync(); var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
@@ -475,6 +541,22 @@ public sealed class StandortePageService : IStandortePageService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private static async Task SaveManualExcelConfigurationAsync(AppDbContext db, int siteId, bool isManualExcelSite, List<ManualExcelColumnMapping> manualExcelMappings)
{
var oldMappings = await db.ManualExcelColumnMappings.Where(m => m.SiteId == siteId).ToListAsync();
if (oldMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(oldMappings);
if (isManualExcelSite)
{
NormalizeManualExcelMappings(manualExcelMappings);
foreach (var mapping in manualExcelMappings)
mapping.SiteId = siteId;
db.ManualExcelColumnMappings.AddRange(manualExcelMappings);
}
await db.SaveChangesAsync();
}
private static async Task<int> ResolveCentralHanaServerIdAsync(AppDbContext db, Site site) private static async Task<int> ResolveCentralHanaServerIdAsync(AppDbContext db, Site site)
{ {
site.UsernameOverride = site.UsernameOverride.Trim(); site.UsernameOverride = site.UsernameOverride.Trim();
@@ -507,6 +589,7 @@ public sealed class StandortEditorState
public List<SapSourceDefinition> SapSources { get; set; } = []; public List<SapSourceDefinition> SapSources { get; set; } = [];
public List<SapJoinDefinition> SapJoins { get; set; } = []; public List<SapJoinDefinition> SapJoins { get; set; } = [];
public List<SapFieldMapping> SapMappings { get; set; } = []; public List<SapFieldMapping> SapMappings { get; set; } = [];
public List<ManualExcelColumnMapping> ManualExcelMappings { get; set; } = [];
} }
public sealed class SapEntitySetRefreshResult public sealed class SapEntitySetRefreshResult
@@ -56,6 +56,7 @@ public class CentralSalesRecordServiceTests : IDisposable
{ {
ExtractionDate = new DateTime(2026, 4, 29), ExtractionDate = new DateTime(2026, 4, 29),
Tsc = "TRCH", Tsc = "TRCH",
DocumentEntry = 999,
InvoiceNumber = "1001", InvoiceNumber = "1001",
PositionOnInvoice = 1, PositionOnInvoice = 1,
Material = "MAT", Material = "MAT",
@@ -81,6 +82,7 @@ public class CentralSalesRecordServiceTests : IDisposable
var rows = await service.GetAllAsync(); var rows = await service.GetAllAsync();
var row = Assert.Single(rows); var row = Assert.Single(rows);
Assert.Equal(999, row.DocumentEntry);
Assert.Equal("EUR", row.DocumentCurrency); Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal(100m, row.DocumentTotalForeignCurrency); Assert.Equal(100m, row.DocumentTotalForeignCurrency);
Assert.Equal(95m, row.DocumentTotalLocalCurrency); Assert.Equal(95m, row.DocumentTotalLocalCurrency);
@@ -20,37 +20,38 @@ public class ManualExcelImportServiceTests
WriteHeaders(ws); WriteHeaders(ws);
ws.Cell(2, 1).Value = "15.04.2026 13:45:00"; ws.Cell(2, 1).Value = "15.04.2026 13:45:00";
ws.Cell(2, 2).Value = "TRDE"; ws.Cell(2, 2).Value = "TRDE";
ws.Cell(2, 3).Value = "INV-100"; ws.Cell(2, 3).Value = 12345;
ws.Cell(2, 4).Value = 7; ws.Cell(2, 4).Value = "INV-100";
ws.Cell(2, 5).Value = "MAT-1"; ws.Cell(2, 5).Value = 7;
ws.Cell(2, 6).Value = "Pressure Sensor"; ws.Cell(2, 6).Value = "MAT-1";
ws.Cell(2, 7).Value = "PG-A"; ws.Cell(2, 7).Value = "Pressure Sensor";
ws.Cell(2, 8).Value = 2.5m; ws.Cell(2, 8).Value = "PG-A";
ws.Cell(2, 9).Value = "SUP-1"; ws.Cell(2, 9).Value = 2.5m;
ws.Cell(2, 10).Value = "Supplier"; ws.Cell(2, 10).Value = "SUP-1";
ws.Cell(2, 11).Value = "DE"; ws.Cell(2, 11).Value = "Supplier";
ws.Cell(2, 12).Value = "CUST-1"; ws.Cell(2, 12).Value = "DE";
ws.Cell(2, 13).Value = "Customer"; ws.Cell(2, 13).Value = "CUST-1";
ws.Cell(2, 14).Value = "CH"; ws.Cell(2, 14).Value = "Customer";
ws.Cell(2, 15).Value = "Industry"; ws.Cell(2, 15).Value = "CH";
ws.Cell(2, 16).Value = 10.25m; ws.Cell(2, 16).Value = "Industry";
ws.Cell(2, 17).Value = "EUR"; ws.Cell(2, 17).Value = 10.25m;
ws.Cell(2, 18).Value = "PO-1"; ws.Cell(2, 18).Value = "EUR";
ws.Cell(2, 19).Value = 21.40m; ws.Cell(2, 19).Value = "PO-1";
ws.Cell(2, 20).Value = "EUR"; ws.Cell(2, 20).Value = 21.40m;
ws.Cell(2, 21).Value = "EUR"; ws.Cell(2, 21).Value = "EUR";
ws.Cell(2, 22).Value = 120.50m; ws.Cell(2, 22).Value = "EUR";
ws.Cell(2, 23).Value = 110.25m; ws.Cell(2, 23).Value = 120.50m;
ws.Cell(2, 24).Value = 8.10m; ws.Cell(2, 24).Value = 110.25m;
ws.Cell(2, 25).Value = 7.45m; ws.Cell(2, 25).Value = 8.10m;
ws.Cell(2, 26).Value = 1.0925m; ws.Cell(2, 26).Value = 7.45m;
ws.Cell(2, 27).Value = "CHF"; ws.Cell(2, 27).Value = 1.0925m;
ws.Cell(2, 28).Value = "DAP"; ws.Cell(2, 28).Value = "CHF";
ws.Cell(2, 29).Value = "Alice"; ws.Cell(2, 29).Value = "DAP";
ws.Cell(2, 30).Value = "14.04.2026"; ws.Cell(2, 30).Value = "Alice";
ws.Cell(2, 31).Value = "10.04.2026"; ws.Cell(2, 31).Value = "14.04.2026";
ws.Cell(2, 32).Value = "Deutschland"; ws.Cell(2, 32).Value = "10.04.2026";
ws.Cell(2, 33).Value = "Invoice"; ws.Cell(2, 33).Value = "Deutschland";
ws.Cell(2, 34).Value = "Invoice";
}); });
try try
@@ -61,6 +62,7 @@ public class ManualExcelImportServiceTests
var row = Assert.Single(rows); var row = Assert.Single(rows);
Assert.Equal("TRDE", row.Tsc); Assert.Equal("TRDE", row.Tsc);
Assert.Equal(12345, row.DocumentEntry);
Assert.Equal("INV-100", row.InvoiceNumber); Assert.Equal("INV-100", row.InvoiceNumber);
Assert.Equal(7, row.PositionOnInvoice); Assert.Equal(7, row.PositionOnInvoice);
Assert.Equal("MAT-1", row.Material); Assert.Equal("MAT-1", row.Material);
@@ -98,7 +100,7 @@ public class ManualExcelImportServiceTests
var ws = workbook.Worksheets.Add("Sales"); var ws = workbook.Worksheets.Add("Sales");
WriteHeaders(ws); WriteHeaders(ws);
ws.Cell(2, 3).Value = "INV-200"; ws.Cell(2, 3).Value = "INV-200";
ws.Cell(2, 5).Value = "MAT-2"; ws.Cell(2, 6).Value = "MAT-2";
}); });
try try
@@ -130,9 +132,9 @@ public class ManualExcelImportServiceTests
var ws = workbook.Worksheets.Add("Sales"); var ws = workbook.Worksheets.Add("Sales");
WriteHeaders(ws); WriteHeaders(ws);
ws.Cell(2, 3).Value = "INV-300"; ws.Cell(2, 3).Value = "INV-300";
ws.Cell(2, 8).Value = "1,50"; ws.Cell(2, 9).Value = "1,50";
ws.Cell(2, 16).Value = "3,25"; ws.Cell(2, 17).Value = "3,25";
ws.Cell(2, 19).Value = "7,90"; ws.Cell(2, 20).Value = "7,90";
ws.Cell(3, 1).Value = ""; ws.Cell(3, 1).Value = "";
ws.Cell(3, 2).Value = ""; ws.Cell(3, 2).Value = "";
ws.Cell(3, 3).Value = ""; ws.Cell(3, 3).Value = "";
@@ -186,6 +188,115 @@ public class ManualExcelImportServiceTests
} }
} }
[Fact]
public async Task ReadSalesRecordsAsync_Uses_Configured_Manual_Excel_Mapping_For_German_Headers()
{
var site = new Site
{
TSC = "TRDE",
Land = "Deutschland"
};
var filePath = CreateWorkbook(workbook =>
{
var ws = workbook.Worksheets.Add("Sales");
var headers = new[]
{
"Export-Datum", "Firma", "Belegnummer", "Position", "ArtikelBezeichnung",
"Warengruppen-Bezeichnung", "Anz. VE", "Lieferanten Nummer", "Name Lieferant",
"Land Lieferant", "AdressNummer-Kunde", "Name Kunde", "Land Kunde", "Branche",
"EinstandsPreis", "Währung", "BestellNummer", "NettoPreisEinzelX",
"NettoPreisGesamtX", "Versandbedingung", "AdressNummer_V", "Belegdatum-Rechnung",
"BelegDatum Auftrag", "ArtikelNummer"
};
for (var i = 0; i < headers.Length; i++)
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(2, 1).Value = "28.04.2026";
ws.Cell(2, 3).Value = "RE2610536";
ws.Cell(2, 5).Value = "Kommentar ohne Position";
ws.Cell(3, 1).Value = "28.04.2026";
ws.Cell(3, 3).Value = "RE2610536";
ws.Cell(3, 4).Value = 10;
ws.Cell(3, 5).Value = "Drucktransmitter NAR";
ws.Cell(3, 6).Value = "Drucktransmitter";
ws.Cell(3, 7).Value = 100;
ws.Cell(3, 8).Value = "60000";
ws.Cell(3, 9).Value = "Trafag AG";
ws.Cell(3, 10).Value = "Schweiz";
ws.Cell(3, 11).Value = "11264";
ws.Cell(3, 12).Value = "Hanning & Kahl GmbH & Co KG";
ws.Cell(3, 13).Value = "Deutschland";
ws.Cell(3, 14).Value = "00 Bahn";
ws.Cell(3, 15).Value = 55m;
ws.Cell(3, 16).Value = "EUR";
ws.Cell(3, 18).Value = 82.8m;
ws.Cell(3, 19).Value = "8280.00";
ws.Cell(3, 20).Value = "ab Lager";
ws.Cell(3, 21).Value = "JR";
ws.Cell(3, 22).Value = "27.04.2026";
ws.Cell(3, 23).Value = "09.03.2026";
ws.Cell(3, 24).Value = "8258.85.2317/55441";
});
var mappings = new List<ManualExcelColumnMapping>
{
Map(nameof(SalesRecord.ExtractionDate), "Export-Datum"),
Map(nameof(SalesRecord.InvoiceNumber), "Belegnummer"),
Map(nameof(SalesRecord.PositionOnInvoice), "Position"),
Map(nameof(SalesRecord.Material), "ArtikelNummer"),
Map(nameof(SalesRecord.Name), "ArtikelBezeichnung"),
Map(nameof(SalesRecord.ProductGroup), "Warengruppen-Bezeichnung"),
Map(nameof(SalesRecord.Quantity), "Anz. VE"),
Map(nameof(SalesRecord.SupplierNumber), "Lieferanten Nummer"),
Map(nameof(SalesRecord.SupplierName), "Name Lieferant"),
Map(nameof(SalesRecord.SupplierCountry), "Land Lieferant"),
Map(nameof(SalesRecord.CustomerNumber), "AdressNummer-Kunde"),
Map(nameof(SalesRecord.CustomerName), "Name Kunde"),
Map(nameof(SalesRecord.CustomerCountry), "Land Kunde"),
Map(nameof(SalesRecord.CustomerIndustry), "Branche"),
Map(nameof(SalesRecord.StandardCost), "EinstandsPreis"),
Map(nameof(SalesRecord.StandardCostCurrency), "Währung"),
Map(nameof(SalesRecord.SalesPriceValue), "NettoPreisGesamtX"),
Map(nameof(SalesRecord.SalesCurrency), "Währung"),
Map(nameof(SalesRecord.DocumentCurrency), "Währung"),
Map(nameof(SalesRecord.CompanyCurrency), "Währung"),
Map(nameof(SalesRecord.Incoterms2020), "Versandbedingung"),
Map(nameof(SalesRecord.SalesResponsibleEmployee), "AdressNummer_V"),
Map(nameof(SalesRecord.InvoiceDate), "Belegdatum-Rechnung"),
Map(nameof(SalesRecord.OrderDate), "BelegDatum Auftrag"),
Map(nameof(SalesRecord.DocumentType), "=Manual Excel")
};
try
{
var service = new ManualExcelImportService();
var rows = await service.ReadSalesRecordsAsync(filePath, site, mappings);
var row = Assert.Single(rows);
Assert.Equal("TRDE", row.Tsc);
Assert.Equal("Deutschland", row.Land);
Assert.Equal("RE2610536", row.InvoiceNumber);
Assert.Equal(10, row.PositionOnInvoice);
Assert.Equal("8258.85.2317/55441", row.Material);
Assert.Equal(100m, row.Quantity);
Assert.Equal(55m, row.StandardCost);
Assert.Equal(8280m, row.SalesPriceValue);
Assert.Equal("EUR", row.SalesCurrency);
Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal("EUR", row.CompanyCurrency);
Assert.Equal(new DateTime(2026, 4, 27), row.InvoiceDate);
Assert.Equal(new DateTime(2026, 3, 9), row.OrderDate);
Assert.Equal("Manual Excel", row.DocumentType);
}
finally
{
File.Delete(filePath);
}
}
private static string CreateWorkbook(Action<XLWorkbook> fillWorkbook) private static string CreateWorkbook(Action<XLWorkbook> fillWorkbook)
{ {
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx");
@@ -201,6 +312,7 @@ public class ManualExcelImportServiceTests
{ {
"extraction date", "extraction date",
"TSC", "TSC",
"Document Entry",
"Invoice Number", "Invoice Number",
"Position on invoice", "Position on invoice",
"Material", "Material",
@@ -239,4 +351,12 @@ public class ManualExcelImportServiceTests
ws.Cell(1, i + 1).Value = headers[i]; ws.Cell(1, i + 1).Value = headers[i];
} }
} }
private static ManualExcelColumnMapping Map(string targetField, string sourceHeader)
=> new()
{
TargetField = targetField,
SourceHeader = sourceHeader,
IsActive = true
};
} }
+279
View File
@@ -356,3 +356,282 @@ Bekannte Warnungen:
6. Wenn Regeln bestaetigt sind: 6. Wenn Regeln bestaetigt sind:
- Finance-Probe erweitert anzeigen - Finance-Probe erweitert anzeigen
- spaeter produktiv ins Hauptprogramm uebernehmen - spaeter produktiv ins Hauptprogramm uebernehmen
---
## Nachtrag 2026-05-04: Excel-Spaltenmapper fuer manuelle Land-Excel-Dateien
Ausloeser:
- Deutschland hat ein eigenes Excel-Beispiel geliefert.
- Das Format entspricht nicht dem bisherigen Standard-Excel-Import.
- Ziel war, nicht fuer jedes Land statischen Spezialcode zu schreiben, sondern die Spaltenzuordnung konfigurierbar zu machen.
Beispielhafte deutsche Spalten:
- `Export-Datum`
- `Firma`
- `Belegnummer`
- `Position`
- `ArtikelBezeichnung`
- `Warengruppen-Bezeichnung`
- `Anz. VE`
- `Lieferanten Nummer`
- `Name Lieferant`
- `Land Lieferant`
- `AdressNummer-Kunde`
- `Name Kunde`
- `Land Kunde`
- `Branche`
- `EinstandsPreis`
- `Währung`
- `BestellNummer`
- `NettoPreisEinzelX`
- `NettoPreisGesamtX`
- `Versandbedingung`
- `AdressNummer_V`
- `Belegdatum-Rechnung`
- `BelegDatum Auftrag`
- `ArtikelNummer`
Wichtige fachliche/technische Interpretation fuer Deutschland:
- `NettoPreisGesamtX` wird als `SalesPriceValue` verwendet.
- `Währung` wird fuer `SalesCurrency`, `DocumentCurrency`, `CompanyCurrency` und `StandardCostCurrency` verwendet.
- `Belegdatum-Rechnung` wird als `InvoiceDate` verwendet.
- `BelegDatum Auftrag` wird als `OrderDate` verwendet.
- `ArtikelNummer` wird als `Material` verwendet.
- Kommentar-/Info-Zeilen ohne echte Position und ohne Betrag werden beim Import ignoriert.
## Neue Datenstruktur
Neue Tabelle / neues Model:
```text
ManualExcelColumnMappings
Models/ManualExcelColumnMapping.cs
```
Felder:
- `SiteId`
- `TargetField`
- `SourceHeader`
- `IsRequired`
- `IsActive`
- `SortOrder`
Zweck:
- Pro Standort kann festgelegt werden, welche Excel-Spalte auf welches internes `SalesRecord`-Feld gemappt wird.
- Konstanten sind moeglich, wenn `SourceHeader` mit `=` beginnt, z. B. `=Manual Excel`.
## Geaenderte Hauptlogik
Geaendert:
```text
Services/ManualExcelImportService.cs
```
Neue Logik:
- Beim manuellen Excel-Import werden zuerst aktive `ManualExcelColumnMappings` des Standorts geladen.
- Wenn Mapping-Zeilen vorhanden sind, wird dieses Mapping verwendet.
- Wenn kein Mapping vorhanden ist, laeuft weiterhin die bisherige statische Standarderkennung.
- Damit bleiben bestehende manuelle Excel-Imports abwaertskompatibel.
Wichtig:
- Der Mapper ersetzt nicht die fachliche Finanzlogik.
- Er sorgt nur dafuer, dass fremde Excel-Spalten korrekt in die internen Felder geschrieben werden.
- Welche Summe spaeter fuer Finance gilt, muss weiterhin fachlich entschieden werden.
## Geaenderte Standort-UI
Geaendert:
```text
Components/Pages/Standorte.razor
Services/StandortePageService.cs
```
In der Standortbearbeitung fuer manuelle Excel-Standorte gibt es neu:
- Bereich `Excel-Spaltenmapping`
- Button `Spalten aus Excel laden`
- Button `Auto-Match`
- Button `Mapping hinzufuegen`
- Tabelle mit:
- Zielfeld
- Excel-Spalte / Konstante
- Pflicht
- Aktiv
- Loeschen
Auto-Match erkennt aktuell u. a. die deutschen Spalten und schlaegt passende Zuordnungen vor.
## Config-Export / Import
Geaendert:
```text
Services/ConfigTransferService.cs
Models/ConfigTransferPackage.cs
```
Neu:
- `ManualExcelColumnMappings` werden im Konfigurationspaket mit exportiert.
- Beim Import werden die Mapping-Zeilen wieder hergestellt.
Damit kann die Konfiguration spaeter zwischen Umgebungen mitgenommen werden.
## Datenbank-Schema
Geaendert:
```text
Data/AppDbContext.cs
Services/DatabaseInitializationService.SchemaSql.cs
Services/DatabaseSchemaMaintenanceService.cs
```
Neu:
- `DbSet<ManualExcelColumnMapping>`
- `CREATE TABLE ManualExcelColumnMappings`
- Schema-Wartung legt die Tabelle nachtraeglich an, falls sie in einer bestehenden DB fehlt.
- Beim Loeschen eines Standorts werden dessen manuelle Excel-Mappings mit geloescht.
## Deutschland lokal eingerichtet
Am 2026-05-04 wurde Deutschland in der lokalen Datenbank direkt ohne UI eingerichtet.
Lokale DB:
```text
C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db
```
Gefundener/konfigurierter Standort:
```text
Id=8
TSC=TRDE
Land=Deutschland
SourceSystem=MANUAL_EXCEL
```
Aktive Mapping-Zeilen:
```text
26
```
Konkrete Zuordnung fuer DE:
```text
ExtractionDate <- Export-Datum
InvoiceNumber <- Belegnummer
PositionOnInvoice <- Position
Material <- ArtikelNummer
Name <- ArtikelBezeichnung
ProductGroup <- Warengruppen-Bezeichnung
Quantity <- Anz. VE
SupplierNumber <- Lieferanten Nummer
SupplierName <- Name Lieferant
SupplierCountry <- Land Lieferant
CustomerNumber <- AdressNummer-Kunde
CustomerName <- Name Kunde
CustomerCountry <- Land Kunde
CustomerIndustry <- Branche
StandardCost <- EinstandsPreis
StandardCostCurrency <- Währung
PurchaseOrderNumber <- BestellNummer
SalesPriceValue <- NettoPreisGesamtX
SalesCurrency <- Währung
DocumentCurrency <- Währung
CompanyCurrency <- Währung
Incoterms2020 <- Versandbedingung
SalesResponsibleEmployee <- AdressNummer_V
InvoiceDate <- Belegdatum-Rechnung
OrderDate <- BelegDatum Auftrag
DocumentType <- =Manual Excel
```
Wichtig fuer Rollback/Umzug:
- Diese DE-Einrichtung wurde direkt in `trafag_exporter.db` gespeichert.
- Die DB-Aenderung ist kein Git-Commit-Inhalt, weil SQLite-Datenbankdaten normalerweise nicht sauber versioniert werden.
- Der Code fuer den Mapper ist aktuell im Worktree vorhanden, aber noch nicht committed.
- Wenn die DB zurueckgerollt oder neu erstellt wird, muss das DE-Mapping erneut ueber die UI, Config-Import oder ein Hilfsskript eingerichtet werden.
## Tests
Ergaenzt:
```text
TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs
```
Neuer Test:
```text
ReadSalesRecordsAsync_Uses_Configured_Manual_Excel_Mapping_For_German_Headers
```
Der Test prueft:
- deutsches Excel-Headerformat
- Kommentarzeile ohne echte Position wird ignoriert
- echte Belegposition wird importiert
- `NettoPreisGesamtX` mit Schweizer Tausenderzeichen wird korrekt als Dezimalzahl gelesen
- Waehrung `EUR` wird in Sales-/Document-/Company-Currency uebernommen
- Rechnungsdatum und Auftragsdatum werden korrekt gelesen
Letzter bekannter Teststand nach Mapper-Arbeit:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore
```
Ergebnis:
- Hauptprojekt baut erfolgreich
- FinanceProbe baut erfolgreich
- Tests erfolgreich
- `49/49` Tests gruen
Bekannte Warnung:
- `NU1900`, weil NuGet-Sicherheitsdaten wegen Netzwerk/nuget.org nicht geladen werden konnten
## Aktueller Laufstand
Die Haupt-App war nach der DE-Konfiguration erreichbar:
```text
http://localhost:55416/standorte
HTTP 200
```
Hinweis:
- Der Browser kann geschlossen sein, waehrend der Serverprozess weiterlaeuft.
- Wenn ein Build wegen gesperrter Dateien fehlschlaegt, zuerst den laufenden `TrafagSalesExporter`-Prozess beenden.
## Noch offen nach Excel-Spaltenmapper
1. Mapper-Code committen, sobald der aktuelle Stand als Rollback-Punkt gesichert werden soll.
2. In der Standort-UI Deutschland oeffnen und visuell pruefen, ob die 26 Mapping-Zeilen angezeigt werden.
3. Mit echtem DE-Excel einen Importlauf testen.
4. Danach Finance-Probe erneut pruefen:
- ob DE nicht mehr `Keine Daten` ist
- ob `SalesPriceValue` gegen Soll aus `check.xlsx` passt
5. Falls weitere Laender eigene Excel-Formate liefern:
- nicht statischen Code bauen
- neues Mapping pro Standort pflegen
6. Klaeren, ob DE fachlich `NettoPreisGesamtX` in EUR als Ist-Wert verwenden soll oder ob CHF-Umrechnung noetig ist.