From dc3fd77c86934b7b7c323f3d7edde8bc74b78438 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 7 May 2026 15:20:54 +0200 Subject: [PATCH] Consolidate mapping and finance configuration --- .../Components/Pages/Standorte.razor | 15 +- TrafagSalesExporter/Data/AppDbContext.cs | 2 + TrafagSalesExporter/HANDOFF_2026-04-15.md | 31 +- TrafagSalesExporter/LLM_SYSTEM_GUIDE.md | 9 + .../Models/ConfigTransferPackage.cs | 22 ++ .../Models/FinanceIntercompanyRule.cs | 11 + .../Models/FinanceReference.cs | 13 + TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 18 ++ TrafagSalesExporter/Program.cs | 1 + .../Services/ConfigTransferService.cs | 59 ++++ .../Services/ConsolidatedExportService.cs | 2 +- ...DatabaseInitializationService.SchemaSql.cs | 22 ++ .../DatabaseSchemaMaintenanceService.cs | 24 ++ .../Services/DatabaseSeedService.cs | 116 ++++++++ .../Services/ExportOrchestrationService.cs | 15 +- .../Services/FinanceReconciliationService.cs | 268 ++++++++--------- .../Services/HanaQueryService.cs | 171 +---------- .../Services/IConsolidatedExportService.cs | 4 +- .../Services/IMappedSalesRecordComposer.cs | 14 + .../Services/MappedSalesRecordComposer.cs | 271 ++++++++++++++++++ .../Services/SapCompositionService.cs | 195 +------------ .../Services/StandortePageService.cs | 79 +++-- .../MappedSalesRecordComposerTests.cs | 118 ++++++++ TrafagSalesExporter/lastchange.md | 45 ++- 24 files changed, 988 insertions(+), 537 deletions(-) create mode 100644 TrafagSalesExporter/Models/FinanceIntercompanyRule.cs create mode 100644 TrafagSalesExporter/Models/FinanceReference.cs create mode 100644 TrafagSalesExporter/Services/IMappedSalesRecordComposer.cs create mode 100644 TrafagSalesExporter/Services/MappedSalesRecordComposer.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/MappedSalesRecordComposerTests.cs diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index b9962ef..bfb9569 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -386,18 +386,18 @@ } else if (IsManualExcelSite()) { - Manueller Excel-Import + Manueller Excel-/CSV-Import - Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. + Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-/CSV-Datei gelesen und in `CentralSalesRecords` übernommen. - Pfad pruefen - + @if (_uploadingManualImport) { Datei wird hochgeladen... @@ -907,9 +907,10 @@ try { var extension = Path.GetExtension(file.Name); - if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen."); + throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv auswaehlen."); } var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs index e4df8d2..21bd8a6 100644 --- a/TrafagSalesExporter/Data/AppDbContext.cs +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -16,6 +16,8 @@ public class AppDbContext : DbContext public DbSet AppEventLogs => Set(); public DbSet FieldTransformationRules => Set(); public DbSet CurrencyExchangeRates => Set(); + public DbSet FinanceReferences => Set(); + public DbSet FinanceIntercompanyRules => Set(); public DbSet SapSourceDefinitions => Set(); public DbSet SapJoinDefinitions => Set(); public DbSet SapFieldMappings => Set(); diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index e69b510..080695f 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,35 @@ Stand: 2026-05-05 +## Nachtrag 2026-05-07 Mapper-Konsolidierung / Finance-Konfiguration + +Architekturstand: + +- `MappedSalesRecordComposer` ist die gemeinsame Engine fuer grafisches Mapping. +- SAP OData und generisches HANA-Mapping nutzen denselben Composer fuer Joins und Zielfeldmapping. +- SAP OData laedt weiter ueber `SapGatewayService`. +- HANA-Tabellen/Views laden weiter ueber `HanaQueryService`. +- Der alte B1-HANA-Pfad ohne Mapping bleibt als Legacy-Pfad fuer bestehende BI1/SAGE-Standorte erhalten. +- `ConsolidatedExportService.ExportAsync()` erzeugt die zentrale Datei nur noch aus `CentralSalesRecords`; es gibt keinen zweiten Records-Parameter mehr. +- Manual Excel/CSV akzeptiert im Standort-Editor und Upload `.xlsx` und `.csv`. + +Neue Konfigurationstabellen: + +- `FinanceReferences`: Soll-/check.xlsx-Referenzen je Jahr und Land/Key. +- `FinanceIntercompanyRules`: IC-/2nd-party-Regeln nach Scope, Kundennummer oder Namensbestandteil. +- Budgetkurse 2025 liegen als `CurrencyExchangeRates` mit `Notes = Budget 2025`. +- Config-Export/-Import nimmt `FinanceReferences` und `FinanceIntercompanyRules` mit. + +Offen: + +- Manual-Excel-Import hat noch zwei Modi: Header-Automatik und grafisches Mapping. +- Der alte B1-HANA-Spezialpfad ist bewusst noch vorhanden, sollte aber mittelfristig durch gepflegte HANA-Mappings abgeloest werden. + +Verifikation: + +- Hauptprojekt Build erfolgreich. +- Tests `52/52` erfolgreich. + ## Nachtrag 2026-05-07 SAP OData / ZSCHWEIZ / Schweiz-Oesterreich Aktueller Architekturentscheid: @@ -51,7 +80,7 @@ Wichtig fuer naechsten Einstieg: Verifikation: - Hauptprojekt Build erfolgreich. -- Tests `50/50` erfolgreich. +- Tests `52/52` erfolgreich. ## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md index 01c7546..f3c5967 100644 --- a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -41,9 +41,18 @@ Mapper: - SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`. - Direkte HANA-Tabellen/Views koennen dieselben Mapping-Tabellen ebenfalls nutzen. +- Gemeinsame Mapping-Engine ist `MappedSalesRecordComposer`. +- `SapCompositionService` und `HanaQueryService.GetMappedSalesRecordsAsync` unterscheiden sich nur noch in der Quellenbeschaffung; Join und `SalesRecord`-Mapping sind zentral. - Bei HANA mit gepflegten Quellen/Mappings nutzt `HanaDataSourceAdapter` den generischen Mapping-Pfad. - Ohne HANA-Mapping bleibt der alte B1-HANA-Standardpfad fuer `OINV/INV1/ORIN/RIN1` aktiv. +Finance-Konfiguration: + +- `FinanceReferences` enthaelt Soll-/check.xlsx-Referenzen. +- `FinanceIntercompanyRules` enthaelt 2nd-party/IC-Regeln. +- Budgetkurse werden als `CurrencyExchangeRates` mit `Notes = Budget 2025` gepflegt. +- Config-Export/-Import umfasst Finance-Referenzen und IC-Regeln. + ZSCHWEIZ-Seed: - Quelle Alias `Z` diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs index 80ef209..f5afc2a 100644 --- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -9,6 +9,8 @@ public class ConfigTransferPackage public ConfigTransferExportSettings? ExportSettings { get; set; } public List SourceSystemDefinitions { get; set; } = []; public List CurrencyExchangeRates { get; set; } = []; + public List FinanceReferences { get; set; } = []; + public List FinanceIntercompanyRules { get; set; } = []; public List HanaServers { get; set; } = []; public List Sites { get; set; } = []; public List FieldTransformationRules { get; set; } = []; @@ -61,6 +63,26 @@ public class ConfigTransferCurrencyExchangeRate public bool IsActive { get; set; } = true; } +public class ConfigTransferFinanceReference +{ + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public int Year { get; set; } = 2025; + public decimal? LocalCurrencyValue { get; set; } + public decimal? CheckValue { get; set; } + public string Notes { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; +} + +public class ConfigTransferFinanceIntercompanyRule +{ + public string ScopeKey { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string CustomerNameContains { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; +} + public class ConfigTransferHanaServer { public string Key { get; set; } = Guid.NewGuid().ToString("N"); diff --git a/TrafagSalesExporter/Models/FinanceIntercompanyRule.cs b/TrafagSalesExporter/Models/FinanceIntercompanyRule.cs new file mode 100644 index 0000000..d0cc159 --- /dev/null +++ b/TrafagSalesExporter/Models/FinanceIntercompanyRule.cs @@ -0,0 +1,11 @@ +namespace TrafagSalesExporter.Models; + +public class FinanceIntercompanyRule +{ + public int Id { get; set; } + public string ScopeKey { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string CustomerNameContains { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; +} diff --git a/TrafagSalesExporter/Models/FinanceReference.cs b/TrafagSalesExporter/Models/FinanceReference.cs new file mode 100644 index 0000000..03476a0 --- /dev/null +++ b/TrafagSalesExporter/Models/FinanceReference.cs @@ -0,0 +1,13 @@ +namespace TrafagSalesExporter.Models; + +public class FinanceReference +{ + public int Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public int Year { get; set; } = 2025; + public decimal? LocalCurrencyValue { get; set; } + public decimal? CheckValue { get; set; } + public string Notes { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; +} diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 6e8fa3b..733ff6e 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,24 @@ Stand: 2026-05-05 +## Nachtrag 2026-05-07 nach Mapper-/Finance-Aufraeumung + +Erledigt: + +- SAP-OData- und HANA-Mapping laufen ueber `MappedSalesRecordComposer`. +- Doppelte SAP-Mapping-Normalisierung wurde entfernt. +- Konsolidierter Export liest eindeutig aus `CentralSalesRecords`. +- Manuelle Standortdateien duerfen `.xlsx` oder `.csv` sein. +- Finance-Sollwerte, Budgetkurse und Intercompany-Regeln sind DB-Konfiguration mit Seed. + +Naechste technische Schritte: + +1. App neu starten, damit Schema/Seed fuer `FinanceReferences`, `FinanceIntercompanyRules` und Budgetkurse laeuft. +2. In Settings Konfiguration exportieren und pruefen, ob Finance-Referenzen und IC-Regeln enthalten sind. +3. Fuer produktive Pflege spaeter eine kleine UI fuer `FinanceReferences` und `FinanceIntercompanyRules` bauen. +4. Manual Excel als naechsten Aufraeumpunkt vereinheitlichen: Header-Automatik und grafisches Mapping in eine gemeinsame Mapping-Engine ziehen. +5. Bestehende BI1/SAGE-Standorte mittelfristig auf grafisches HANA-Mapping migrieren; erst danach den alten B1-Spezialpfad entfernen. + ## Nachtrag 2026-05-07 ZSCHWEIZ ueber SAP OData Finaler Stand fuer Schweiz/Oesterreich: diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 30c95e0..44710ee 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -52,6 +52,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index 9a97864..b68b67d 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -26,6 +26,15 @@ public class ConfigTransferService : IConfigTransferService .ThenBy(x => x.ToCurrency) .ThenByDescending(x => x.ValidFrom) .ToListAsync(); + var financeReferences = await db.FinanceReferences + .OrderBy(x => x.Year) + .ThenBy(x => x.Key) + .ToListAsync(); + var financeIntercompanyRules = await db.FinanceIntercompanyRules + .OrderBy(x => x.ScopeKey) + .ThenBy(x => x.CustomerNumber) + .ThenBy(x => x.CustomerNameContains) + .ToListAsync(); var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync(); var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync(); var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); @@ -79,6 +88,24 @@ public class ConfigTransferService : IConfigTransferService Notes = rate.Notes, IsActive = rate.IsActive }).ToList(), + FinanceReferences = financeReferences.Select(reference => new ConfigTransferFinanceReference + { + Key = reference.Key, + Label = reference.Label, + Year = reference.Year, + LocalCurrencyValue = reference.LocalCurrencyValue, + CheckValue = reference.CheckValue, + Notes = reference.Notes, + IsActive = reference.IsActive + }).ToList(), + FinanceIntercompanyRules = financeIntercompanyRules.Select(rule => new ConfigTransferFinanceIntercompanyRule + { + ScopeKey = rule.ScopeKey, + CustomerNumber = rule.CustomerNumber, + CustomerNameContains = rule.CustomerNameContains, + Notes = rule.Notes, + IsActive = rule.IsActive + }).ToList(), HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer { Key = serverKeyMap[server.Id], @@ -177,6 +204,8 @@ public class ConfigTransferService : IConfigTransferService var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync(); var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync(); + var existingFinanceReferences = await db.FinanceReferences.ToListAsync(); + var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync(); var existingSites = await db.Sites.ToListAsync(); var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync(); var existingRules = await db.FieldTransformationRules.ToListAsync(); @@ -202,6 +231,10 @@ public class ConfigTransferService : IConfigTransferService if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); + if (package.FinanceReferences.Count > 0 && existingFinanceReferences.Count > 0) + db.FinanceReferences.RemoveRange(existingFinanceReferences); + if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0) + db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules); if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); @@ -262,6 +295,32 @@ public class ConfigTransferService : IConfigTransferService })); } + if (package.FinanceReferences.Count > 0) + { + db.FinanceReferences.AddRange(package.FinanceReferences.Select(reference => new FinanceReference + { + Key = reference.Key, + Label = reference.Label, + Year = reference.Year, + LocalCurrencyValue = reference.LocalCurrencyValue, + CheckValue = reference.CheckValue, + Notes = reference.Notes, + IsActive = reference.IsActive + })); + } + + if (package.FinanceIntercompanyRules.Count > 0) + { + db.FinanceIntercompanyRules.AddRange(package.FinanceIntercompanyRules.Select(rule => new FinanceIntercompanyRule + { + ScopeKey = rule.ScopeKey, + CustomerNumber = rule.CustomerNumber, + CustomerNameContains = rule.CustomerNameContains, + Notes = rule.Notes, + IsActive = rule.IsActive + })); + } + var serverIdMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var server in package.HanaServers) { diff --git a/TrafagSalesExporter/Services/ConsolidatedExportService.cs b/TrafagSalesExporter/Services/ConsolidatedExportService.cs index f7e3597..9be8915 100644 --- a/TrafagSalesExporter/Services/ConsolidatedExportService.cs +++ b/TrafagSalesExporter/Services/ConsolidatedExportService.cs @@ -23,7 +23,7 @@ public class ConsolidatedExportService : IConsolidatedExportService _sharePointService = sharePointService; } - public async Task ExportAsync(List records) + public async Task ExportAsync() { var consolidatedRecords = await _centralSalesRecordService.GetAllAsync(); if (consolidatedRecords.Count == 0) diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index ceea16a..280f5e1 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -169,4 +169,26 @@ CREATE TABLE ManualExcelColumnMappings ( SortOrder INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (SiteId) REFERENCES Sites (Id) );"; + + internal static string GetFinanceReferencesCreateSql() => @" +CREATE TABLE FinanceReferences ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + Key TEXT NOT NULL, + Label TEXT NOT NULL, + Year INTEGER NOT NULL DEFAULT 2025, + LocalCurrencyValue TEXT NULL, + CheckValue TEXT NULL, + Notes TEXT NOT NULL DEFAULT '', + IsActive INTEGER NOT NULL DEFAULT 1 +);"; + + internal static string GetFinanceIntercompanyRulesCreateSql() => @" +CREATE TABLE FinanceIntercompanyRules ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + ScopeKey TEXT NOT NULL DEFAULT '', + CustomerNumber TEXT NOT NULL DEFAULT '', + CustomerNameContains TEXT NOT NULL DEFAULT '', + Notes TEXT NOT NULL DEFAULT '', + IsActive INTEGER NOT NULL DEFAULT 1 +);"; } diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index 79a1465..f9ded6e 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -34,6 +34,8 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic EnsureTransformationTable(db); AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'"); EnsureCurrencyExchangeRateTable(db); + EnsureFinanceReferenceTable(db); + EnsureFinanceIntercompanyRuleTable(db); EnsureSourceSystemDefinitionTable(db); AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''"); EnsureSapSourceTable(db); @@ -292,6 +294,28 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates ( cmd.ExecuteNonQuery(); } + private static void EnsureFinanceReferenceTable(AppDbContext db) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = DatabaseSchemaSql.GetFinanceReferencesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS"); + cmd.ExecuteNonQuery(); + } + + private static void EnsureFinanceIntercompanyRuleTable(AppDbContext db) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = DatabaseSchemaSql.GetFinanceIntercompanyRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS"); + cmd.ExecuteNonQuery(); + } + private static void EnsureSapJoinTable(AppDbContext db) { var conn = db.Database.GetDbConnection(); diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index 20c8c9c..0d857a4 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -14,6 +14,9 @@ public class DatabaseSeedService : IDatabaseSeedService EnsureCentralHanaServerRecords(db); EnsureSpainManualExcelSite(db); EnsureSapODataDachSite(db); + EnsureFinanceReferenceDefaults(db); + EnsureBudgetExchangeRateDefaults(db); + EnsureFinanceIntercompanyRuleDefaults(db); } private static void SeedIfEmpty(AppDbContext db) @@ -464,4 +467,117 @@ public class DatabaseSeedService : IDatabaseSeedService if (changed) db.SaveChanges(); } + + private static void EnsureFinanceReferenceDefaults(AppDbContext db) + { + var defaults = new[] + { + new FinanceReference { Key = "AT", Label = "Trafag AT", Year = 2025, LocalCurrencyValue = 3443863m }, + new FinanceReference { Key = "CH", Label = "Trafag CH", Year = 2025 }, + new FinanceReference { Key = "CN", Label = "Trafag CN", Year = 2025 }, + new FinanceReference { Key = "CZ", Label = "Trafag CZ", Year = 2025, LocalCurrencyValue = 95458782m }, + new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, LocalCurrencyValue = 3635923m }, + new FinanceReference { Key = "ES", Label = "Trafag ES", Year = 2025, LocalCurrencyValue = 3102334m }, + new FinanceReference { Key = "FR", Label = "Trafag FR", Year = 2025, LocalCurrencyValue = 1450582m, CheckValue = 1471218m }, + new FinanceReference { Key = "GFS", Label = "Trafag GfS", Year = 2025, LocalCurrencyValue = 6495513m }, + new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, LocalCurrencyValue = 747341702m, CheckValue = 750936591m }, + new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, LocalCurrencyValue = 7669840m }, + new FinanceReference { Key = "JP", Label = "Trafag JP", Year = 2025, LocalCurrencyValue = 187739814m }, + new FinanceReference { Key = "MS", Label = "Trafag MS", Year = 2025, LocalCurrencyValue = 1850199m }, + new FinanceReference { Key = "MSA", Label = "Trafag MSA", Year = 2025, LocalCurrencyValue = 1445258m }, + new FinanceReference { Key = "PL", Label = "Trafag PL Poltraf", Year = 2025, LocalCurrencyValue = 11279297m }, + new FinanceReference { Key = "RU", Label = "Trafag RU", Year = 2025 }, + new FinanceReference { Key = "UK", Label = "Trafag UK", Year = 2025, LocalCurrencyValue = 3538972m, CheckValue = 3749865m }, + new FinanceReference { Key = "US", Label = "Trafag US", Year = 2025, LocalCurrencyValue = 3896728m, CheckValue = 3749865m } + }; + + var existing = db.FinanceReferences.ToList(); + var changed = false; + foreach (var item in defaults) + { + var current = existing.FirstOrDefault(x => x.Year == item.Year && x.Key == item.Key); + if (current is not null) + continue; + + db.FinanceReferences.Add(item); + changed = true; + } + + if (changed) + db.SaveChanges(); + } + + private static void EnsureBudgetExchangeRateDefaults(AppDbContext db) + { + var defaults = new (string From, string To, decimal Rate)[] + { + ("CHF", "CHF", 1m), + ("USD", "CHF", 0.85m), + ("EUR", "CHF", 0.95m), + ("GBP", "CHF", 1.13m), + ("CNY", "CHF", 1m / 8.50m), + ("INR", "CHF", 1m / 90.91m), + ("CZK", "CHF", 1m / 25.64m), + ("PLN", "CHF", 0.22m), + ("JPY", "CHF", 1m / 156.25m) + }; + + var changed = false; + foreach (var item in defaults) + { + var exists = db.CurrencyExchangeRates.Any(x => + x.FromCurrency == item.From && + x.ToCurrency == item.To && + x.ValidFrom == new DateTime(2025, 1, 1) && + x.Notes == "Budget 2025"); + if (exists) + continue; + + db.CurrencyExchangeRates.Add(new CurrencyExchangeRate + { + FromCurrency = item.From, + ToCurrency = item.To, + Rate = item.Rate, + ValidFrom = new DateTime(2025, 1, 1), + ValidTo = new DateTime(2025, 12, 31), + Notes = "Budget 2025", + IsActive = true + }); + changed = true; + } + + if (changed) + db.SaveChanges(); + } + + private static void EnsureFinanceIntercompanyRuleDefaults(AppDbContext db) + { + var defaults = new[] + { + new FinanceIntercompanyRule { CustomerNameContains = "TRAFAG", Notes = "Default IC name marker" }, + new FinanceIntercompanyRule { CustomerNameContains = "MAGNETIC SENSE", Notes = "Default IC name marker" }, + new FinanceIntercompanyRule { CustomerNameContains = "MAGNETS SENSE", Notes = "Default IC name marker" }, + new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUER SENSORIK", Notes = "Default IC name marker" }, + new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUR SENSORIK", Notes = "Default IC name marker" }, + new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_IT01_0306794", Notes = "IT IC customer number" }, + new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_CH01_0302179", Notes = "IT IC customer number" } + }; + + var changed = false; + foreach (var item in defaults) + { + var exists = db.FinanceIntercompanyRules.Any(x => + x.ScopeKey == item.ScopeKey && + x.CustomerNumber == item.CustomerNumber && + x.CustomerNameContains == item.CustomerNameContains); + if (exists) + continue; + + db.FinanceIntercompanyRules.Add(item); + changed = true; + } + + if (changed) + db.SaveChanges(); + } } diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs index 1180458..e12d900 100644 --- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -69,21 +69,16 @@ public class ExportOrchestrationService { using var db = await _dbFactory.CreateDbContextAsync(); var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); - var consolidatedRecords = new List(); foreach (var site in sites) - { - var result = await ExportSiteAsync(site); - if (result?.Records is { Count: > 0 }) - consolidatedRecords.AddRange(result.Records); - } + await ExportSiteAsync(site); - await RunConsolidatedExportAsync(consolidatedRecords); + await RunConsolidatedExportAsync(); } public async Task ExportConsolidatedOnlyAsync() { - return await RunConsolidatedExportAsync(null); + return await RunConsolidatedExportAsync(); } public async Task ExportSiteByIdAsync(int siteId) @@ -139,7 +134,7 @@ public class ExportOrchestrationService OnExportStatusChanged?.Invoke(); } - private async Task RunConsolidatedExportAsync(List? records) + private async Task RunConsolidatedExportAsync() { lock (_lock) { @@ -153,7 +148,7 @@ public class ExportOrchestrationService try { - return await _consolidatedExportService.ExportAsync(records ?? []); + return await _consolidatedExportService.ExportAsync(); } catch (Exception ex) { diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index e10158d..443fb69 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; namespace TrafagSalesExporter.Services; @@ -12,40 +13,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService { private readonly IDbContextFactory _dbFactory; - private static readonly IReadOnlyList NetSalesReferences = - [ - new("AT", "Trafag AT", 3443863m, null), - new("CH", "Trafag CH", null, null), - new("CN", "Trafag CN", null, null), - new("CZ", "Trafag CZ", 95458782m, null), - new("DE", "Trafag DE", 3635923m, null), - new("ES", "Trafag ES", 3102334m, null), - new("FR", "Trafag FR", 1450582m, 1471218m), - new("GFS", "Trafag GfS", 6495513m, null), - new("IN", "Trafag IN", 747341702m, 750936591m), - new("IT", "Trafag IT", 7669840m, null), - new("JP", "Trafag JP", 187739814m, null), - new("MS", "Trafag MS", 1850199m, null), - new("MSA", "Trafag MSA", 1445258m, null), - new("PL", "Trafag PL Poltraf", 11279297m, null), - new("RU", "Rrafag RU", null, null), - new("UK", "Trafag UK", 3538972m, 3749865m), - new("US", "Traga US", 3896728m, 3749865m) - ]; - - private static readonly IReadOnlyDictionary BudgetRatesToChf = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["CHF"] = 1m, - ["USD"] = 0.85m, - ["EUR"] = 0.95m, - ["GBP"] = 1.13m, - ["CNY"] = 1m / 8.50m, - ["INR"] = 1m / 90.91m, - ["CZK"] = 1m / 25.64m, - ["PLN"] = 0.22m, - ["JPY"] = 1m / 156.25m - }; - public FinanceReconciliationService(IDbContextFactory dbFactory) { _dbFactory = dbFactory; @@ -54,6 +21,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService public async Task> BuildNetSalesReferenceRowsAsync(int year = 2025) { await using var db = await _dbFactory.CreateDbContextAsync(); + var financeReferences = await db.FinanceReferences + .AsNoTracking() + .Where(r => r.IsActive && r.Year == year) + .OrderBy(r => r.Label) + .ToListAsync(); + var budgetRatesToChf = await LoadBudgetRatesToChfAsync(db, year); + var intercompanyRules = await db.FinanceIntercompanyRules + .AsNoTracking() + .Where(r => r.IsActive) + .ToListAsync(); var centralRows = await db.CentralSalesRecords .AsNoTracking() @@ -80,7 +57,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService .GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - BuildNetSalesActual, + rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules), StringComparer.OrdinalIgnoreCase); var activeSiteKeys = (await db.Sites @@ -91,58 +68,89 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService .Select(s => ResolveReferenceKey(s.Land, s.TSC)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - return NetSalesReferences + return financeReferences .Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key)) - .Select(reference => - { - groupedActuals.TryGetValue(reference.Key, out var actual); - var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; - var selected = actual?.Candidates - .OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency") - .ThenByDescending(candidate => candidate.Key == "SalesPriceValue") - .FirstOrDefault(); - var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value; - var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue - ? (decimal?)null - : selected.ValueExcludingIntercompany - referenceValue.Value; - - return new NetSalesReferenceRow - { - Key = reference.Key, - Label = reference.Label, - ActualValue = selected?.Value, - IntercompanyDeduction = selected?.IntercompanyValue, - ActualValueExcludingIntercompany = selected?.ValueExcludingIntercompany, - ReferenceValue = referenceValue, - Difference = difference, - DifferenceExcludingIntercompany = intercompanyAdjustedDifference, - RowCount = actual?.RowCount ?? 0, - Currencies = actual?.Currencies ?? string.Empty, - ValueField = selected?.Label ?? string.Empty, - ActualCurrency = selected?.Currency ?? string.Empty, - ReferenceSource = "check.xlsx Soll", - ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC", - Status = BuildReferenceStatus(difference), - Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow - { - Key = candidate.Key, - Label = candidate.Label, - Currency = candidate.Currency, - Value = candidate.Value, - IntercompanyValue = candidate.IntercompanyValue, - ValueExcludingIntercompany = candidate.ValueExcludingIntercompany, - Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null, - DifferenceExcludingIntercompany = referenceValue.HasValue - ? candidate.ValueExcludingIntercompany - referenceValue.Value - : null - }).ToList() ?? [] - }; - }) + .Select(reference => BuildReferenceRow(reference, groupedActuals)) .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) .ToList(); } - private static NetSalesActual BuildNetSalesActual(IEnumerable rows) + private static NetSalesReferenceRow BuildReferenceRow( + FinanceReference reference, + IReadOnlyDictionary groupedActuals) + { + groupedActuals.TryGetValue(reference.Key, out var actual); + var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue; + var selected = actual?.Candidates + .OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency") + .ThenByDescending(candidate => candidate.Key == "SalesPriceValue") + .FirstOrDefault(); + var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value; + var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue + ? (decimal?)null + : selected.ValueExcludingIntercompany - referenceValue.Value; + + return new NetSalesReferenceRow + { + Key = reference.Key, + Label = reference.Label, + ActualValue = selected?.Value, + IntercompanyDeduction = selected?.IntercompanyValue, + ActualValueExcludingIntercompany = selected?.ValueExcludingIntercompany, + ReferenceValue = referenceValue, + Difference = difference, + DifferenceExcludingIntercompany = intercompanyAdjustedDifference, + RowCount = actual?.RowCount ?? 0, + Currencies = actual?.Currencies ?? string.Empty, + ValueField = selected?.Label ?? string.Empty, + ActualCurrency = selected?.Currency ?? string.Empty, + ReferenceSource = "check.xlsx Soll", + ReferenceCurrency = reference.CheckValue.HasValue ? "Sollwert" : "LC", + Status = BuildReferenceStatus(difference), + Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow + { + Key = candidate.Key, + Label = candidate.Label, + Currency = candidate.Currency, + Value = candidate.Value, + IntercompanyValue = candidate.IntercompanyValue, + ValueExcludingIntercompany = candidate.ValueExcludingIntercompany, + Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null, + DifferenceExcludingIntercompany = referenceValue.HasValue + ? candidate.ValueExcludingIntercompany - referenceValue.Value + : null + }).ToList() ?? [] + }; + } + + private static async Task> LoadBudgetRatesToChfAsync(AppDbContext db, int year) + { + var validFrom = new DateTime(year, 1, 1); + var rates = await db.CurrencyExchangeRates + .AsNoTracking() + .Where(r => r.IsActive && r.Notes == $"Budget {year}" && r.ValidFrom <= validFrom && (!r.ValidTo.HasValue || r.ValidTo >= validFrom)) + .ToListAsync(); + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CHF"] = 1m + }; + + foreach (var rate in rates) + { + if (rate.ToCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase)) + result[rate.FromCurrency] = rate.Rate; + else if (rate.FromCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase) && rate.Rate != 0m) + result[rate.ToCurrency] = 1m / rate.Rate; + } + + return result; + } + + private static NetSalesActual BuildNetSalesActual( + IEnumerable rows, + IReadOnlyDictionary budgetRatesToChf, + IReadOnlyList intercompanyRules) { var rowList = rows.ToList(); var documentRows = rowList @@ -157,7 +165,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService "Sales Price/Value", ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)), rowList.Sum(row => row.SalesPriceValue), - rowList.Where(IsLikelyIntercompanyCustomer).Sum(row => row.SalesPriceValue)) + rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue)) }; var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency); @@ -167,7 +175,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService "DocTotalFC - VatSumFC", ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)), netDocumentForeignCurrency, - documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency))); + documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency))); var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency); if (netDocumentLocalCurrency != 0m) @@ -176,16 +184,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService "Nettofakturawert Hauswaehrung", ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)), netDocumentLocalCurrency, - documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))); + documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))); - var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)); + var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)); if (budgetChf != 0m) candidates.Add(new( "NetDocumentLocalCurrencyBudgetChf", "Nettofakturawert Hauswaehrung -> CHF Budget 2025", "CHF", budgetChf, - documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)))); + documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)))); return new NetSalesActual { @@ -198,14 +206,52 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService }; } - private static decimal ConvertHouseCurrencyNetToBudgetChf(NetSalesActualSourceRow row, decimal value) + private static decimal ConvertHouseCurrencyNetToBudgetChf( + NetSalesActualSourceRow row, + decimal value, + IReadOnlyDictionary budgetRatesToChf) { var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant(); - return BudgetRatesToChf.TryGetValue(currency, out var rate) - ? value * rate - : 0m; + return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m; } + private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList rules) + { + var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty; + var customerName = row.CustomerName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName)) + return false; + + var normalizedCustomerName = NormalizeRuleText(customerName); + var referenceKey = ResolveReferenceKey(row.Land, row.Tsc); + + foreach (var rule in rules) + { + if (!string.IsNullOrWhiteSpace(rule.ScopeKey) && + !rule.ScopeKey.Equals(referenceKey, StringComparison.OrdinalIgnoreCase) && + !rule.ScopeKey.Equals(row.Tsc, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!string.IsNullOrWhiteSpace(rule.CustomerNumber) && + customerNumber.Equals(rule.CustomerNumber.Trim(), StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(rule.CustomerNameContains) && + normalizedCustomerName.Contains(NormalizeRuleText(rule.CustomerNameContains), StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static string NormalizeRuleText(string value) + => (value ?? string.Empty) + .Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase) + .Trim() + .ToUpperInvariant(); + private static string ResolveCurrencyLabel(IEnumerable currencies) { var distinct = currencies @@ -223,40 +269,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService ? $"{tsc}|{documentType}|{documentEntry}" : $"{tsc}|{documentType}|{invoiceNumber}"; - private static bool IsLikelyIntercompanyCustomer(NetSalesActualSourceRow row) - { - var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty; - var customerName = row.CustomerName?.Trim() ?? string.Empty; - - if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName)) - return false; - - var normalizedCustomerName = customerName - .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) - .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) - .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) - .ToUpperInvariant(); - - if (normalizedCustomerName.Contains("TRAFAG", StringComparison.OrdinalIgnoreCase) || - normalizedCustomerName.Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase) || - normalizedCustomerName.Contains("MAGNETS SENSE", StringComparison.OrdinalIgnoreCase) || - normalizedCustomerName.Contains("GESELLSCHAFT FUER SENSORIK", StringComparison.OrdinalIgnoreCase) || - normalizedCustomerName.Contains("GESELLSCHAFT FUR SENSORIK", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase)) - { - return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) || - customerNumber.Equals("C_CH01_0302179", StringComparison.OrdinalIgnoreCase) || - customerName.Equals("TRAFAG ITALIA S.R.L.", StringComparison.OrdinalIgnoreCase) || - customerName.Equals("Trafag AG", StringComparison.OrdinalIgnoreCase); - } - - return false; - } - private static string BuildReferenceStatus(decimal? difference) { if (!difference.HasValue) @@ -315,12 +327,6 @@ public sealed class NetSalesCandidateRow public decimal? DifferenceExcludingIntercompany { get; set; } } -internal sealed record NetSalesReferenceDefinition( - string Key, - string Label, - decimal? LocalCurrencyValue, - decimal? PowerBiValue); - internal sealed class NetSalesActual { public int RowCount { get; set; } diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs index fbc7679..ae1f3ce 100644 --- a/TrafagSalesExporter/Services/HanaQueryService.cs +++ b/TrafagSalesExporter/Services/HanaQueryService.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Sap.Data.Hana; using TrafagSalesExporter.Models; @@ -9,10 +8,12 @@ public class HanaQueryService : IHanaQueryService private const string TscParameterName = "tsc"; private const string DateFilterParameterName = "dateFilter"; private readonly IAppEventLogService _appEventLogService; + private readonly IMappedSalesRecordComposer _composer; - public HanaQueryService(IAppEventLogService appEventLogService) + public HanaQueryService(IAppEventLogService appEventLogService, IMappedSalesRecordComposer composer) { _appEventLogService = appEventLogService; + _composer = composer; } public async Task> GetSalesRecordsAsync(HanaServer server, @@ -244,22 +245,7 @@ public class HanaQueryService : IHanaQueryService $"Alias={source.Alias} | Tabelle/View={source.EntitySet} | Zeilen={sourceRows[source.Alias].Count}"); } - var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First(); - var composedRows = sourceRows[primarySource.Alias] - .Select(r => PrefixRow(primarySource.Alias, r)) - .ToList(); - - foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id)) - { - if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows)) - continue; - - composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows); - } - - return composedRows - .Select(row => MapToSalesRecord(site, row, mappings)) - .ToList(); + return _composer.Compose(site, activeSources, joins, mappings, sourceRows, "HANA"); } private async Task> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken) @@ -377,155 +363,6 @@ public class HanaQueryService : IHanaQueryService return Convert.ToInt32(count) > 0; } - private static Dictionary PrefixRow(string alias, Dictionary row) - => row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); - - private static List> ApplyLeftJoin( - List> leftRows, - string leftAlias, - string leftKeys, - string rightAlias, - string rightKeys, - List> rightRows) - { - var leftKeyParts = SplitKeys(leftKeys); - var rightKeyParts = SplitKeys(rightKeys); - if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count) - return leftRows; - - var rightLookup = rightRows - .GroupBy(r => BuildKey(r, rightKeyParts)) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - var results = new List>(); - foreach (var leftRow in leftRows) - { - var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts); - if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0) - { - foreach (var match in matches) - { - var merged = new Dictionary(leftRow, StringComparer.OrdinalIgnoreCase); - foreach (var kvp in PrefixRow(rightAlias, match)) - merged[kvp.Key] = kvp.Value; - results.Add(merged); - } - } - else - { - results.Add(leftRow); - } - } - - return results; - } - - private static SalesRecord MapToSalesRecord(Site site, Dictionary row, IReadOnlyList mappings) - { - var record = new SalesRecord - { - ExtractionDate = DateTime.UtcNow, - Tsc = site.TSC, - Land = site.Land, - DocumentType = "HANA" - }; - - foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id)) - { - var value = EvaluateExpression(row, mapping.SourceExpression); - ApplyValue(record, mapping.TargetField, 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; - - return record; - } - - private static object? EvaluateExpression(Dictionary row, string expression) - { - if (string.IsNullOrWhiteSpace(expression)) - return null; - - var value = expression.Trim(); - if (value.StartsWith('=')) - return value[1..]; - - if (row.TryGetValue(value, out var direct)) - return direct; - - return null; - } - - private static void ApplyValue(SalesRecord record, string targetField, object? value) - { - var property = typeof(SalesRecord).GetProperty(targetField); - if (property is null) - return; - - try - { - if (property.PropertyType == typeof(string)) - { - property.SetValue(record, value?.ToString() ?? string.Empty); - return; - } - - if (property.PropertyType == typeof(int)) - { - if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) - property.SetValue(record, intValue); - return; - } - - if (property.PropertyType == typeof(decimal)) - { - if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) - property.SetValue(record, decimalValue); - return; - } - - if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime)) - { - if (TryParseDate(value?.ToString(), out var date)) - property.SetValue(record, date); - } - } - catch - { - // Invalid field mappings should not stop the remaining row mapping. - } - } - - private static bool TryParseDate(string? value, out DateTime date) - { - date = default; - if (string.IsNullOrWhiteSpace(value)) - return false; - - return DateTime.TryParse(value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date) - || DateTime.TryParse(value.Trim(), CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date); - } - - private static string BuildKey(Dictionary row, IReadOnlyList keys) - => string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null))); - - private static string BuildKey(Dictionary row, string alias, IReadOnlyList keys) - => string.Join("||", keys.Select(k => - { - row.TryGetValue($"{alias}.{k}", out var value); - return NormalizeKeyValue(value); - })); - - private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty; - - private static List SplitKeys(string keys) - => keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - private static string GetInvoiceQuery(string schema) { var schemaPrefix = BuildSchemaPrefix(schema); diff --git a/TrafagSalesExporter/Services/IConsolidatedExportService.cs b/TrafagSalesExporter/Services/IConsolidatedExportService.cs index 62643fc..9cf52fc 100644 --- a/TrafagSalesExporter/Services/IConsolidatedExportService.cs +++ b/TrafagSalesExporter/Services/IConsolidatedExportService.cs @@ -1,8 +1,6 @@ -using TrafagSalesExporter.Models; - namespace TrafagSalesExporter.Services; public interface IConsolidatedExportService { - Task ExportAsync(List records); + Task ExportAsync(); } diff --git a/TrafagSalesExporter/Services/IMappedSalesRecordComposer.cs b/TrafagSalesExporter/Services/IMappedSalesRecordComposer.cs new file mode 100644 index 0000000..50b6017 --- /dev/null +++ b/TrafagSalesExporter/Services/IMappedSalesRecordComposer.cs @@ -0,0 +1,14 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IMappedSalesRecordComposer +{ + List Compose( + Site site, + IReadOnlyList sources, + IReadOnlyList joins, + IReadOnlyList mappings, + IReadOnlyDictionary>> sourceRows, + string defaultDocumentType); +} diff --git a/TrafagSalesExporter/Services/MappedSalesRecordComposer.cs b/TrafagSalesExporter/Services/MappedSalesRecordComposer.cs new file mode 100644 index 0000000..a34ff86 --- /dev/null +++ b/TrafagSalesExporter/Services/MappedSalesRecordComposer.cs @@ -0,0 +1,271 @@ +using System.Globalization; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public sealed class MappedSalesRecordComposer : IMappedSalesRecordComposer +{ + public List Compose( + Site site, + IReadOnlyList sources, + IReadOnlyList joins, + IReadOnlyList mappings, + IReadOnlyDictionary>> sourceRows, + string defaultDocumentType) + { + var activeSources = sources + .Where(s => s.IsActive) + .OrderBy(s => s.SortOrder) + .ThenBy(s => s.Id) + .ToList(); + + if (activeSources.Count == 0) + throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven Mapping-Quellen."); + if (!mappings.Any(m => m.IsActive)) + throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven Feldmappings."); + + var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First(); + if (!sourceRows.TryGetValue(primarySource.Alias, out var primaryRows)) + throw new InvalidOperationException($"Primaerquelle '{primarySource.Alias}' wurde nicht geladen."); + + var composedRows = primaryRows + .Select(r => PrefixRow(primarySource.Alias, r)) + .ToList(); + + foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id)) + { + if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows)) + continue; + + composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows); + } + + return composedRows + .Select(row => MapToSalesRecord(site, row, mappings, defaultDocumentType)) + .ToList(); + } + + private static Dictionary PrefixRow(string alias, Dictionary row) + => row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); + + private static List> ApplyLeftJoin( + List> leftRows, + string leftAlias, + string leftKeys, + string rightAlias, + string rightKeys, + List> rightRows) + { + var leftKeyParts = SplitKeys(leftKeys); + var rightKeyParts = SplitKeys(rightKeys); + if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count) + return leftRows; + + var rightLookup = rightRows + .GroupBy(r => BuildKey(r, rightKeyParts)) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + var results = new List>(); + foreach (var leftRow in leftRows) + { + var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts); + if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0) + { + foreach (var match in matches) + { + var merged = new Dictionary(leftRow, StringComparer.OrdinalIgnoreCase); + foreach (var kvp in PrefixRow(rightAlias, match)) + merged[kvp.Key] = kvp.Value; + results.Add(merged); + } + } + else + { + results.Add(leftRow); + } + } + + return results; + } + + private static SalesRecord MapToSalesRecord( + Site site, + Dictionary row, + IReadOnlyList mappings, + string defaultDocumentType) + { + var record = new SalesRecord + { + ExtractionDate = DateTime.UtcNow, + Tsc = site.TSC, + Land = site.Land, + DocumentType = defaultDocumentType + }; + + foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id)) + { + var value = EvaluateExpression(row, mapping.SourceExpression); + ApplyValue(record, mapping.TargetField, 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 = defaultDocumentType; + + return record; + } + + private static object? EvaluateExpression(Dictionary row, string expression) + { + if (string.IsNullOrWhiteSpace(expression)) + return null; + + var value = expression.Trim(); + if (value.StartsWith('=')) + return value[1..]; + + if (row.TryGetValue(value, out var direct)) + return direct; + + return null; + } + + private static void ApplyValue(SalesRecord record, string targetField, object? value) + { + var property = typeof(SalesRecord).GetProperty(targetField); + if (property is null) + return; + + try + { + if (property.PropertyType == typeof(string)) + { + property.SetValue(record, value?.ToString() ?? string.Empty); + return; + } + + if (property.PropertyType == typeof(int)) + { + if (TryConvertInt(value, out var intValue)) + property.SetValue(record, intValue); + return; + } + + if (property.PropertyType == typeof(decimal)) + { + if (TryConvertDecimal(value, out var decimalValue)) + property.SetValue(record, decimalValue); + return; + } + + if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime)) + { + if (TryConvertDate(value, out var date)) + property.SetValue(record, date); + } + } + catch + { + // Invalid field mappings should not stop the remaining row mapping. + } + } + + private static bool TryConvertInt(object? value, out int result) + { + result = default; + if (TryConvertDecimal(value, out var decimalValue)) + { + result = (int)Math.Round(decimalValue); + return true; + } + + return false; + } + + private static bool TryConvertDecimal(object? value, out decimal result) + { + result = default; + if (value is null) + return false; + if (value is decimal decimalValue) + { + result = decimalValue; + return true; + } + if (value is IConvertible convertible && value is not string) + { + try + { + result = convertible.ToDecimal(CultureInfo.InvariantCulture); + return true; + } + catch + { + // Fall back to culture-aware string parsing below. + } + } + + var text = value.ToString()?.Trim(); + return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out result) + || decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out result) + || decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out result); + } + + private static bool TryConvertDate(object? value, out DateTime date) + { + date = default; + if (value is null) + return false; + if (value is DateTime dateTime) + { + date = dateTime; + return true; + } + if (value is DateTimeOffset dateTimeOffset) + { + date = dateTimeOffset.DateTime; + return true; + } + + var text = value.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return false; + + if (text.StartsWith("/Date(", StringComparison.Ordinal) && text.EndsWith(")/", StringComparison.Ordinal)) + { + var epochRaw = text[6..^2]; + var separator = epochRaw.IndexOfAny(['+', '-']); + if (separator > 0) + epochRaw = epochRaw[..separator]; + if (long.TryParse(epochRaw, out var ms)) + { + date = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime; + return true; + } + } + + return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date) + || DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date) + || DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.AssumeLocal, out date); + } + + private static string BuildKey(Dictionary row, IReadOnlyList keys) + => string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null))); + + private static string BuildKey(Dictionary row, string alias, IReadOnlyList keys) + => string.Join("||", keys.Select(k => + { + row.TryGetValue($"{alias}.{k}", out var value); + return NormalizeKeyValue(value); + })); + + private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty; + + private static List SplitKeys(string keys) + => keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); +} diff --git a/TrafagSalesExporter/Services/SapCompositionService.cs b/TrafagSalesExporter/Services/SapCompositionService.cs index ad39c88..e65acd4 100644 --- a/TrafagSalesExporter/Services/SapCompositionService.cs +++ b/TrafagSalesExporter/Services/SapCompositionService.cs @@ -1,4 +1,3 @@ -using System.Globalization; using TrafagSalesExporter.Models; namespace TrafagSalesExporter.Services; @@ -6,11 +5,16 @@ namespace TrafagSalesExporter.Services; public class SapCompositionService : ISapCompositionService { private readonly ISapGatewayService _sapGatewayService; + private readonly IMappedSalesRecordComposer _composer; private readonly IAppEventLogService _appEventLogService; - public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService) + public SapCompositionService( + ISapGatewayService sapGatewayService, + IMappedSalesRecordComposer composer, + IAppEventLogService appEventLogService) { _sapGatewayService = sapGatewayService; + _composer = composer; _appEventLogService = appEventLogService; } @@ -46,192 +50,11 @@ public class SapCompositionService : ISapCompositionService $"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}"); } - var composedRows = sourceRows[primarySource.Alias] - .Select(r => PrefixRow(primarySource.Alias, r)) - .ToList(); - await _appEventLogService.WriteDebugAsync("SAP", "Primärquelle vorbereitet", site.Id, site.Land, - $"Alias={primarySource.Alias} | Startzeilen={composedRows.Count}"); - - foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id)) - { - if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows)) - continue; - - await _appEventLogService.WriteDebugAsync("SAP", "Join gestartet", site.Id, site.Land, - $"{join.LeftAlias}({join.LeftKeys}) -> {join.RightAlias}({join.RightKeys}) | RightRows={rightRows.Count}"); - composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows); - await _appEventLogService.WriteDebugAsync("SAP", "Join beendet", site.Id, site.Land, - $"{join.LeftAlias} -> {join.RightAlias} | Ergebniszeilen={composedRows.Count}"); - } - - var result = composedRows - .Select(row => MapToSalesRecord(site, row, mappings)) - .ToList(); + await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema gestartet", site.Id, site.Land, + $"Primaerquelle={primarySource.Alias} | Mappings={mappings.Count(x => x.IsActive)}"); + var result = _composer.Compose(site, activeSources, joins, mappings, sourceRows, "SAP"); await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land, $"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}"); return result; } - - private static Dictionary PrefixRow(string alias, Dictionary row) - => row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); - - private static List> ApplyLeftJoin( - List> leftRows, - string leftAlias, - string leftKeys, - string rightAlias, - string rightKeys, - List> rightRows) - { - var leftKeyParts = SplitKeys(leftKeys); - var rightKeyParts = SplitKeys(rightKeys); - if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count) - return leftRows; - - var rightLookup = rightRows - .GroupBy(r => BuildKey(r, rightKeyParts)) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - var results = new List>(); - foreach (var leftRow in leftRows) - { - var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts); - if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0) - { - foreach (var match in matches) - { - var merged = new Dictionary(leftRow, StringComparer.OrdinalIgnoreCase); - foreach (var kvp in PrefixRow(rightAlias, match)) - merged[kvp.Key] = kvp.Value; - results.Add(merged); - } - } - else - { - results.Add(leftRow); - } - } - - return results; - } - - private static SalesRecord MapToSalesRecord(Site site, Dictionary row, IReadOnlyList mappings) - { - var record = new SalesRecord - { - ExtractionDate = DateTime.UtcNow, - Tsc = site.TSC, - Land = site.Land, - DocumentType = "SAP" - }; - - foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id)) - { - var value = EvaluateExpression(row, mapping.SourceExpression); - ApplyValue(record, mapping.TargetField, 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; - - return record; - } - - private static object? EvaluateExpression(Dictionary row, string expression) - { - if (string.IsNullOrWhiteSpace(expression)) - return null; - - var value = expression.Trim(); - if (value.StartsWith('=')) - return value[1..]; - - if (row.TryGetValue(value, out var direct)) - return direct; - - return null; - } - - private static void ApplyValue(SalesRecord record, string targetField, object? value) - { - var property = typeof(SalesRecord).GetProperty(targetField); - if (property is null) - return; - - try - { - if (property.PropertyType == typeof(string)) - { - property.SetValue(record, value?.ToString() ?? string.Empty); - return; - } - - if (property.PropertyType == typeof(int)) - { - if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) - property.SetValue(record, intValue); - return; - } - - if (property.PropertyType == typeof(decimal)) - { - if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) - property.SetValue(record, decimalValue); - return; - } - - if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime)) - { - if (TryParseDate(value?.ToString(), out var date)) - property.SetValue(record, date); - } - } - catch - { - // ignore invalid mappings and continue with remaining fields - } - } - - private static bool TryParseDate(string? value, out DateTime date) - { - date = default; - if (string.IsNullOrWhiteSpace(value)) - return false; - - var trimmed = value.Trim(); - if (trimmed.StartsWith("/Date(", StringComparison.Ordinal) && trimmed.EndsWith(")/", StringComparison.Ordinal)) - { - var epochRaw = trimmed[6..^2]; - var separator = epochRaw.IndexOfAny(['+', '-']); - if (separator > 0) - epochRaw = epochRaw[..separator]; - if (long.TryParse(epochRaw, out var ms)) - { - date = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime; - return true; - } - } - - return DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date) - || DateTime.TryParse(trimmed, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date); - } - - private static string BuildKey(Dictionary row, IReadOnlyList keys) - => string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null))); - - private static string BuildKey(Dictionary row, string alias, IReadOnlyList keys) - => string.Join("||", keys.Select(k => - { - row.TryGetValue($"{alias}.{k}", out var value); - return NormalizeKeyValue(value); - })); - - private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty; - - private static List SplitKeys(string keys) - => keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } diff --git a/TrafagSalesExporter/Services/StandortePageService.cs b/TrafagSalesExporter/Services/StandortePageService.cs index 2575505..c18b408 100644 --- a/TrafagSalesExporter/Services/StandortePageService.cs +++ b/TrafagSalesExporter/Services/StandortePageService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; @@ -27,6 +28,7 @@ public sealed class StandortePageService : IStandortePageService private readonly IDbContextFactory _dbFactory; private readonly IHanaQueryService _hanaService; private readonly ISapGatewayService _sapGatewayService; + private readonly IStandorteSapEditorService _sapEditorService; private readonly ISharePointUploadService _sharePointService; private readonly IAppEventLogService _appEventLogService; @@ -34,12 +36,14 @@ public sealed class StandortePageService : IStandortePageService IDbContextFactory dbFactory, IHanaQueryService hanaService, ISapGatewayService sapGatewayService, + IStandorteSapEditorService sapEditorService, ISharePointUploadService sharePointService, IAppEventLogService appEventLogService) { _dbFactory = dbFactory; _hanaService = hanaService; _sapGatewayService = sapGatewayService; + _sapEditorService = sapEditorService; _sharePointService = sharePointService; _appEventLogService = appEventLogService; } @@ -401,8 +405,8 @@ public sealed class StandortePageService : IStandortePageService var trimmedPath = manualImportFilePath.Trim(); if (string.IsNullOrWhiteSpace(trimmedPath)) throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen."); - if (!string.Equals(Path.GetExtension(trimmedPath), ".xlsx", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben."); + if (!IsSupportedManualImportFile(trimmedPath)) + throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv angeben."); if (File.Exists(trimmedPath)) return File.GetLastWriteTimeUtc(trimmedPath); @@ -440,18 +444,9 @@ public sealed class StandortePageService : IStandortePageService 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(); + return string.Equals(Path.GetExtension(manualImportFilePath?.Trim()), ".csv", StringComparison.OrdinalIgnoreCase) + ? LoadCsvHeaders(filePath) + : LoadExcelHeaders(filePath); } finally { @@ -541,21 +536,45 @@ public sealed class StandortePageService : IStandortePageService path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); - private static void NormalizeSapConfigCollections(List sapSources, List sapJoins, List sapMappings) - { - for (var i = 0; i < sapSources.Count; i++) - sapSources[i].SortOrder = i; - for (var i = 0; i < sapJoins.Count; i++) - sapJoins[i].SortOrder = i; - for (var i = 0; i < sapMappings.Count; i++) - sapMappings[i].SortOrder = i; + private static bool IsSupportedManualImportFile(string path) + => string.Equals(Path.GetExtension(path), ".xlsx", StringComparison.OrdinalIgnoreCase) || + string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase); - var selectedPrimaryIndex = sapSources.FindIndex(s => s.IsPrimary); - var primarySource = selectedPrimaryIndex >= 0 ? sapSources[selectedPrimaryIndex] : sapSources.FirstOrDefault(); - foreach (var source in sapSources) - source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource); - if (sapSources.Count > 0 && sapSources.All(s => !s.IsPrimary)) - sapSources[0].IsPrimary = true; + private static List LoadExcelHeaders(string filePath) + { + 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(); + } + + private static List LoadCsvHeaders(string filePath) + { + using var parser = new TextFieldParser(filePath) + { + TextFieldType = FieldType.Delimited, + HasFieldsEnclosedInQuotes = true, + TrimWhiteSpace = false + }; + parser.SetDelimiters(";"); + + var header = parser.ReadFields() + ?? throw new InvalidOperationException("Die CSV-Datei enthaelt keine Kopfzeile."); + + return header + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); } private static void NormalizeManualExcelMappings(List manualExcelMappings) @@ -564,7 +583,7 @@ public sealed class StandortePageService : IStandortePageService manualExcelMappings[i].SortOrder = i; } - private static async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List sapSources, List sapJoins, List sapMappings) + private 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(); var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync(); @@ -575,7 +594,7 @@ public sealed class StandortePageService : IStandortePageService if (isSapSite) { - NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings); + _sapEditorService.NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings); foreach (var source in sapSources) source.SiteId = siteId; foreach (var join in sapJoins) join.SiteId = siteId; foreach (var mapping in sapMappings) mapping.SiteId = siteId; diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/MappedSalesRecordComposerTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/MappedSalesRecordComposerTests.cs new file mode 100644 index 0000000..d32a09a --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/MappedSalesRecordComposerTests.cs @@ -0,0 +1,118 @@ +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class MappedSalesRecordComposerTests +{ + [Fact] + public void Compose_MapsPrimarySourceConstantsAndODataDate() + { + var composer = new MappedSalesRecordComposer(); + var site = new Site { TSC = "TRCH", Land = "Schweiz" }; + var sources = new[] + { + new SapSourceDefinition { Alias = "Z", EntitySet = "ZSCHWEIZSet", IsPrimary = true, IsActive = true } + }; + var mappings = new[] + { + Mapping(nameof(SalesRecord.InvoiceNumber), "Z.VBELN"), + Mapping(nameof(SalesRecord.PositionOnInvoice), "Z.POSNR"), + Mapping(nameof(SalesRecord.InvoiceDate), "Z.FKDAT"), + Mapping(nameof(SalesRecord.SalesPriceValue), "Z.NETWR_HC"), + Mapping(nameof(SalesRecord.SalesCurrency), "Z.HWAER"), + Mapping(nameof(SalesRecord.DocumentType), "=SAP") + }; + var rows = new Dictionary>>(StringComparer.OrdinalIgnoreCase) + { + ["Z"] = + [ + new(StringComparer.OrdinalIgnoreCase) + { + ["VBELN"] = "900001", + ["POSNR"] = "10", + ["FKDAT"] = "/Date(1735689600000)/", + ["NETWR_HC"] = "1234.50", + ["HWAER"] = "CHF" + } + ] + }; + + var result = composer.Compose(site, sources, [], mappings, rows, "SAP"); + + Assert.Single(result); + Assert.Equal("TRCH", result[0].Tsc); + Assert.Equal("Schweiz", result[0].Land); + Assert.Equal("900001", result[0].InvoiceNumber); + Assert.Equal(10, result[0].PositionOnInvoice); + Assert.Equal(new DateTime(2025, 1, 1), result[0].InvoiceDate); + Assert.Equal(1234.50m, result[0].SalesPriceValue); + Assert.Equal("CHF", result[0].SalesCurrency); + Assert.Equal("SAP", result[0].DocumentType); + } + + [Fact] + public void Compose_AppliesLeftJoinAndDefaultDocumentType() + { + var composer = new MappedSalesRecordComposer(); + var site = new Site { TSC = "TRAT", Land = "Oesterreich" }; + var sources = new[] + { + new SapSourceDefinition { Alias = "H", EntitySet = "Header", IsPrimary = true, IsActive = true }, + new SapSourceDefinition { Alias = "C", EntitySet = "Customer", IsActive = true, SortOrder = 1 } + }; + var joins = new[] + { + new SapJoinDefinition + { + LeftAlias = "H", + RightAlias = "C", + LeftKeys = "KUNNR", + RightKeys = "KUNNR", + IsActive = true + } + }; + var mappings = new[] + { + Mapping(nameof(SalesRecord.InvoiceNumber), "H.VBELN"), + Mapping(nameof(SalesRecord.CustomerName), "C.NAME1"), + Mapping(nameof(SalesRecord.CustomerCountry), "C.LAND1") + }; + var rows = new Dictionary>>(StringComparer.OrdinalIgnoreCase) + { + ["H"] = + [ + new(StringComparer.OrdinalIgnoreCase) + { + ["VBELN"] = "910001", + ["KUNNR"] = "1000" + } + ], + ["C"] = + [ + new(StringComparer.OrdinalIgnoreCase) + { + ["KUNNR"] = "1000", + ["NAME1"] = "Trafag AG", + ["LAND1"] = "CH" + } + ] + }; + + var result = composer.Compose(site, sources, joins, mappings, rows, "HANA"); + + Assert.Single(result); + Assert.Equal("910001", result[0].InvoiceNumber); + Assert.Equal("Trafag AG", result[0].CustomerName); + Assert.Equal("CH", result[0].CustomerCountry); + Assert.Equal("HANA", result[0].DocumentType); + } + + private static SapFieldMapping Mapping(string targetField, string sourceExpression) + => new() + { + TargetField = targetField, + SourceExpression = sourceExpression, + IsActive = true + }; +} diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index f636449..a81030a 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,48 @@ # Last Change 2026-05-04 +## Mapper-/Finance-Konfiguration konsolidiert 2026-05-07 + +Umgesetzte Aufraeumarbeiten: + +- Die doppelte SAP-OData/HANA-Mapping-Engine wurde entfernt. +- Neuer gemeinsamer Service: `MappedSalesRecordComposer`. +- `SapCompositionService` und `HanaQueryService.GetMappedSalesRecordsAsync` laden ihre Quellen weiterhin separat, nutzen danach aber denselben Composer fuer: + - Primaerquelle + - Left Joins + - `SapFieldMapping` nach `SalesRecord` + - Konstanten wie `=SAP` / `=HANA` + - Datums-/Zahlenkonvertierung +- Der alte HANA-B1-Pfad fuer `OINV/INV1/ORIN/RIN1` bleibt bewusst bestehen, damit BI1/SAGE ohne grafisches Mapping weiter laufen. +- Die SAP-Mapping-Normalisierung liegt nur noch in `StandorteSapEditorService`; `StandortePageService` ruft diesen Service beim Speichern auf. +- Der tote Parameter im konsolidierten Export wurde entfernt. `ConsolidatedExportService.ExportAsync()` liest eindeutig aus `CentralSalesRecords`. +- Manueller Import erlaubt in UI und Service jetzt `.xlsx` und `.csv`. + +Finance-Konfiguration: + +- Neue Tabelle `FinanceReferences` fuer Soll-/check.xlsx-Referenzen je Jahr. +- Neue Tabelle `FinanceIntercompanyRules` fuer 2nd-party/IC-Erkennung nach `ScopeKey`, Kundennummer oder Namensmarker. +- Budgetkurse 2025 werden in `CurrencyExchangeRates` mit `Notes = Budget 2025` geseedet. +- `FinanceReconciliationService` liest Sollwerte, Budgetkurse und IC-Regeln aus der DB. +- Config-Export/-Import enthaelt jetzt `FinanceReferences` und `FinanceIntercompanyRules`. + +Noch bewusst offen: + +- HANA-B1-Spezialpfad und generischer HANA-Mapper laufen parallel. Das ist aktuell noetig fuer bestehende BI1/SAGE-Standorte ohne Mapping. +- Manual Excel hat weiterhin Header-Automatik und grafisches Mapping. Naechster Aufraeumpunkt waere eine gemeinsame Import-Mapping-Engine. + +Letzte technische Verifikation: + +```text +dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false --verbosity minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich, `52/52` +- Bekannte MudBlazor-Analyzerwarnungen zu `Dense` bleiben bestehen. + ## SAP OData / ZSCHWEIZ / HANA Mapping 2026-05-07 Aktueller Entscheid: @@ -88,7 +131,7 @@ Umsetzung in der FinanceProbe: - `Sales Price/Value` bleibt als Vergleichsvariante sichtbar. - Zusaetzlicher Kandidat `Nettofakturawert Hauswaehrung -> CHF Budget 2025`. - Referenz in der Oberflaeche wird als `check.xlsx Sollwert` bezeichnet, nicht mehr als fuehrende Power-BI-Referenz. -- Intercompany-Anzeige wurde fachlich als `2nd-party/IC` beschriftet; dauerhafte Pflege als eigenes Auswahlfeld ist noch offen. +- Intercompany-Anzeige wurde fachlich als `2nd-party/IC` beschriftet; Regeln werden jetzt in `FinanceIntercompanyRules` geseedet und per Config exportiert/importiert. ## Finance Probe / Sales-Abgrenzung