Consolidate mapping and finance configuration

This commit is contained in:
2026-05-07 15:20:54 +02:00
parent dea171862c
commit dc3fd77c86
24 changed files with 988 additions and 537 deletions
@@ -386,18 +386,18 @@
}
else if (IsManualExcelSite())
{
<MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-Import</MudText>
<MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-/CSV-Import</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
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.
</MudAlert>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx."
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-/CSV-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx bzw. .csv."
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3">
Pfad pruefen
</MudButton>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx,.csv" />
@if (_uploadingManualImport)
{
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
@@ -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");
+2
View File
@@ -16,6 +16,8 @@ public class AppDbContext : DbContext
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
+30 -1
View File
@@ -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
+9
View File
@@ -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`
@@ -9,6 +9,8 @@ public class ConfigTransferPackage
public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> 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");
@@ -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;
}
@@ -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;
}
@@ -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:
+1
View File
@@ -52,6 +52,7 @@ builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
@@ -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<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
@@ -23,7 +23,7 @@ public class ConsolidatedExportService : IConsolidatedExportService
_sharePointService = sharePointService;
}
public async Task<string?> ExportAsync(List<SalesRecord> records)
public async Task<string?> ExportAsync()
{
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
if (consolidatedRecords.Count == 0)
@@ -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
);";
}
@@ -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();
@@ -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();
}
}
@@ -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<SalesRecord>();
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<string?> ExportConsolidatedOnlyAsync()
{
return await RunConsolidatedExportAsync(null);
return await RunConsolidatedExportAsync();
}
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
@@ -139,7 +134,7 @@ public class ExportOrchestrationService
OnExportStatusChanged?.Invoke();
}
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records)
private async Task<string?> RunConsolidatedExportAsync()
{
lock (_lock)
{
@@ -153,7 +148,7 @@ public class ExportOrchestrationService
try
{
return await _consolidatedExportService.ExportAsync(records ?? []);
return await _consolidatedExportService.ExportAsync();
}
catch (Exception ex)
{
@@ -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<AppDbContext> _dbFactory;
private static readonly IReadOnlyList<NetSalesReferenceDefinition> 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<string, decimal> BudgetRatesToChf = new Dictionary<string, decimal>(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<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
@@ -54,6 +21,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
public async Task<List<NetSalesReferenceRow>> 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,12 +68,19 @@ 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 =>
.Select(reference => BuildReferenceRow(reference, groupedActuals))
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static NetSalesReferenceRow BuildReferenceRow(
FinanceReference reference,
IReadOnlyDictionary<string, NetSalesActual> groupedActuals)
{
groupedActuals.TryGetValue(reference.Key, out var actual);
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
var selected = actual?.Candidates
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
@@ -121,7 +105,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
ValueField = selected?.Label ?? string.Empty,
ActualCurrency = selected?.Currency ?? string.Empty,
ReferenceSource = "check.xlsx Soll",
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC",
ReferenceCurrency = reference.CheckValue.HasValue ? "Sollwert" : "LC",
Status = BuildReferenceStatus(difference),
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
{
@@ -137,12 +121,36 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
: null
}).ToList() ?? []
};
})
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static NetSalesActual BuildNetSalesActual(IEnumerable<NetSalesActualSourceRow> rows)
private static async Task<IReadOnlyDictionary<string, decimal>> 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<string, decimal>(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<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> 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<string, decimal> 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<FinanceIntercompanyRule> 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<string> 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; }
@@ -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<List<SalesRecord>> 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<List<SalesRecord>> 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<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
private static List<Dictionary<string, object?>> ApplyLeftJoin(
List<Dictionary<string, object?>> leftRows,
string leftAlias,
string leftKeys,
string rightAlias,
string rightKeys,
List<Dictionary<string, object?>> 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<Dictionary<string, object?>>();
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<string, object?>(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<string, object?> row, IReadOnlyList<SapFieldMapping> 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<string, object?> 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<string, object?> row, IReadOnlyList<string> keys)
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> 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<string> SplitKeys(string keys)
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
private static string GetInvoiceQuery(string schema)
{
var schemaPrefix = BuildSchemaPrefix(schema);
@@ -1,8 +1,6 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IConsolidatedExportService
{
Task<string?> ExportAsync(List<SalesRecord> records);
Task<string?> ExportAsync();
}
@@ -0,0 +1,14 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IMappedSalesRecordComposer
{
List<SalesRecord> Compose(
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
IReadOnlyDictionary<string, List<Dictionary<string, object?>>> sourceRows,
string defaultDocumentType);
}
@@ -0,0 +1,271 @@
using System.Globalization;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class MappedSalesRecordComposer : IMappedSalesRecordComposer
{
public List<SalesRecord> Compose(
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
IReadOnlyDictionary<string, List<Dictionary<string, object?>>> 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<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
private static List<Dictionary<string, object?>> ApplyLeftJoin(
List<Dictionary<string, object?>> leftRows,
string leftAlias,
string leftKeys,
string rightAlias,
string rightKeys,
List<Dictionary<string, object?>> 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<Dictionary<string, object?>>();
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<string, object?>(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<string, object?> row,
IReadOnlyList<SapFieldMapping> 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<string, object?> 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<string, object?> row, IReadOnlyList<string> keys)
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> 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<string> SplitKeys(string keys)
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
@@ -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<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
private static List<Dictionary<string, object?>> ApplyLeftJoin(
List<Dictionary<string, object?>> leftRows,
string leftAlias,
string leftKeys,
string rightAlias,
string rightKeys,
List<Dictionary<string, object?>> 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<Dictionary<string, object?>>();
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<string, object?>(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<string, object?> row, IReadOnlyList<SapFieldMapping> 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<string, object?> 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<string, object?> row, IReadOnlyList<string> keys)
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> 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<string> SplitKeys(string keys)
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
@@ -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<AppDbContext> _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<AppDbContext> 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<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> 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<string> 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<string> 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<ManualExcelColumnMapping> 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<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
private async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
{
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
var 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;
@@ -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<string, List<Dictionary<string, object?>>>(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<string, List<Dictionary<string, object?>>>(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
};
}
+44 -1
View File
@@ -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