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