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