Consolidate mapping and finance configuration
This commit is contained in:
@@ -386,18 +386,18 @@
|
|||||||
}
|
}
|
||||||
else if (IsManualExcelSite())
|
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">
|
<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>
|
</MudAlert>
|
||||||
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
|
<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."
|
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx bzw. .csv."
|
||||||
Class="mb-2" />
|
Class="mb-2" />
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
|
||||||
Disabled="_uploadingManualImport" Class="mb-3">
|
Disabled="_uploadingManualImport" Class="mb-3">
|
||||||
Pfad pruefen
|
Pfad pruefen
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
|
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx,.csv" />
|
||||||
@if (_uploadingManualImport)
|
@if (_uploadingManualImport)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
|
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
|
||||||
@@ -907,9 +907,10 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(file.Name);
|
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");
|
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
|
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
|
||||||
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
||||||
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
|
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<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||||
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||||
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
||||||
|
|||||||
@@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
Stand: 2026-05-05
|
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
|
## Nachtrag 2026-05-07 SAP OData / ZSCHWEIZ / Schweiz-Oesterreich
|
||||||
|
|
||||||
Aktueller Architekturentscheid:
|
Aktueller Architekturentscheid:
|
||||||
@@ -51,7 +80,7 @@ Wichtig fuer naechsten Einstieg:
|
|||||||
Verifikation:
|
Verifikation:
|
||||||
|
|
||||||
- Hauptprojekt Build erfolgreich.
|
- Hauptprojekt Build erfolgreich.
|
||||||
- Tests `50/50` erfolgreich.
|
- Tests `52/52` erfolgreich.
|
||||||
|
|
||||||
## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich
|
## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,18 @@ Mapper:
|
|||||||
|
|
||||||
- SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`.
|
- SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`.
|
||||||
- Direkte HANA-Tabellen/Views koennen dieselben Mapping-Tabellen ebenfalls nutzen.
|
- 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.
|
- 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.
|
- 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:
|
ZSCHWEIZ-Seed:
|
||||||
|
|
||||||
- Quelle Alias `Z`
|
- Quelle Alias `Z`
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public class ConfigTransferPackage
|
|||||||
public ConfigTransferExportSettings? ExportSettings { get; set; }
|
public ConfigTransferExportSettings? ExportSettings { get; set; }
|
||||||
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
|
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
|
||||||
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { 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<ConfigTransferHanaServer> HanaServers { get; set; } = [];
|
||||||
public List<ConfigTransferSite> Sites { get; set; } = [];
|
public List<ConfigTransferSite> Sites { get; set; } = [];
|
||||||
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
||||||
@@ -61,6 +63,26 @@ public class ConfigTransferCurrencyExchangeRate
|
|||||||
public bool IsActive { get; set; } = true;
|
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 class ConfigTransferHanaServer
|
||||||
{
|
{
|
||||||
public string Key { get; set; } = Guid.NewGuid().ToString("N");
|
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
|
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
|
## Nachtrag 2026-05-07 ZSCHWEIZ ueber SAP OData
|
||||||
|
|
||||||
Finaler Stand fuer Schweiz/Oesterreich:
|
Finaler Stand fuer Schweiz/Oesterreich:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
|
|||||||
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
||||||
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
|
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
|
||||||
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
|
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
|
||||||
|
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
|
||||||
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
|
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
|
||||||
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
|
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
|
||||||
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
|
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
.ThenBy(x => x.ToCurrency)
|
.ThenBy(x => x.ToCurrency)
|
||||||
.ThenByDescending(x => x.ValidFrom)
|
.ThenByDescending(x => x.ValidFrom)
|
||||||
.ToListAsync();
|
.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 hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
|
||||||
var sites = await db.Sites.OrderBy(x => x.Land).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();
|
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,
|
Notes = rate.Notes,
|
||||||
IsActive = rate.IsActive
|
IsActive = rate.IsActive
|
||||||
}).ToList(),
|
}).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
|
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
|
||||||
{
|
{
|
||||||
Key = serverKeyMap[server.Id],
|
Key = serverKeyMap[server.Id],
|
||||||
@@ -177,6 +204,8 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
|
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
|
||||||
var existingServers = await db.HanaServers.ToListAsync();
|
var existingServers = await db.HanaServers.ToListAsync();
|
||||||
var existingExchangeRates = await db.CurrencyExchangeRates.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 existingSites = await db.Sites.ToListAsync();
|
||||||
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
||||||
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
||||||
@@ -202,6 +231,10 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
|
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
|
||||||
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
||||||
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
|
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 (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
|
||||||
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
||||||
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
|
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);
|
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var server in package.HanaServers)
|
foreach (var server in package.HanaServers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class ConsolidatedExportService : IConsolidatedExportService
|
|||||||
_sharePointService = sharePointService;
|
_sharePointService = sharePointService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> ExportAsync(List<SalesRecord> records)
|
public async Task<string?> ExportAsync()
|
||||||
{
|
{
|
||||||
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
||||||
if (consolidatedRecords.Count == 0)
|
if (consolidatedRecords.Count == 0)
|
||||||
|
|||||||
@@ -169,4 +169,26 @@ CREATE TABLE ManualExcelColumnMappings (
|
|||||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
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);
|
EnsureTransformationTable(db);
|
||||||
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
|
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
|
||||||
EnsureCurrencyExchangeRateTable(db);
|
EnsureCurrencyExchangeRateTable(db);
|
||||||
|
EnsureFinanceReferenceTable(db);
|
||||||
|
EnsureFinanceIntercompanyRuleTable(db);
|
||||||
EnsureSourceSystemDefinitionTable(db);
|
EnsureSourceSystemDefinitionTable(db);
|
||||||
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||||
EnsureSapSourceTable(db);
|
EnsureSapSourceTable(db);
|
||||||
@@ -292,6 +294,28 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
|
|||||||
cmd.ExecuteNonQuery();
|
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)
|
private static void EnsureSapJoinTable(AppDbContext db)
|
||||||
{
|
{
|
||||||
var conn = db.Database.GetDbConnection();
|
var conn = db.Database.GetDbConnection();
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class DatabaseSeedService : IDatabaseSeedService
|
|||||||
EnsureCentralHanaServerRecords(db);
|
EnsureCentralHanaServerRecords(db);
|
||||||
EnsureSpainManualExcelSite(db);
|
EnsureSpainManualExcelSite(db);
|
||||||
EnsureSapODataDachSite(db);
|
EnsureSapODataDachSite(db);
|
||||||
|
EnsureFinanceReferenceDefaults(db);
|
||||||
|
EnsureBudgetExchangeRateDefaults(db);
|
||||||
|
EnsureFinanceIntercompanyRuleDefaults(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SeedIfEmpty(AppDbContext db)
|
private static void SeedIfEmpty(AppDbContext db)
|
||||||
@@ -464,4 +467,117 @@ public class DatabaseSeedService : IDatabaseSeedService
|
|||||||
if (changed)
|
if (changed)
|
||||||
db.SaveChanges();
|
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();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
||||||
var consolidatedRecords = new List<SalesRecord>();
|
|
||||||
|
|
||||||
foreach (var site in sites)
|
foreach (var site in sites)
|
||||||
{
|
await ExportSiteAsync(site);
|
||||||
var result = await ExportSiteAsync(site);
|
|
||||||
if (result?.Records is { Count: > 0 })
|
|
||||||
consolidatedRecords.AddRange(result.Records);
|
|
||||||
}
|
|
||||||
|
|
||||||
await RunConsolidatedExportAsync(consolidatedRecords);
|
await RunConsolidatedExportAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> ExportConsolidatedOnlyAsync()
|
public async Task<string?> ExportConsolidatedOnlyAsync()
|
||||||
{
|
{
|
||||||
return await RunConsolidatedExportAsync(null);
|
return await RunConsolidatedExportAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||||
@@ -139,7 +134,7 @@ public class ExportOrchestrationService
|
|||||||
OnExportStatusChanged?.Invoke();
|
OnExportStatusChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records)
|
private async Task<string?> RunConsolidatedExportAsync()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -153,7 +148,7 @@ public class ExportOrchestrationService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _consolidatedExportService.ExportAsync(records ?? []);
|
return await _consolidatedExportService.ExportAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
namespace TrafagSalesExporter.Services;
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
@@ -12,40 +13,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
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)
|
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -54,6 +21,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
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
|
var centralRows = await db.CentralSalesRecords
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -80,7 +57,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
BuildNetSalesActual,
|
rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules),
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var activeSiteKeys = (await db.Sites
|
var activeSiteKeys = (await db.Sites
|
||||||
@@ -91,12 +68,19 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
.Select(s => ResolveReferenceKey(s.Land, s.TSC))
|
.Select(s => ResolveReferenceKey(s.Land, s.TSC))
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
return NetSalesReferences
|
return financeReferences
|
||||||
.Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key))
|
.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);
|
groupedActuals.TryGetValue(reference.Key, out var actual);
|
||||||
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
|
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
|
||||||
var selected = actual?.Candidates
|
var selected = actual?.Candidates
|
||||||
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
|
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
|
||||||
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
|
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
|
||||||
@@ -121,7 +105,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
ValueField = selected?.Label ?? string.Empty,
|
ValueField = selected?.Label ?? string.Empty,
|
||||||
ActualCurrency = selected?.Currency ?? string.Empty,
|
ActualCurrency = selected?.Currency ?? string.Empty,
|
||||||
ReferenceSource = "check.xlsx Soll",
|
ReferenceSource = "check.xlsx Soll",
|
||||||
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC",
|
ReferenceCurrency = reference.CheckValue.HasValue ? "Sollwert" : "LC",
|
||||||
Status = BuildReferenceStatus(difference),
|
Status = BuildReferenceStatus(difference),
|
||||||
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
||||||
{
|
{
|
||||||
@@ -137,12 +121,36 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
: null
|
: null
|
||||||
}).ToList() ?? []
|
}).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 rowList = rows.ToList();
|
||||||
var documentRows = rowList
|
var documentRows = rowList
|
||||||
@@ -157,7 +165,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
"Sales Price/Value",
|
"Sales Price/Value",
|
||||||
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)),
|
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)),
|
||||||
rowList.Sum(row => row.SalesPriceValue),
|
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);
|
var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency);
|
||||||
@@ -167,7 +175,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
"DocTotalFC - VatSumFC",
|
"DocTotalFC - VatSumFC",
|
||||||
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
|
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
|
||||||
netDocumentForeignCurrency,
|
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);
|
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
|
||||||
if (netDocumentLocalCurrency != 0m)
|
if (netDocumentLocalCurrency != 0m)
|
||||||
@@ -176,16 +184,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
"Nettofakturawert Hauswaehrung",
|
"Nettofakturawert Hauswaehrung",
|
||||||
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
||||||
netDocumentLocalCurrency,
|
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)
|
if (budgetChf != 0m)
|
||||||
candidates.Add(new(
|
candidates.Add(new(
|
||||||
"NetDocumentLocalCurrencyBudgetChf",
|
"NetDocumentLocalCurrencyBudgetChf",
|
||||||
"Nettofakturawert Hauswaehrung -> CHF Budget 2025",
|
"Nettofakturawert Hauswaehrung -> CHF Budget 2025",
|
||||||
"CHF",
|
"CHF",
|
||||||
budgetChf,
|
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
|
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();
|
var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
|
||||||
return BudgetRatesToChf.TryGetValue(currency, out var rate)
|
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
|
||||||
? 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)
|
private static string ResolveCurrencyLabel(IEnumerable<string> currencies)
|
||||||
{
|
{
|
||||||
var distinct = currencies
|
var distinct = currencies
|
||||||
@@ -223,40 +269,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
? $"{tsc}|{documentType}|{documentEntry}"
|
? $"{tsc}|{documentType}|{documentEntry}"
|
||||||
: $"{tsc}|{documentType}|{invoiceNumber}";
|
: $"{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)
|
private static string BuildReferenceStatus(decimal? difference)
|
||||||
{
|
{
|
||||||
if (!difference.HasValue)
|
if (!difference.HasValue)
|
||||||
@@ -315,12 +327,6 @@ public sealed class NetSalesCandidateRow
|
|||||||
public decimal? DifferenceExcludingIntercompany { get; set; }
|
public decimal? DifferenceExcludingIntercompany { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record NetSalesReferenceDefinition(
|
|
||||||
string Key,
|
|
||||||
string Label,
|
|
||||||
decimal? LocalCurrencyValue,
|
|
||||||
decimal? PowerBiValue);
|
|
||||||
|
|
||||||
internal sealed class NetSalesActual
|
internal sealed class NetSalesActual
|
||||||
{
|
{
|
||||||
public int RowCount { get; set; }
|
public int RowCount { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Sap.Data.Hana;
|
using Sap.Data.Hana;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
@@ -9,10 +8,12 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
private const string TscParameterName = "tsc";
|
private const string TscParameterName = "tsc";
|
||||||
private const string DateFilterParameterName = "dateFilter";
|
private const string DateFilterParameterName = "dateFilter";
|
||||||
private readonly IAppEventLogService _appEventLogService;
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
private readonly IMappedSalesRecordComposer _composer;
|
||||||
|
|
||||||
public HanaQueryService(IAppEventLogService appEventLogService)
|
public HanaQueryService(IAppEventLogService appEventLogService, IMappedSalesRecordComposer composer)
|
||||||
{
|
{
|
||||||
_appEventLogService = appEventLogService;
|
_appEventLogService = appEventLogService;
|
||||||
|
_composer = composer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SalesRecord>> GetSalesRecordsAsync(HanaServer server,
|
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}");
|
$"Alias={source.Alias} | Tabelle/View={source.EntitySet} | Zeilen={sourceRows[source.Alias].Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
|
return _composer.Compose(site, activeSources, joins, mappings, sourceRows, "HANA");
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<SalesRecord>> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken)
|
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;
|
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)
|
private static string GetInvoiceQuery(string schema)
|
||||||
{
|
{
|
||||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using TrafagSalesExporter.Models;
|
|
||||||
|
|
||||||
namespace TrafagSalesExporter.Services;
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
public interface IConsolidatedExportService
|
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;
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
namespace TrafagSalesExporter.Services;
|
namespace TrafagSalesExporter.Services;
|
||||||
@@ -6,11 +5,16 @@ namespace TrafagSalesExporter.Services;
|
|||||||
public class SapCompositionService : ISapCompositionService
|
public class SapCompositionService : ISapCompositionService
|
||||||
{
|
{
|
||||||
private readonly ISapGatewayService _sapGatewayService;
|
private readonly ISapGatewayService _sapGatewayService;
|
||||||
|
private readonly IMappedSalesRecordComposer _composer;
|
||||||
private readonly IAppEventLogService _appEventLogService;
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService)
|
public SapCompositionService(
|
||||||
|
ISapGatewayService sapGatewayService,
|
||||||
|
IMappedSalesRecordComposer composer,
|
||||||
|
IAppEventLogService appEventLogService)
|
||||||
{
|
{
|
||||||
_sapGatewayService = sapGatewayService;
|
_sapGatewayService = sapGatewayService;
|
||||||
|
_composer = composer;
|
||||||
_appEventLogService = appEventLogService;
|
_appEventLogService = appEventLogService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,192 +50,11 @@ public class SapCompositionService : ISapCompositionService
|
|||||||
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
|
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var composedRows = sourceRows[primarySource.Alias]
|
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema gestartet", site.Id, site.Land,
|
||||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
$"Primaerquelle={primarySource.Alias} | Mappings={mappings.Count(x => x.IsActive)}");
|
||||||
.ToList();
|
var result = _composer.Compose(site, activeSources, joins, mappings, sourceRows, "SAP");
|
||||||
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 beendet", site.Id, site.Land,
|
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land,
|
||||||
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
|
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
|
||||||
return result;
|
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 System.Text.Json;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.VisualBasic.FileIO;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
private readonly IHanaQueryService _hanaService;
|
private readonly IHanaQueryService _hanaService;
|
||||||
private readonly ISapGatewayService _sapGatewayService;
|
private readonly ISapGatewayService _sapGatewayService;
|
||||||
|
private readonly IStandorteSapEditorService _sapEditorService;
|
||||||
private readonly ISharePointUploadService _sharePointService;
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
private readonly IAppEventLogService _appEventLogService;
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
@@ -34,12 +36,14 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
IDbContextFactory<AppDbContext> dbFactory,
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
IHanaQueryService hanaService,
|
IHanaQueryService hanaService,
|
||||||
ISapGatewayService sapGatewayService,
|
ISapGatewayService sapGatewayService,
|
||||||
|
IStandorteSapEditorService sapEditorService,
|
||||||
ISharePointUploadService sharePointService,
|
ISharePointUploadService sharePointService,
|
||||||
IAppEventLogService appEventLogService)
|
IAppEventLogService appEventLogService)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_hanaService = hanaService;
|
_hanaService = hanaService;
|
||||||
_sapGatewayService = sapGatewayService;
|
_sapGatewayService = sapGatewayService;
|
||||||
|
_sapEditorService = sapEditorService;
|
||||||
_sharePointService = sharePointService;
|
_sharePointService = sharePointService;
|
||||||
_appEventLogService = appEventLogService;
|
_appEventLogService = appEventLogService;
|
||||||
}
|
}
|
||||||
@@ -401,8 +405,8 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
var trimmedPath = manualImportFilePath.Trim();
|
var trimmedPath = manualImportFilePath.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(trimmedPath))
|
if (string.IsNullOrWhiteSpace(trimmedPath))
|
||||||
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
|
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
|
||||||
if (!string.Equals(Path.GetExtension(trimmedPath), ".xlsx", StringComparison.OrdinalIgnoreCase))
|
if (!IsSupportedManualImportFile(trimmedPath))
|
||||||
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
|
throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv angeben.");
|
||||||
|
|
||||||
if (File.Exists(trimmedPath))
|
if (File.Exists(trimmedPath))
|
||||||
return File.GetLastWriteTimeUtc(trimmedPath);
|
return File.GetLastWriteTimeUtc(trimmedPath);
|
||||||
@@ -440,18 +444,9 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase);
|
var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook(filePath);
|
return string.Equals(Path.GetExtension(manualImportFilePath?.Trim()), ".csv", StringComparison.OrdinalIgnoreCase)
|
||||||
var worksheet = workbook.Worksheets.FirstOrDefault()
|
? LoadCsvHeaders(filePath)
|
||||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
: LoadExcelHeaders(filePath);
|
||||||
var usedRange = worksheet.RangeUsed()
|
|
||||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
|
|
||||||
|
|
||||||
return usedRange.FirstRow().CellsUsed()
|
|
||||||
.Select(cell => cell.GetString().Trim())
|
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -541,21 +536,45 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
|
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
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)
|
private static bool IsSupportedManualImportFile(string path)
|
||||||
{
|
=> string.Equals(Path.GetExtension(path), ".xlsx", StringComparison.OrdinalIgnoreCase) ||
|
||||||
for (var i = 0; i < sapSources.Count; i++)
|
string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase);
|
||||||
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;
|
|
||||||
|
|
||||||
var selectedPrimaryIndex = sapSources.FindIndex(s => s.IsPrimary);
|
private static List<string> LoadExcelHeaders(string filePath)
|
||||||
var primarySource = selectedPrimaryIndex >= 0 ? sapSources[selectedPrimaryIndex] : sapSources.FirstOrDefault();
|
{
|
||||||
foreach (var source in sapSources)
|
using var workbook = new XLWorkbook(filePath);
|
||||||
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
|
var worksheet = workbook.Worksheets.FirstOrDefault()
|
||||||
if (sapSources.Count > 0 && sapSources.All(s => !s.IsPrimary))
|
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
||||||
sapSources[0].IsPrimary = true;
|
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)
|
private static void NormalizeManualExcelMappings(List<ManualExcelColumnMapping> manualExcelMappings)
|
||||||
@@ -564,7 +583,7 @@ public sealed class StandortePageService : IStandortePageService
|
|||||||
manualExcelMappings[i].SortOrder = i;
|
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 oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
|
||||||
var oldJoins = await db.SapJoinDefinitions.Where(j => j.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)
|
if (isSapSite)
|
||||||
{
|
{
|
||||||
NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings);
|
_sapEditorService.NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings);
|
||||||
foreach (var source in sapSources) source.SiteId = siteId;
|
foreach (var source in sapSources) source.SiteId = siteId;
|
||||||
foreach (var join in sapJoins) join.SiteId = siteId;
|
foreach (var join in sapJoins) join.SiteId = siteId;
|
||||||
foreach (var mapping in sapMappings) mapping.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
|
# 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
|
## SAP OData / ZSCHWEIZ / HANA Mapping 2026-05-07
|
||||||
|
|
||||||
Aktueller Entscheid:
|
Aktueller Entscheid:
|
||||||
@@ -88,7 +131,7 @@ Umsetzung in der FinanceProbe:
|
|||||||
- `Sales Price/Value` bleibt als Vergleichsvariante sichtbar.
|
- `Sales Price/Value` bleibt als Vergleichsvariante sichtbar.
|
||||||
- Zusaetzlicher Kandidat `Nettofakturawert Hauswaehrung -> CHF Budget 2025`.
|
- Zusaetzlicher Kandidat `Nettofakturawert Hauswaehrung -> CHF Budget 2025`.
|
||||||
- Referenz in der Oberflaeche wird als `check.xlsx Sollwert` bezeichnet, nicht mehr als fuehrende Power-BI-Referenz.
|
- 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
|
## Finance Probe / Sales-Abgrenzung
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user