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.