Consolidate mapping and finance configuration
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user