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