Add manual Excel column mapping
This commit is contained in:
@@ -12,6 +12,63 @@
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
|
||||
<MudSpacer />
|
||||
<MudText Typo="Typo.caption">check.xlsx / Power BI Stand 29.04.2026</MudText>
|
||||
</MudStack>
|
||||
<MudTable Items="_netSalesReferenceRows" Dense Hover Striped>
|
||||
<HeaderContent>
|
||||
<MudTh>@T("Firma", "Company")</MudTh>
|
||||
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
|
||||
<MudTh>@T("IC-Abzug", "IC deduction")</MudTh>
|
||||
<MudTh>@T("Ist exkl. IC", "Actual excl. IC")</MudTh>
|
||||
<MudTh>@T("Referenz", "Reference")</MudTh>
|
||||
<MudTh>@T("Summenfeld", "Value field")</MudTh>
|
||||
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||
<MudTh>@T("Differenz", "Difference")</MudTh>
|
||||
<MudTh>@T("Diff exkl. IC", "Diff excl. IC")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
<MudTh>@T("Status", "Status")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
|
||||
<MudTd>@FormatAmount(context.IntercompanyDeduction)</MudTd>
|
||||
<MudTd>@FormatAmount(context.ActualValueExcludingIntercompany)</MudTd>
|
||||
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
|
||||
<MudTd>@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudTd>
|
||||
<MudTd>@context.ReferenceSource</MudTd>
|
||||
<MudTd>@FormatAmount(context.Difference)</MudTd>
|
||||
<MudTd>@FormatAmount(context.DifferenceExcludingIntercompany)</MudTd>
|
||||
<MudTd>@(string.IsNullOrWhiteSpace(context.Currencies) ? "-" : context.Currencies)</MudTd>
|
||||
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||
<MudTd>
|
||||
@if (context.Status == "OK")
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">OK</MudChip>
|
||||
}
|
||||
else if (context.Status == "Pruefen")
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">@T("Pruefen", "Check")</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@T("Keine Daten", "No data")</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
|
||||
@T("Vergleich: Jahr 2025 aus Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Trafag-IT-Abgleich und veraendert die Originaldaten nicht.", "Comparison: year 2025 from Invoice Date, otherwise Extraction Date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current Trafag IT reconciliation and does not change the original data.")
|
||||
</MudAlert>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
@@ -155,6 +212,7 @@
|
||||
@code {
|
||||
private List<DashboardRow> _dashboardRows = new();
|
||||
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
|
||||
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
|
||||
private bool _loading = true;
|
||||
private bool _anyRunning;
|
||||
private CancellationTokenSource? _pollingCts;
|
||||
@@ -171,6 +229,7 @@
|
||||
var state = await DashboardPageActions.LoadAsync();
|
||||
_dashboardRows = state.DashboardRows;
|
||||
_consolidatedRows = state.ConsolidatedRows;
|
||||
_netSalesReferenceRows = state.NetSalesReferenceRows;
|
||||
|
||||
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||
_loading = false;
|
||||
@@ -183,12 +242,25 @@
|
||||
StartPolling();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Orchestrator.ExportAllAsync();
|
||||
await InvokeAsync(async () =>
|
||||
try
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
await Orchestrator.ExportAllAsync();
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
});
|
||||
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
|
||||
}
|
||||
@@ -200,22 +272,33 @@
|
||||
StartPolling();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
||||
await InvokeAsync(async () =>
|
||||
try
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
||||
}
|
||||
else
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning));
|
||||
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
});
|
||||
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
|
||||
@@ -228,22 +311,33 @@
|
||||
StartPolling();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||
await InvokeAsync(async () =>
|
||||
try
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||
|
||||
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
||||
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
||||
}
|
||||
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
||||
}
|
||||
}
|
||||
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
||||
catch (Exception ex)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
||||
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
});
|
||||
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
|
||||
@@ -366,6 +460,12 @@
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string FormatAmount(decimal? value)
|
||||
=> value.HasValue ? value.Value.ToString("N2") : "-";
|
||||
|
||||
private static string FormatException(Exception ex)
|
||||
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
|
||||
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -408,6 +408,63 @@
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText>
|
||||
}
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudText Typo="Typo.h6">Excel-Spaltenmapping</MudText>
|
||||
<MudStack Row Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
|
||||
OnClick="LoadManualExcelHeadersAsync" Disabled="_loadingManualExcelHeaders">
|
||||
@if (_loadingManualExcelHeaders)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||
@("Lade Spalten...")
|
||||
}
|
||||
else
|
||||
{
|
||||
@("Spalten aus Excel laden")
|
||||
}
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
OnClick="AutoMatchManualExcelMappings">
|
||||
Auto-Match
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddManualExcelMapping">Mapping hinzufügen</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption" Class="mb-2">
|
||||
Wenn hier Mappings gepflegt sind, werden diese vor dem Standardformat verwendet. Konstanten sind mit `=Wert` moeglich, z. B. `=Manual Excel`.
|
||||
</MudText>
|
||||
<MudTable Items="_manualExcelMappings" Dense Hover Striped>
|
||||
<HeaderContent>
|
||||
<MudTh>Zielfeld</MudTh>
|
||||
<MudTh>Excel-Spalte / Konstante</MudTh>
|
||||
<MudTh>Pflicht</MudTh>
|
||||
<MudTh>Aktiv</MudTh>
|
||||
<MudTh>Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudSelect @bind-Value="context.TargetField" Dense>
|
||||
@foreach (var field in _salesRecordFields)
|
||||
{
|
||||
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudSelect T="string" @bind-Value="context.SourceHeader" Dense>
|
||||
@foreach (var header in GetAvailableManualExcelHeaders(context.SourceHeader))
|
||||
{
|
||||
<MudSelectItem Value="@header">@header</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudTd>
|
||||
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
|
||||
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
||||
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveManualExcelMapping(context)" /></MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -422,8 +479,8 @@
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
|
||||
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport || _loadingManualExcelHeaders">Abbrechen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders">Speichern</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -439,6 +496,8 @@
|
||||
private List<SapSourceDefinition> _sapSources = [];
|
||||
private List<SapJoinDefinition> _sapJoins = [];
|
||||
private List<SapFieldMapping> _sapMappings = [];
|
||||
private List<ManualExcelColumnMapping> _manualExcelMappings = [];
|
||||
private List<string> _manualExcelHeaders = [];
|
||||
private readonly string[] _salesRecordFields = typeof(SalesRecord)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
@@ -453,6 +512,7 @@
|
||||
private bool _savingSite;
|
||||
private bool _loadingSchemas;
|
||||
private bool _uploadingManualImport;
|
||||
private bool _loadingManualExcelHeaders;
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -565,6 +625,8 @@
|
||||
_sapSources = [];
|
||||
_sapJoins = [];
|
||||
_sapMappings = [];
|
||||
_manualExcelMappings = [];
|
||||
_manualExcelHeaders = [];
|
||||
_siteDialogVisible = true;
|
||||
}
|
||||
|
||||
@@ -582,6 +644,8 @@
|
||||
_sapSources = editorState.SapSources;
|
||||
_sapJoins = editorState.SapJoins;
|
||||
_sapMappings = editorState.SapMappings;
|
||||
_manualExcelMappings = editorState.ManualExcelMappings;
|
||||
_manualExcelHeaders = BuildHeadersFromManualExcelMappings();
|
||||
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
|
||||
_sapSourceFieldMap = BuildSourceFieldMapFromJoins();
|
||||
_siteDialogVisible = true;
|
||||
@@ -595,7 +659,7 @@
|
||||
_savingSite = true;
|
||||
try
|
||||
{
|
||||
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache);
|
||||
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), IsManualExcelSite(), _sapSources, _sapJoins, _sapMappings, _manualExcelMappings, _sapEntitySetsCache);
|
||||
_siteDialogVisible = false;
|
||||
await LoadDataAsync();
|
||||
Snackbar.Add("Standort gespeichert", Severity.Success);
|
||||
@@ -811,7 +875,7 @@
|
||||
|
||||
private void CloseSiteDialog()
|
||||
{
|
||||
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport)
|
||||
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders)
|
||||
return;
|
||||
|
||||
_siteDialogVisible = false;
|
||||
@@ -878,6 +942,170 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadManualExcelHeadersAsync()
|
||||
{
|
||||
if (_loadingManualExcelHeaders)
|
||||
return;
|
||||
|
||||
_loadingManualExcelHeaders = true;
|
||||
try
|
||||
{
|
||||
_manualExcelHeaders = await StandortePageService.LoadManualExcelHeadersAsync(_editingSite.ManualImportFilePath);
|
||||
Snackbar.Add($"{_manualExcelHeaders.Count} Excel-Spalten geladen.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Spalten laden fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadingManualExcelHeaders = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddManualExcelMapping()
|
||||
{
|
||||
_manualExcelMappings.Add(new ManualExcelColumnMapping
|
||||
{
|
||||
TargetField = _salesRecordFields.First(),
|
||||
SourceHeader = GetAvailableManualExcelHeaders(null).FirstOrDefault() ?? string.Empty,
|
||||
IsActive = true,
|
||||
SortOrder = _manualExcelMappings.Count
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveManualExcelMapping(ManualExcelColumnMapping mapping)
|
||||
=> _manualExcelMappings.Remove(mapping);
|
||||
|
||||
private void AutoMatchManualExcelMappings()
|
||||
{
|
||||
if (_manualExcelHeaders.Count == 0)
|
||||
{
|
||||
Snackbar.Add("Bitte zuerst 'Spalten aus Excel laden' ausfuehren.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var suggestions = BuildManualExcelAutoMatchSuggestions();
|
||||
var addedOrUpdated = 0;
|
||||
foreach (var (targetField, sourceHeader) in suggestions)
|
||||
{
|
||||
var existing = _manualExcelMappings.FirstOrDefault(m =>
|
||||
string.Equals(m.TargetField, targetField, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
_manualExcelMappings.Add(new ManualExcelColumnMapping
|
||||
{
|
||||
TargetField = targetField,
|
||||
SourceHeader = sourceHeader,
|
||||
IsActive = true,
|
||||
IsRequired = IsImportantManualExcelField(targetField),
|
||||
SortOrder = _manualExcelMappings.Count
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.SourceHeader = sourceHeader;
|
||||
existing.IsActive = true;
|
||||
}
|
||||
|
||||
addedOrUpdated++;
|
||||
}
|
||||
|
||||
Snackbar.Add(
|
||||
addedOrUpdated == 0 ? "Keine passenden Spalten gefunden." : $"{addedOrUpdated} Mapping-Vorschlaege gesetzt.",
|
||||
addedOrUpdated == 0 ? Severity.Info : Severity.Success);
|
||||
}
|
||||
|
||||
private List<(string TargetField, string SourceHeader)> BuildManualExcelAutoMatchSuggestions()
|
||||
{
|
||||
var headerByNormalized = _manualExcelHeaders
|
||||
.GroupBy(NormalizeHeader, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var aliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[nameof(SalesRecord.ExtractionDate)] = ["Export-Datum", "Extraction Date"],
|
||||
[nameof(SalesRecord.InvoiceNumber)] = ["Belegnummer", "Invoice Number"],
|
||||
[nameof(SalesRecord.PositionOnInvoice)] = ["Position", "Position on invoice"],
|
||||
[nameof(SalesRecord.Material)] = ["ArtikelNummer", "Material", "Groesse"],
|
||||
[nameof(SalesRecord.Name)] = ["ArtikelBezeichnung", "Name"],
|
||||
[nameof(SalesRecord.ProductGroup)] = ["Warengruppen-Bezeichnung", "Product Group"],
|
||||
[nameof(SalesRecord.Quantity)] = ["Anz. VE", "Quantity"],
|
||||
[nameof(SalesRecord.SupplierNumber)] = ["Lieferanten Nummer", "Supplier number"],
|
||||
[nameof(SalesRecord.SupplierName)] = ["Name Lieferant", "Supplier name"],
|
||||
[nameof(SalesRecord.SupplierCountry)] = ["Land Lieferant", "Supplier country"],
|
||||
[nameof(SalesRecord.CustomerNumber)] = ["AdressNummer-Kunde", "Customer number"],
|
||||
[nameof(SalesRecord.CustomerName)] = ["Name Kunde", "Customer name"],
|
||||
[nameof(SalesRecord.CustomerCountry)] = ["Land Kunde", "Customer country"],
|
||||
[nameof(SalesRecord.CustomerIndustry)] = ["Branche", "Customer Industry"],
|
||||
[nameof(SalesRecord.StandardCost)] = ["EinstandsPreis", "Standard cost"],
|
||||
[nameof(SalesRecord.StandardCostCurrency)] = ["Währung", "Waehrung", "Standard Cost Currency"],
|
||||
[nameof(SalesRecord.PurchaseOrderNumber)] = ["BestellNummer", "Purchase Order number"],
|
||||
[nameof(SalesRecord.SalesPriceValue)] = ["NettoPreisGesamtX", "Sales Price/Value"],
|
||||
[nameof(SalesRecord.SalesCurrency)] = ["Währung", "Waehrung", "Sales Currency"],
|
||||
[nameof(SalesRecord.DocumentCurrency)] = ["Währung", "Waehrung", "Document Currency"],
|
||||
[nameof(SalesRecord.CompanyCurrency)] = ["Währung", "Waehrung", "Company Currency"],
|
||||
[nameof(SalesRecord.Incoterms2020)] = ["Versandbedingung", "Incoterms 2020"],
|
||||
[nameof(SalesRecord.SalesResponsibleEmployee)] = ["AdressNummer_V", "Sales responsible employee"],
|
||||
[nameof(SalesRecord.InvoiceDate)] = ["Belegdatum-Rechnung", "invoice date"],
|
||||
[nameof(SalesRecord.OrderDate)] = ["BelegDatum Auftrag", "order date"]
|
||||
};
|
||||
|
||||
var result = new List<(string TargetField, string SourceHeader)>();
|
||||
foreach (var (targetField, sourceAliases) in aliases)
|
||||
{
|
||||
foreach (var alias in sourceAliases)
|
||||
{
|
||||
if (headerByNormalized.TryGetValue(NormalizeHeader(alias), out var actualHeader))
|
||||
{
|
||||
result.Add((targetField, actualHeader));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Add((nameof(SalesRecord.DocumentType), "=Manual Excel"));
|
||||
return result;
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetAvailableManualExcelHeaders(string? currentValue)
|
||||
{
|
||||
var values = new List<string>(_manualExcelHeaders);
|
||||
values.Add("=Manual Excel");
|
||||
if (!string.IsNullOrWhiteSpace(currentValue) && !values.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
|
||||
values.Insert(0, currentValue);
|
||||
|
||||
return values
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x.StartsWith('=') ? 1 : 0)
|
||||
.ThenBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<string> BuildHeadersFromManualExcelMappings()
|
||||
=> _manualExcelMappings
|
||||
.Select(m => m.SourceHeader)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x) && !x.Trim().StartsWith('='))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private static bool IsImportantManualExcelField(string targetField)
|
||||
=> targetField is nameof(SalesRecord.InvoiceNumber) or
|
||||
nameof(SalesRecord.SalesPriceValue) or
|
||||
nameof(SalesRecord.InvoiceDate);
|
||||
|
||||
private static string NormalizeHeader(string value)
|
||||
{
|
||||
var chars = value
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static List<string> ParseSapEntitySets(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -19,5 +19,6 @@ public class AppDbContext : DbContext
|
||||
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
||||
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
|
||||
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,108 @@
|
||||
|
||||
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
|
||||
|
||||
Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert.
|
||||
@@ -15,6 +117,7 @@ Grund:
|
||||
|
||||
Neue Felder in `SalesRecord` und `CentralSalesRecord`:
|
||||
|
||||
- `DocumentEntry`
|
||||
- `DocumentCurrency`
|
||||
- `DocumentTotalForeignCurrency`
|
||||
- `DocumentTotalLocalCurrency`
|
||||
@@ -25,6 +128,7 @@ Neue Felder in `SalesRecord` und `CentralSalesRecord`:
|
||||
|
||||
B1-Feldmapping:
|
||||
|
||||
- `DocumentEntry` = `OINV/ORIN.DocEntry`
|
||||
- `DocumentCurrency` = `OINV/ORIN.DocCur`
|
||||
- `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC`
|
||||
- `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal`
|
||||
@@ -52,8 +156,14 @@ Wichtig fuer Power BI:
|
||||
- sie werden in der positionsbasierten Excel pro Positionszeile wiederholt
|
||||
- diese Felder duerfen daher nicht blind positionsweise summiert werden
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
|
||||
|
||||
@@ -14,6 +14,7 @@ public class CentralSalesRecord
|
||||
public string SourceSystem { get; set; } = string.Empty;
|
||||
public DateTime ExtractionDate { get; set; }
|
||||
public string Tsc { get; set; } = string.Empty;
|
||||
public int DocumentEntry { get; set; }
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public int PositionOnInvoice { get; set; }
|
||||
public string Material { get; set; } = string.Empty;
|
||||
|
||||
@@ -15,6 +15,7 @@ public class ConfigTransferPackage
|
||||
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
|
||||
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
|
||||
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
|
||||
public List<ConfigTransferManualExcelColumnMapping> ManualExcelColumnMappings { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ConfigTransferSourceSystemDefinition
|
||||
@@ -124,3 +125,13 @@ public class ConfigTransferSapFieldMapping
|
||||
public bool IsActive { get; set; } = true;
|
||||
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 string Tsc { get; set; } = string.Empty;
|
||||
public int DocumentEntry { get; set; }
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public int PositionOnInvoice { get; set; }
|
||||
public string Material { get; set; } = string.Empty;
|
||||
|
||||
@@ -2,10 +2,70 @@
|
||||
|
||||
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
|
||||
|
||||
Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder:
|
||||
|
||||
- `DocEntry`
|
||||
- `DocCur`
|
||||
- `DocTotalFC`
|
||||
- `DocTotal`
|
||||
@@ -16,6 +76,7 @@ Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder:
|
||||
|
||||
Neue Zielfelder:
|
||||
|
||||
- `DocumentEntry`
|
||||
- `DocumentCurrency`
|
||||
- `DocumentTotalForeignCurrency`
|
||||
- `DocumentTotalLocalCurrency`
|
||||
@@ -39,7 +100,7 @@ Die neuen `DocumentTotal*`- und `VatSum*`-Werte sind Belegkopfwerte und werden i
|
||||
Power BI:
|
||||
|
||||
- nicht positionsweise summieren
|
||||
- zuerst nach Beleg deduplizieren, z. B. `TSC` + `DocumentType` + `Invoice Number`
|
||||
- zuerst nach Beleg deduplizieren, bevorzugt `TSC` + `DocumentType` + `DocumentEntry`
|
||||
- danach Belegkopfwerte summieren
|
||||
|
||||
Positionswerte wie `Sales Price/Value`, `Quantity` und `Standard cost` bleiben fuer positionsbasierte Summen geeignet.
|
||||
|
||||
@@ -7,45 +7,61 @@ namespace TrafagSalesExporter.Services;
|
||||
public class AppEventLogService : IAppEventLogService
|
||||
{
|
||||
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;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.AppEventLogs.Add(new AppEventLog
|
||||
try
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
|
||||
Category = category?.Trim() ?? string.Empty,
|
||||
SiteId = siteId,
|
||||
Land = land?.Trim() ?? string.Empty,
|
||||
Message = message?.Trim() ?? string.Empty,
|
||||
Details = details?.Trim() ?? string.Empty
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.AppEventLogs.Add(new AppEventLog
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
|
||||
Category = category?.Trim() ?? string.Empty,
|
||||
SiteId = siteId,
|
||||
Land = land?.Trim() ?? string.Empty,
|
||||
Message = message?.Trim() ?? string.Empty,
|
||||
Details = details?.Trim() ?? string.Empty
|
||||
});
|
||||
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)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||
if (settings is null || !settings.DebugLoggingEnabled)
|
||||
return;
|
||||
|
||||
db.AppEventLogs.Add(new AppEventLog
|
||||
try
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = "Debug",
|
||||
Category = category?.Trim() ?? string.Empty,
|
||||
SiteId = siteId,
|
||||
Land = land?.Trim() ?? string.Empty,
|
||||
Message = message?.Trim() ?? string.Empty,
|
||||
Details = details?.Trim() ?? string.Empty
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||
if (settings is null || !settings.DebugLoggingEnabled)
|
||||
return;
|
||||
|
||||
db.AppEventLogs.Add(new AppEventLog
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = "Debug",
|
||||
Category = category?.Trim() ?? string.Empty,
|
||||
SiteId = siteId,
|
||||
Land = land?.Trim() ?? string.Empty,
|
||||
Message = message?.Trim() ?? string.Empty,
|
||||
Details = details?.Trim() ?? string.Empty
|
||||
});
|
||||
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,
|
||||
Tsc = r.Tsc,
|
||||
DocumentEntry = r.DocumentEntry,
|
||||
InvoiceNumber = r.InvoiceNumber,
|
||||
PositionOnInvoice = r.PositionOnInvoice,
|
||||
Material = r.Material,
|
||||
@@ -161,7 +162,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
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,
|
||||
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
|
||||
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
|
||||
@@ -169,7 +170,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
|
||||
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
|
||||
)
|
||||
VALUES (
|
||||
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
|
||||
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
|
||||
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
|
||||
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
|
||||
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
|
||||
@@ -183,6 +184,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
|
||||
command.Parameters.Add("$sourceSystem", SqliteType.Text);
|
||||
command.Parameters.Add("$extractionDate", SqliteType.Text);
|
||||
command.Parameters.Add("$tsc", SqliteType.Text);
|
||||
command.Parameters.Add("$documentEntry", SqliteType.Integer);
|
||||
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
|
||||
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
|
||||
command.Parameters.Add("$material", SqliteType.Text);
|
||||
@@ -225,6 +227,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
|
||||
command.Parameters["$sourceSystem"].Value = sourceSystem;
|
||||
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
|
||||
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
|
||||
command.Parameters["$documentEntry"].Value = record.DocumentEntry;
|
||||
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
|
||||
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
|
||||
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 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 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 siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
|
||||
@@ -148,6 +149,15 @@ public class ConfigTransferService : IConfigTransferService
|
||||
IsRequired = m.IsRequired,
|
||||
IsActive = m.IsActive,
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -173,6 +183,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
|
||||
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
|
||||
var existingSapMappings = await db.SapFieldMappings.ToListAsync();
|
||||
var existingManualExcelMappings = await db.ManualExcelColumnMappings.ToListAsync();
|
||||
|
||||
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
|
||||
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
|
||||
@@ -187,6 +198,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
|
||||
|
||||
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 (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
||||
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
|
||||
@@ -314,6 +326,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
SourceSystem = record.SourceSystem,
|
||||
ExtractionDate = record.ExtractionDate,
|
||||
Tsc = record.Tsc,
|
||||
DocumentEntry = record.DocumentEntry,
|
||||
InvoiceNumber = record.InvoiceNumber,
|
||||
PositionOnInvoice = record.PositionOnInvoice,
|
||||
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 transaction.CommitAsync();
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ CREATE TABLE CentralSalesRecords (
|
||||
SourceSystem TEXT NOT NULL,
|
||||
ExtractionDate TEXT NOT NULL,
|
||||
Tsc TEXT NOT NULL,
|
||||
DocumentEntry INTEGER NOT NULL DEFAULT 0,
|
||||
InvoiceNumber TEXT NOT NULL,
|
||||
PositionOnInvoice INTEGER NOT NULL,
|
||||
Material TEXT NOT NULL,
|
||||
@@ -156,4 +157,16 @@ CREATE TABLE SapFieldMappings (
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
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);
|
||||
EnsureSapJoinTable(db);
|
||||
EnsureSapFieldMappingTable(db);
|
||||
EnsureManualExcelColumnMappingTable(db);
|
||||
EnsureCentralSalesRecordTable(db);
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
|
||||
@@ -191,16 +193,19 @@ FROM Sites_old;";
|
||||
("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()),
|
||||
("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()),
|
||||
("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()),
|
||||
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql())
|
||||
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql()),
|
||||
("ManualExcelColumnMappings", DatabaseSchemaSql.GetManualExcelColumnMappingsCreateSql())
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -309,6 +314,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
|
||||
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)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
@@ -369,6 +385,25 @@ internal static class DatabaseSchemaTools
|
||||
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)
|
||||
{
|
||||
using var disableFk = connection.CreateCommand();
|
||||
|
||||
@@ -42,6 +42,7 @@ public class ExcelExportService : IExcelExportService
|
||||
{
|
||||
"extraction date",
|
||||
"TSC",
|
||||
"Document Entry",
|
||||
"Invoice Number",
|
||||
"Position on invoice",
|
||||
"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, 2).Value = record.Tsc;
|
||||
ws.Cell(row, 3).Value = record.InvoiceNumber;
|
||||
ws.Cell(row, 4).Value = record.PositionOnInvoice;
|
||||
ws.Cell(row, 5).Value = record.Material;
|
||||
ws.Cell(row, 6).Value = record.Name;
|
||||
ws.Cell(row, 7).Value = record.ProductGroup;
|
||||
ws.Cell(row, 8).Value = record.Quantity;
|
||||
ws.Cell(row, 9).Value = record.SupplierNumber;
|
||||
ws.Cell(row, 10).Value = record.SupplierName;
|
||||
ws.Cell(row, 11).Value = record.SupplierCountry;
|
||||
ws.Cell(row, 12).Value = record.CustomerNumber;
|
||||
ws.Cell(row, 13).Value = record.CustomerName;
|
||||
ws.Cell(row, 14).Value = record.CustomerCountry;
|
||||
ws.Cell(row, 15).Value = record.CustomerIndustry;
|
||||
ws.Cell(row, 16).Value = record.StandardCost;
|
||||
ws.Cell(row, 17).Value = record.StandardCostCurrency;
|
||||
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
|
||||
ws.Cell(row, 19).Value = record.SalesPriceValue;
|
||||
ws.Cell(row, 20).Value = record.SalesCurrency;
|
||||
ws.Cell(row, 21).Value = record.DocumentCurrency;
|
||||
ws.Cell(row, 22).Value = record.DocumentTotalForeignCurrency;
|
||||
ws.Cell(row, 23).Value = record.DocumentTotalLocalCurrency;
|
||||
ws.Cell(row, 24).Value = record.VatSumForeignCurrency;
|
||||
ws.Cell(row, 25).Value = record.VatSumLocalCurrency;
|
||||
ws.Cell(row, 26).Value = record.DocumentRate;
|
||||
ws.Cell(row, 27).Value = record.CompanyCurrency;
|
||||
ws.Cell(row, 28).Value = record.Incoterms2020;
|
||||
ws.Cell(row, 29).Value = record.SalesResponsibleEmployee;
|
||||
ws.Cell(row, 30).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 31).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 32).Value = record.Land;
|
||||
ws.Cell(row, 33).Value = record.DocumentType;
|
||||
ws.Cell(row, 3).Value = record.DocumentEntry;
|
||||
ws.Cell(row, 4).Value = record.InvoiceNumber;
|
||||
ws.Cell(row, 5).Value = record.PositionOnInvoice;
|
||||
ws.Cell(row, 6).Value = record.Material;
|
||||
ws.Cell(row, 7).Value = record.Name;
|
||||
ws.Cell(row, 8).Value = record.ProductGroup;
|
||||
ws.Cell(row, 9).Value = record.Quantity;
|
||||
ws.Cell(row, 10).Value = record.SupplierNumber;
|
||||
ws.Cell(row, 11).Value = record.SupplierName;
|
||||
ws.Cell(row, 12).Value = record.SupplierCountry;
|
||||
ws.Cell(row, 13).Value = record.CustomerNumber;
|
||||
ws.Cell(row, 14).Value = record.CustomerName;
|
||||
ws.Cell(row, 15).Value = record.CustomerCountry;
|
||||
ws.Cell(row, 16).Value = record.CustomerIndustry;
|
||||
ws.Cell(row, 17).Value = record.StandardCost;
|
||||
ws.Cell(row, 18).Value = record.StandardCostCurrency;
|
||||
ws.Cell(row, 19).Value = record.PurchaseOrderNumber;
|
||||
ws.Cell(row, 20).Value = record.SalesPriceValue;
|
||||
ws.Cell(row, 21).Value = record.SalesCurrency;
|
||||
ws.Cell(row, 22).Value = record.DocumentCurrency;
|
||||
ws.Cell(row, 23).Value = record.DocumentTotalForeignCurrency;
|
||||
ws.Cell(row, 24).Value = record.DocumentTotalLocalCurrency;
|
||||
ws.Cell(row, 25).Value = record.VatSumForeignCurrency;
|
||||
ws.Cell(row, 26).Value = record.VatSumLocalCurrency;
|
||||
ws.Cell(row, 27).Value = record.DocumentRate;
|
||||
ws.Cell(row, 28).Value = record.CompanyCurrency;
|
||||
ws.Cell(row, 29).Value = record.Incoterms2020;
|
||||
ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
|
||||
ws.Cell(row, 31).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 32).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 33).Value = record.Land;
|
||||
ws.Cell(row, 34).Value = record.DocumentType;
|
||||
row++;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,25 @@ namespace TrafagSalesExporter.Services;
|
||||
public class ExportLogService : IExportLogService
|
||||
{
|
||||
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;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ExportLog log)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.ExportLogs.Add(log);
|
||||
await db.SaveChangesAsync();
|
||||
try
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.ExportLogs.Add(log);
|
||||
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 IConsolidatedExportService _consolidatedExportService;
|
||||
private readonly IExportLogService _exportLogService;
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
|
||||
public event Action? OnExportStatusChanged;
|
||||
|
||||
@@ -22,12 +23,14 @@ public class ExportOrchestrationService
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
ISiteExportService siteExportService,
|
||||
IConsolidatedExportService consolidatedExportService,
|
||||
IExportLogService exportLogService)
|
||||
IExportLogService exportLogService,
|
||||
IAppEventLogService appEventLogService)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_siteExportService = siteExportService;
|
||||
_consolidatedExportService = consolidatedExportService;
|
||||
_exportLogService = exportLogService;
|
||||
_appEventLogService = appEventLogService;
|
||||
}
|
||||
|
||||
public bool IsExporting(int siteId)
|
||||
@@ -152,6 +155,11 @@ public class ExportOrchestrationService
|
||||
{
|
||||
return await _consolidatedExportService.ExportAsync(records ?? []);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _appEventLogService.WriteAsync("Export", "Zentrale Datei fehlgeschlagen", "Error", details: ex.ToString());
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -158,6 +158,7 @@ public class HanaQueryService : IHanaQueryService
|
||||
{
|
||||
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
|
||||
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
|
||||
DocumentEntry = Convert.ToInt32(reader["document_entry"]),
|
||||
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
|
||||
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
|
||||
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)
|
||||
{
|
||||
var quotedSchema = QuoteIdentifier(schema);
|
||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||
return $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
:{TscParameterName} AS tsc,
|
||||
h.""DocEntry"" AS document_entry,
|
||||
h.""DocNum"" AS invoice_number,
|
||||
p.""LineNum"" AS invoice_position,
|
||||
h.""DocDate"" AS invoice_date,
|
||||
@@ -240,35 +242,36 @@ SELECT
|
||||
'' AS incoterms_2020,
|
||||
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||
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"")
|
||||
ELSE NULL END AS order_date,
|
||||
'INV' AS doc_type
|
||||
FROM {quotedSchema}.""OINV"" h
|
||||
INNER JOIN {quotedSchema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
CROSS JOIN {quotedSchema}.""OADM"" adm
|
||||
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {quotedSchema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
FROM {schemaPrefix}""OINV"" h
|
||||
INNER JOIN {schemaPrefix}""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
CROSS JOIN {schemaPrefix}""OADM"" adm
|
||||
LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||
LEFT JOIN {quotedSchema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {quotedSchema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
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'
|
||||
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}
|
||||
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
}
|
||||
|
||||
private static string GetCreditNoteQuery(string schema)
|
||||
{
|
||||
var quotedSchema = QuoteIdentifier(schema);
|
||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||
return $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
:{TscParameterName} AS tsc,
|
||||
h.""DocEntry"" AS document_entry,
|
||||
h.""DocNum"" AS invoice_number,
|
||||
p.""LineNum"" AS invoice_position,
|
||||
h.""DocDate"" AS invoice_date,
|
||||
@@ -299,20 +302,20 @@ SELECT
|
||||
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||
NULL AS order_date,
|
||||
'CRN' AS doc_type
|
||||
FROM {quotedSchema}.""ORIN"" h
|
||||
INNER JOIN {quotedSchema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
CROSS JOIN {quotedSchema}.""OADM"" adm
|
||||
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {quotedSchema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
FROM {schemaPrefix}""ORIN"" h
|
||||
INNER JOIN {schemaPrefix}""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
CROSS JOIN {schemaPrefix}""OADM"" adm
|
||||
LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||
LEFT JOIN {quotedSchema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {quotedSchema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
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'
|
||||
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}
|
||||
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)
|
||||
=> $"{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;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -340,7 +343,7 @@ ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'.");
|
||||
}
|
||||
|
||||
return $@"""{value}""";
|
||||
return $"{value}.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
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)
|
||||
{
|
||||
["extractiondate"] = nameof(SalesRecord.ExtractionDate),
|
||||
["tsc"] = nameof(SalesRecord.Tsc),
|
||||
["documententry"] = nameof(SalesRecord.DocumentEntry),
|
||||
["invoicenumber"] = nameof(SalesRecord.InvoiceNumber),
|
||||
["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice),
|
||||
["material"] = nameof(SalesRecord.Material),
|
||||
@@ -47,15 +55,62 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
["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);
|
||||
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()
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
|
||||
|
||||
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 rows = new List<SalesRecord>();
|
||||
|
||||
@@ -68,6 +123,7 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
{
|
||||
ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow,
|
||||
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)),
|
||||
PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))),
|
||||
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)
|
||||
@@ -125,6 +238,41 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
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)
|
||||
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
|
||||
|
||||
@@ -148,18 +296,7 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
if (cell.TryGetValue<double>(out var doubleValue))
|
||||
return Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture);
|
||||
|
||||
var text = cell.GetFormattedString().Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return 0m;
|
||||
|
||||
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.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;
|
||||
return ParseDecimal(cell.GetFormattedString().Trim());
|
||||
}
|
||||
|
||||
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))
|
||||
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))
|
||||
return null;
|
||||
|
||||
@@ -184,7 +379,7 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
"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;
|
||||
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out dateValue))
|
||||
return dateValue;
|
||||
@@ -194,6 +389,12 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
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)
|
||||
{
|
||||
var chars = value
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
@@ -12,11 +13,12 @@ public interface IStandortePageService
|
||||
Task DeleteServerAsync(HanaServer server);
|
||||
Task<ConnectionTestResult> TestServerConnectionAsync(HanaServer server);
|
||||
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<List<string>> LoadAvailableSchemasAsync(Site site);
|
||||
Task<SapEntitySetRefreshResult> RefreshSapEntitySetsAsync(Site site);
|
||||
Task<SapSourceFieldRefreshResult> RefreshSapSourceFieldsAsync(Site site, List<SapSourceDefinition> sapSources, List<SapFieldMapping> sapMappings);
|
||||
Task<List<string>> LoadManualExcelHeadersAsync(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 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 manualExcelMappings = await db.ManualExcelColumnMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToListAsync();
|
||||
|
||||
return new StandortEditorState
|
||||
{
|
||||
@@ -188,11 +191,12 @@ public sealed class StandortePageService : IStandortePageService
|
||||
SapEntitySets = ParseSapEntitySets(site.SapEntitySetsCache),
|
||||
SapSources = sapSources,
|
||||
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();
|
||||
var serverId = usesHanaConnection ? await ResolveCentralHanaServerIdAsync(db, site) : (int?)null;
|
||||
@@ -212,6 +216,7 @@ public sealed class StandortePageService : IStandortePageService
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await SaveSapConfigurationAsync(db, site.Id, isSapSite, sapSources, sapJoins, sapMappings);
|
||||
await SaveManualExcelConfigurationAsync(db, site.Id, isManualExcelSite, manualExcelMappings);
|
||||
}
|
||||
|
||||
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 joins = await db.SapJoinDefinitions.Where(j => j.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();
|
||||
if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources);
|
||||
if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins);
|
||||
if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings);
|
||||
if (manualMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(manualMappings);
|
||||
if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows);
|
||||
db.Sites.Remove(entity);
|
||||
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)
|
||||
{
|
||||
target.SourceSystem = source.SourceSystem;
|
||||
@@ -452,6 +512,12 @@ public sealed class StandortePageService : IStandortePageService
|
||||
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)
|
||||
{
|
||||
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
|
||||
@@ -475,6 +541,22 @@ public sealed class StandortePageService : IStandortePageService
|
||||
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)
|
||||
{
|
||||
site.UsernameOverride = site.UsernameOverride.Trim();
|
||||
@@ -507,6 +589,7 @@ public sealed class StandortEditorState
|
||||
public List<SapSourceDefinition> SapSources { get; set; } = [];
|
||||
public List<SapJoinDefinition> SapJoins { get; set; } = [];
|
||||
public List<SapFieldMapping> SapMappings { get; set; } = [];
|
||||
public List<ManualExcelColumnMapping> ManualExcelMappings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SapEntitySetRefreshResult
|
||||
|
||||
@@ -56,6 +56,7 @@ public class CentralSalesRecordServiceTests : IDisposable
|
||||
{
|
||||
ExtractionDate = new DateTime(2026, 4, 29),
|
||||
Tsc = "TRCH",
|
||||
DocumentEntry = 999,
|
||||
InvoiceNumber = "1001",
|
||||
PositionOnInvoice = 1,
|
||||
Material = "MAT",
|
||||
@@ -81,6 +82,7 @@ public class CentralSalesRecordServiceTests : IDisposable
|
||||
var rows = await service.GetAllAsync();
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(999, row.DocumentEntry);
|
||||
Assert.Equal("EUR", row.DocumentCurrency);
|
||||
Assert.Equal(100m, row.DocumentTotalForeignCurrency);
|
||||
Assert.Equal(95m, row.DocumentTotalLocalCurrency);
|
||||
|
||||
@@ -20,37 +20,38 @@ public class ManualExcelImportServiceTests
|
||||
WriteHeaders(ws);
|
||||
ws.Cell(2, 1).Value = "15.04.2026 13:45:00";
|
||||
ws.Cell(2, 2).Value = "TRDE";
|
||||
ws.Cell(2, 3).Value = "INV-100";
|
||||
ws.Cell(2, 4).Value = 7;
|
||||
ws.Cell(2, 5).Value = "MAT-1";
|
||||
ws.Cell(2, 6).Value = "Pressure Sensor";
|
||||
ws.Cell(2, 7).Value = "PG-A";
|
||||
ws.Cell(2, 8).Value = 2.5m;
|
||||
ws.Cell(2, 9).Value = "SUP-1";
|
||||
ws.Cell(2, 10).Value = "Supplier";
|
||||
ws.Cell(2, 11).Value = "DE";
|
||||
ws.Cell(2, 12).Value = "CUST-1";
|
||||
ws.Cell(2, 13).Value = "Customer";
|
||||
ws.Cell(2, 14).Value = "CH";
|
||||
ws.Cell(2, 15).Value = "Industry";
|
||||
ws.Cell(2, 16).Value = 10.25m;
|
||||
ws.Cell(2, 17).Value = "EUR";
|
||||
ws.Cell(2, 18).Value = "PO-1";
|
||||
ws.Cell(2, 19).Value = 21.40m;
|
||||
ws.Cell(2, 20).Value = "EUR";
|
||||
ws.Cell(2, 3).Value = 12345;
|
||||
ws.Cell(2, 4).Value = "INV-100";
|
||||
ws.Cell(2, 5).Value = 7;
|
||||
ws.Cell(2, 6).Value = "MAT-1";
|
||||
ws.Cell(2, 7).Value = "Pressure Sensor";
|
||||
ws.Cell(2, 8).Value = "PG-A";
|
||||
ws.Cell(2, 9).Value = 2.5m;
|
||||
ws.Cell(2, 10).Value = "SUP-1";
|
||||
ws.Cell(2, 11).Value = "Supplier";
|
||||
ws.Cell(2, 12).Value = "DE";
|
||||
ws.Cell(2, 13).Value = "CUST-1";
|
||||
ws.Cell(2, 14).Value = "Customer";
|
||||
ws.Cell(2, 15).Value = "CH";
|
||||
ws.Cell(2, 16).Value = "Industry";
|
||||
ws.Cell(2, 17).Value = 10.25m;
|
||||
ws.Cell(2, 18).Value = "EUR";
|
||||
ws.Cell(2, 19).Value = "PO-1";
|
||||
ws.Cell(2, 20).Value = 21.40m;
|
||||
ws.Cell(2, 21).Value = "EUR";
|
||||
ws.Cell(2, 22).Value = 120.50m;
|
||||
ws.Cell(2, 23).Value = 110.25m;
|
||||
ws.Cell(2, 24).Value = 8.10m;
|
||||
ws.Cell(2, 25).Value = 7.45m;
|
||||
ws.Cell(2, 26).Value = 1.0925m;
|
||||
ws.Cell(2, 27).Value = "CHF";
|
||||
ws.Cell(2, 28).Value = "DAP";
|
||||
ws.Cell(2, 29).Value = "Alice";
|
||||
ws.Cell(2, 30).Value = "14.04.2026";
|
||||
ws.Cell(2, 31).Value = "10.04.2026";
|
||||
ws.Cell(2, 32).Value = "Deutschland";
|
||||
ws.Cell(2, 33).Value = "Invoice";
|
||||
ws.Cell(2, 22).Value = "EUR";
|
||||
ws.Cell(2, 23).Value = 120.50m;
|
||||
ws.Cell(2, 24).Value = 110.25m;
|
||||
ws.Cell(2, 25).Value = 8.10m;
|
||||
ws.Cell(2, 26).Value = 7.45m;
|
||||
ws.Cell(2, 27).Value = 1.0925m;
|
||||
ws.Cell(2, 28).Value = "CHF";
|
||||
ws.Cell(2, 29).Value = "DAP";
|
||||
ws.Cell(2, 30).Value = "Alice";
|
||||
ws.Cell(2, 31).Value = "14.04.2026";
|
||||
ws.Cell(2, 32).Value = "10.04.2026";
|
||||
ws.Cell(2, 33).Value = "Deutschland";
|
||||
ws.Cell(2, 34).Value = "Invoice";
|
||||
});
|
||||
|
||||
try
|
||||
@@ -61,6 +62,7 @@ public class ManualExcelImportServiceTests
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal("TRDE", row.Tsc);
|
||||
Assert.Equal(12345, row.DocumentEntry);
|
||||
Assert.Equal("INV-100", row.InvoiceNumber);
|
||||
Assert.Equal(7, row.PositionOnInvoice);
|
||||
Assert.Equal("MAT-1", row.Material);
|
||||
@@ -98,7 +100,7 @@ public class ManualExcelImportServiceTests
|
||||
var ws = workbook.Worksheets.Add("Sales");
|
||||
WriteHeaders(ws);
|
||||
ws.Cell(2, 3).Value = "INV-200";
|
||||
ws.Cell(2, 5).Value = "MAT-2";
|
||||
ws.Cell(2, 6).Value = "MAT-2";
|
||||
});
|
||||
|
||||
try
|
||||
@@ -130,9 +132,9 @@ public class ManualExcelImportServiceTests
|
||||
var ws = workbook.Worksheets.Add("Sales");
|
||||
WriteHeaders(ws);
|
||||
ws.Cell(2, 3).Value = "INV-300";
|
||||
ws.Cell(2, 8).Value = "1,50";
|
||||
ws.Cell(2, 16).Value = "3,25";
|
||||
ws.Cell(2, 19).Value = "7,90";
|
||||
ws.Cell(2, 9).Value = "1,50";
|
||||
ws.Cell(2, 17).Value = "3,25";
|
||||
ws.Cell(2, 20).Value = "7,90";
|
||||
ws.Cell(3, 1).Value = "";
|
||||
ws.Cell(3, 2).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 = "8’280.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)
|
||||
{
|
||||
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx");
|
||||
@@ -201,6 +312,7 @@ public class ManualExcelImportServiceTests
|
||||
{
|
||||
"extraction date",
|
||||
"TSC",
|
||||
"Document Entry",
|
||||
"Invoice Number",
|
||||
"Position on invoice",
|
||||
"Material",
|
||||
@@ -239,4 +351,12 @@ public class ManualExcelImportServiceTests
|
||||
ws.Cell(1, i + 1).Value = headers[i];
|
||||
}
|
||||
}
|
||||
|
||||
private static ManualExcelColumnMapping Map(string targetField, string sourceHeader)
|
||||
=> new()
|
||||
{
|
||||
TargetField = targetField,
|
||||
SourceHeader = sourceHeader,
|
||||
IsActive = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,3 +356,282 @@ Bekannte Warnungen:
|
||||
6. Wenn Regeln bestaetigt sind:
|
||||
- Finance-Probe erweitert anzeigen
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user