Consolidate mapping and finance configuration
This commit is contained in:
@@ -26,6 +26,15 @@ public class ConfigTransferService : IConfigTransferService
|
||||
.ThenBy(x => x.ToCurrency)
|
||||
.ThenByDescending(x => x.ValidFrom)
|
||||
.ToListAsync();
|
||||
var financeReferences = await db.FinanceReferences
|
||||
.OrderBy(x => x.Year)
|
||||
.ThenBy(x => x.Key)
|
||||
.ToListAsync();
|
||||
var financeIntercompanyRules = await db.FinanceIntercompanyRules
|
||||
.OrderBy(x => x.ScopeKey)
|
||||
.ThenBy(x => x.CustomerNumber)
|
||||
.ThenBy(x => x.CustomerNameContains)
|
||||
.ToListAsync();
|
||||
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
|
||||
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
|
||||
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
|
||||
@@ -79,6 +88,24 @@ public class ConfigTransferService : IConfigTransferService
|
||||
Notes = rate.Notes,
|
||||
IsActive = rate.IsActive
|
||||
}).ToList(),
|
||||
FinanceReferences = financeReferences.Select(reference => new ConfigTransferFinanceReference
|
||||
{
|
||||
Key = reference.Key,
|
||||
Label = reference.Label,
|
||||
Year = reference.Year,
|
||||
LocalCurrencyValue = reference.LocalCurrencyValue,
|
||||
CheckValue = reference.CheckValue,
|
||||
Notes = reference.Notes,
|
||||
IsActive = reference.IsActive
|
||||
}).ToList(),
|
||||
FinanceIntercompanyRules = financeIntercompanyRules.Select(rule => new ConfigTransferFinanceIntercompanyRule
|
||||
{
|
||||
ScopeKey = rule.ScopeKey,
|
||||
CustomerNumber = rule.CustomerNumber,
|
||||
CustomerNameContains = rule.CustomerNameContains,
|
||||
Notes = rule.Notes,
|
||||
IsActive = rule.IsActive
|
||||
}).ToList(),
|
||||
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
|
||||
{
|
||||
Key = serverKeyMap[server.Id],
|
||||
@@ -177,6 +204,8 @@ public class ConfigTransferService : IConfigTransferService
|
||||
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
|
||||
var existingServers = await db.HanaServers.ToListAsync();
|
||||
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
|
||||
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
|
||||
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
|
||||
var existingSites = await db.Sites.ToListAsync();
|
||||
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
||||
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
||||
@@ -202,6 +231,10 @@ public class ConfigTransferService : IConfigTransferService
|
||||
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
|
||||
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
||||
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
|
||||
if (package.FinanceReferences.Count > 0 && existingFinanceReferences.Count > 0)
|
||||
db.FinanceReferences.RemoveRange(existingFinanceReferences);
|
||||
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
|
||||
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
|
||||
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
|
||||
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
||||
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
|
||||
@@ -262,6 +295,32 @@ public class ConfigTransferService : IConfigTransferService
|
||||
}));
|
||||
}
|
||||
|
||||
if (package.FinanceReferences.Count > 0)
|
||||
{
|
||||
db.FinanceReferences.AddRange(package.FinanceReferences.Select(reference => new FinanceReference
|
||||
{
|
||||
Key = reference.Key,
|
||||
Label = reference.Label,
|
||||
Year = reference.Year,
|
||||
LocalCurrencyValue = reference.LocalCurrencyValue,
|
||||
CheckValue = reference.CheckValue,
|
||||
Notes = reference.Notes,
|
||||
IsActive = reference.IsActive
|
||||
}));
|
||||
}
|
||||
|
||||
if (package.FinanceIntercompanyRules.Count > 0)
|
||||
{
|
||||
db.FinanceIntercompanyRules.AddRange(package.FinanceIntercompanyRules.Select(rule => new FinanceIntercompanyRule
|
||||
{
|
||||
ScopeKey = rule.ScopeKey,
|
||||
CustomerNumber = rule.CustomerNumber,
|
||||
CustomerNameContains = rule.CustomerNameContains,
|
||||
Notes = rule.Notes,
|
||||
IsActive = rule.IsActive
|
||||
}));
|
||||
}
|
||||
|
||||
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var server in package.HanaServers)
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ public class ConsolidatedExportService : IConsolidatedExportService
|
||||
_sharePointService = sharePointService;
|
||||
}
|
||||
|
||||
public async Task<string?> ExportAsync(List<SalesRecord> records)
|
||||
public async Task<string?> ExportAsync()
|
||||
{
|
||||
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
||||
if (consolidatedRecords.Count == 0)
|
||||
|
||||
@@ -169,4 +169,26 @@ CREATE TABLE ManualExcelColumnMappings (
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||
);";
|
||||
|
||||
internal static string GetFinanceReferencesCreateSql() => @"
|
||||
CREATE TABLE FinanceReferences (
|
||||
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
Key TEXT NOT NULL,
|
||||
Label TEXT NOT NULL,
|
||||
Year INTEGER NOT NULL DEFAULT 2025,
|
||||
LocalCurrencyValue TEXT NULL,
|
||||
CheckValue TEXT NULL,
|
||||
Notes TEXT NOT NULL DEFAULT '',
|
||||
IsActive INTEGER NOT NULL DEFAULT 1
|
||||
);";
|
||||
|
||||
internal static string GetFinanceIntercompanyRulesCreateSql() => @"
|
||||
CREATE TABLE FinanceIntercompanyRules (
|
||||
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ScopeKey TEXT NOT NULL DEFAULT '',
|
||||
CustomerNumber TEXT NOT NULL DEFAULT '',
|
||||
CustomerNameContains TEXT NOT NULL DEFAULT '',
|
||||
Notes TEXT NOT NULL DEFAULT '',
|
||||
IsActive INTEGER NOT NULL DEFAULT 1
|
||||
);";
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
|
||||
EnsureTransformationTable(db);
|
||||
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
|
||||
EnsureCurrencyExchangeRateTable(db);
|
||||
EnsureFinanceReferenceTable(db);
|
||||
EnsureFinanceIntercompanyRuleTable(db);
|
||||
EnsureSourceSystemDefinitionTable(db);
|
||||
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||
EnsureSapSourceTable(db);
|
||||
@@ -292,6 +294,28 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void EnsureFinanceReferenceTable(AppDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
conn.Open();
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = DatabaseSchemaSql.GetFinanceReferencesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void EnsureFinanceIntercompanyRuleTable(AppDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
conn.Open();
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = DatabaseSchemaSql.GetFinanceIntercompanyRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void EnsureSapJoinTable(AppDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
|
||||
@@ -14,6 +14,9 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
EnsureCentralHanaServerRecords(db);
|
||||
EnsureSpainManualExcelSite(db);
|
||||
EnsureSapODataDachSite(db);
|
||||
EnsureFinanceReferenceDefaults(db);
|
||||
EnsureBudgetExchangeRateDefaults(db);
|
||||
EnsureFinanceIntercompanyRuleDefaults(db);
|
||||
}
|
||||
|
||||
private static void SeedIfEmpty(AppDbContext db)
|
||||
@@ -464,4 +467,117 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void EnsureFinanceReferenceDefaults(AppDbContext db)
|
||||
{
|
||||
var defaults = new[]
|
||||
{
|
||||
new FinanceReference { Key = "AT", Label = "Trafag AT", Year = 2025, LocalCurrencyValue = 3443863m },
|
||||
new FinanceReference { Key = "CH", Label = "Trafag CH", Year = 2025 },
|
||||
new FinanceReference { Key = "CN", Label = "Trafag CN", Year = 2025 },
|
||||
new FinanceReference { Key = "CZ", Label = "Trafag CZ", Year = 2025, LocalCurrencyValue = 95458782m },
|
||||
new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, LocalCurrencyValue = 3635923m },
|
||||
new FinanceReference { Key = "ES", Label = "Trafag ES", Year = 2025, LocalCurrencyValue = 3102334m },
|
||||
new FinanceReference { Key = "FR", Label = "Trafag FR", Year = 2025, LocalCurrencyValue = 1450582m, CheckValue = 1471218m },
|
||||
new FinanceReference { Key = "GFS", Label = "Trafag GfS", Year = 2025, LocalCurrencyValue = 6495513m },
|
||||
new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, LocalCurrencyValue = 747341702m, CheckValue = 750936591m },
|
||||
new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, LocalCurrencyValue = 7669840m },
|
||||
new FinanceReference { Key = "JP", Label = "Trafag JP", Year = 2025, LocalCurrencyValue = 187739814m },
|
||||
new FinanceReference { Key = "MS", Label = "Trafag MS", Year = 2025, LocalCurrencyValue = 1850199m },
|
||||
new FinanceReference { Key = "MSA", Label = "Trafag MSA", Year = 2025, LocalCurrencyValue = 1445258m },
|
||||
new FinanceReference { Key = "PL", Label = "Trafag PL Poltraf", Year = 2025, LocalCurrencyValue = 11279297m },
|
||||
new FinanceReference { Key = "RU", Label = "Trafag RU", Year = 2025 },
|
||||
new FinanceReference { Key = "UK", Label = "Trafag UK", Year = 2025, LocalCurrencyValue = 3538972m, CheckValue = 3749865m },
|
||||
new FinanceReference { Key = "US", Label = "Trafag US", Year = 2025, LocalCurrencyValue = 3896728m, CheckValue = 3749865m }
|
||||
};
|
||||
|
||||
var existing = db.FinanceReferences.ToList();
|
||||
var changed = false;
|
||||
foreach (var item in defaults)
|
||||
{
|
||||
var current = existing.FirstOrDefault(x => x.Year == item.Year && x.Key == item.Key);
|
||||
if (current is not null)
|
||||
continue;
|
||||
|
||||
db.FinanceReferences.Add(item);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void EnsureBudgetExchangeRateDefaults(AppDbContext db)
|
||||
{
|
||||
var defaults = new (string From, string To, decimal Rate)[]
|
||||
{
|
||||
("CHF", "CHF", 1m),
|
||||
("USD", "CHF", 0.85m),
|
||||
("EUR", "CHF", 0.95m),
|
||||
("GBP", "CHF", 1.13m),
|
||||
("CNY", "CHF", 1m / 8.50m),
|
||||
("INR", "CHF", 1m / 90.91m),
|
||||
("CZK", "CHF", 1m / 25.64m),
|
||||
("PLN", "CHF", 0.22m),
|
||||
("JPY", "CHF", 1m / 156.25m)
|
||||
};
|
||||
|
||||
var changed = false;
|
||||
foreach (var item in defaults)
|
||||
{
|
||||
var exists = db.CurrencyExchangeRates.Any(x =>
|
||||
x.FromCurrency == item.From &&
|
||||
x.ToCurrency == item.To &&
|
||||
x.ValidFrom == new DateTime(2025, 1, 1) &&
|
||||
x.Notes == "Budget 2025");
|
||||
if (exists)
|
||||
continue;
|
||||
|
||||
db.CurrencyExchangeRates.Add(new CurrencyExchangeRate
|
||||
{
|
||||
FromCurrency = item.From,
|
||||
ToCurrency = item.To,
|
||||
Rate = item.Rate,
|
||||
ValidFrom = new DateTime(2025, 1, 1),
|
||||
ValidTo = new DateTime(2025, 12, 31),
|
||||
Notes = "Budget 2025",
|
||||
IsActive = true
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void EnsureFinanceIntercompanyRuleDefaults(AppDbContext db)
|
||||
{
|
||||
var defaults = new[]
|
||||
{
|
||||
new FinanceIntercompanyRule { CustomerNameContains = "TRAFAG", Notes = "Default IC name marker" },
|
||||
new FinanceIntercompanyRule { CustomerNameContains = "MAGNETIC SENSE", Notes = "Default IC name marker" },
|
||||
new FinanceIntercompanyRule { CustomerNameContains = "MAGNETS SENSE", Notes = "Default IC name marker" },
|
||||
new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUER SENSORIK", Notes = "Default IC name marker" },
|
||||
new FinanceIntercompanyRule { CustomerNameContains = "GESELLSCHAFT FUR SENSORIK", Notes = "Default IC name marker" },
|
||||
new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_IT01_0306794", Notes = "IT IC customer number" },
|
||||
new FinanceIntercompanyRule { ScopeKey = "IT", CustomerNumber = "C_CH01_0302179", Notes = "IT IC customer number" }
|
||||
};
|
||||
|
||||
var changed = false;
|
||||
foreach (var item in defaults)
|
||||
{
|
||||
var exists = db.FinanceIntercompanyRules.Any(x =>
|
||||
x.ScopeKey == item.ScopeKey &&
|
||||
x.CustomerNumber == item.CustomerNumber &&
|
||||
x.CustomerNameContains == item.CustomerNameContains);
|
||||
if (exists)
|
||||
continue;
|
||||
|
||||
db.FinanceIntercompanyRules.Add(item);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,21 +69,16 @@ public class ExportOrchestrationService
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
||||
var consolidatedRecords = new List<SalesRecord>();
|
||||
|
||||
foreach (var site in sites)
|
||||
{
|
||||
var result = await ExportSiteAsync(site);
|
||||
if (result?.Records is { Count: > 0 })
|
||||
consolidatedRecords.AddRange(result.Records);
|
||||
}
|
||||
await ExportSiteAsync(site);
|
||||
|
||||
await RunConsolidatedExportAsync(consolidatedRecords);
|
||||
await RunConsolidatedExportAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> ExportConsolidatedOnlyAsync()
|
||||
{
|
||||
return await RunConsolidatedExportAsync(null);
|
||||
return await RunConsolidatedExportAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||
@@ -139,7 +134,7 @@ public class ExportOrchestrationService
|
||||
OnExportStatusChanged?.Invoke();
|
||||
}
|
||||
|
||||
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records)
|
||||
private async Task<string?> RunConsolidatedExportAsync()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -153,7 +148,7 @@ public class ExportOrchestrationService
|
||||
|
||||
try
|
||||
{
|
||||
return await _consolidatedExportService.ExportAsync(records ?? []);
|
||||
return await _consolidatedExportService.ExportAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
@@ -12,40 +13,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
|
||||
private static readonly IReadOnlyList<NetSalesReferenceDefinition> NetSalesReferences =
|
||||
[
|
||||
new("AT", "Trafag AT", 3443863m, null),
|
||||
new("CH", "Trafag CH", null, null),
|
||||
new("CN", "Trafag CN", null, null),
|
||||
new("CZ", "Trafag CZ", 95458782m, null),
|
||||
new("DE", "Trafag DE", 3635923m, null),
|
||||
new("ES", "Trafag ES", 3102334m, null),
|
||||
new("FR", "Trafag FR", 1450582m, 1471218m),
|
||||
new("GFS", "Trafag GfS", 6495513m, null),
|
||||
new("IN", "Trafag IN", 747341702m, 750936591m),
|
||||
new("IT", "Trafag IT", 7669840m, null),
|
||||
new("JP", "Trafag JP", 187739814m, null),
|
||||
new("MS", "Trafag MS", 1850199m, null),
|
||||
new("MSA", "Trafag MSA", 1445258m, null),
|
||||
new("PL", "Trafag PL Poltraf", 11279297m, null),
|
||||
new("RU", "Rrafag RU", null, null),
|
||||
new("UK", "Trafag UK", 3538972m, 3749865m),
|
||||
new("US", "Traga US", 3896728m, 3749865m)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, decimal> BudgetRatesToChf = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["CHF"] = 1m,
|
||||
["USD"] = 0.85m,
|
||||
["EUR"] = 0.95m,
|
||||
["GBP"] = 1.13m,
|
||||
["CNY"] = 1m / 8.50m,
|
||||
["INR"] = 1m / 90.91m,
|
||||
["CZK"] = 1m / 25.64m,
|
||||
["PLN"] = 0.22m,
|
||||
["JPY"] = 1m / 156.25m
|
||||
};
|
||||
|
||||
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -54,6 +21,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var financeReferences = await db.FinanceReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsActive && r.Year == year)
|
||||
.OrderBy(r => r.Label)
|
||||
.ToListAsync();
|
||||
var budgetRatesToChf = await LoadBudgetRatesToChfAsync(db, year);
|
||||
var intercompanyRules = await db.FinanceIntercompanyRules
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
var centralRows = await db.CentralSalesRecords
|
||||
.AsNoTracking()
|
||||
@@ -80,7 +57,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
BuildNetSalesActual,
|
||||
rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var activeSiteKeys = (await db.Sites
|
||||
@@ -91,58 +68,89 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
.Select(s => ResolveReferenceKey(s.Land, s.TSC))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return NetSalesReferences
|
||||
return financeReferences
|
||||
.Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key))
|
||||
.Select(reference =>
|
||||
{
|
||||
groupedActuals.TryGetValue(reference.Key, out var actual);
|
||||
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
|
||||
var selected = actual?.Candidates
|
||||
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
|
||||
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
|
||||
.FirstOrDefault();
|
||||
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
|
||||
var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue
|
||||
? (decimal?)null
|
||||
: selected.ValueExcludingIntercompany - referenceValue.Value;
|
||||
|
||||
return new NetSalesReferenceRow
|
||||
{
|
||||
Key = reference.Key,
|
||||
Label = reference.Label,
|
||||
ActualValue = selected?.Value,
|
||||
IntercompanyDeduction = selected?.IntercompanyValue,
|
||||
ActualValueExcludingIntercompany = selected?.ValueExcludingIntercompany,
|
||||
ReferenceValue = referenceValue,
|
||||
Difference = difference,
|
||||
DifferenceExcludingIntercompany = intercompanyAdjustedDifference,
|
||||
RowCount = actual?.RowCount ?? 0,
|
||||
Currencies = actual?.Currencies ?? string.Empty,
|
||||
ValueField = selected?.Label ?? string.Empty,
|
||||
ActualCurrency = selected?.Currency ?? string.Empty,
|
||||
ReferenceSource = "check.xlsx Soll",
|
||||
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC",
|
||||
Status = BuildReferenceStatus(difference),
|
||||
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
||||
{
|
||||
Key = candidate.Key,
|
||||
Label = candidate.Label,
|
||||
Currency = candidate.Currency,
|
||||
Value = candidate.Value,
|
||||
IntercompanyValue = candidate.IntercompanyValue,
|
||||
ValueExcludingIntercompany = candidate.ValueExcludingIntercompany,
|
||||
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
|
||||
DifferenceExcludingIntercompany = referenceValue.HasValue
|
||||
? candidate.ValueExcludingIntercompany - referenceValue.Value
|
||||
: null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
})
|
||||
.Select(reference => BuildReferenceRow(reference, groupedActuals))
|
||||
.OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static NetSalesActual BuildNetSalesActual(IEnumerable<NetSalesActualSourceRow> rows)
|
||||
private static NetSalesReferenceRow BuildReferenceRow(
|
||||
FinanceReference reference,
|
||||
IReadOnlyDictionary<string, NetSalesActual> groupedActuals)
|
||||
{
|
||||
groupedActuals.TryGetValue(reference.Key, out var actual);
|
||||
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
|
||||
var selected = actual?.Candidates
|
||||
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
|
||||
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
|
||||
.FirstOrDefault();
|
||||
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
|
||||
var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue
|
||||
? (decimal?)null
|
||||
: selected.ValueExcludingIntercompany - referenceValue.Value;
|
||||
|
||||
return new NetSalesReferenceRow
|
||||
{
|
||||
Key = reference.Key,
|
||||
Label = reference.Label,
|
||||
ActualValue = selected?.Value,
|
||||
IntercompanyDeduction = selected?.IntercompanyValue,
|
||||
ActualValueExcludingIntercompany = selected?.ValueExcludingIntercompany,
|
||||
ReferenceValue = referenceValue,
|
||||
Difference = difference,
|
||||
DifferenceExcludingIntercompany = intercompanyAdjustedDifference,
|
||||
RowCount = actual?.RowCount ?? 0,
|
||||
Currencies = actual?.Currencies ?? string.Empty,
|
||||
ValueField = selected?.Label ?? string.Empty,
|
||||
ActualCurrency = selected?.Currency ?? string.Empty,
|
||||
ReferenceSource = "check.xlsx Soll",
|
||||
ReferenceCurrency = reference.CheckValue.HasValue ? "Sollwert" : "LC",
|
||||
Status = BuildReferenceStatus(difference),
|
||||
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
||||
{
|
||||
Key = candidate.Key,
|
||||
Label = candidate.Label,
|
||||
Currency = candidate.Currency,
|
||||
Value = candidate.Value,
|
||||
IntercompanyValue = candidate.IntercompanyValue,
|
||||
ValueExcludingIntercompany = candidate.ValueExcludingIntercompany,
|
||||
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
|
||||
DifferenceExcludingIntercompany = referenceValue.HasValue
|
||||
? candidate.ValueExcludingIntercompany - referenceValue.Value
|
||||
: null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, decimal>> LoadBudgetRatesToChfAsync(AppDbContext db, int year)
|
||||
{
|
||||
var validFrom = new DateTime(year, 1, 1);
|
||||
var rates = await db.CurrencyExchangeRates
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsActive && r.Notes == $"Budget {year}" && r.ValidFrom <= validFrom && (!r.ValidTo.HasValue || r.ValidTo >= validFrom))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["CHF"] = 1m
|
||||
};
|
||||
|
||||
foreach (var rate in rates)
|
||||
{
|
||||
if (rate.ToCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase))
|
||||
result[rate.FromCurrency] = rate.Rate;
|
||||
else if (rate.FromCurrency.Equals("CHF", StringComparison.OrdinalIgnoreCase) && rate.Rate != 0m)
|
||||
result[rate.ToCurrency] = 1m / rate.Rate;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static NetSalesActual BuildNetSalesActual(
|
||||
IEnumerable<NetSalesActualSourceRow> rows,
|
||||
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
|
||||
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
|
||||
{
|
||||
var rowList = rows.ToList();
|
||||
var documentRows = rowList
|
||||
@@ -157,7 +165,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
"Sales Price/Value",
|
||||
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)),
|
||||
rowList.Sum(row => row.SalesPriceValue),
|
||||
rowList.Where(IsLikelyIntercompanyCustomer).Sum(row => row.SalesPriceValue))
|
||||
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue))
|
||||
};
|
||||
|
||||
var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency);
|
||||
@@ -167,7 +175,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
"DocTotalFC - VatSumFC",
|
||||
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
|
||||
netDocumentForeignCurrency,
|
||||
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency)));
|
||||
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency)));
|
||||
|
||||
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
|
||||
if (netDocumentLocalCurrency != 0m)
|
||||
@@ -176,16 +184,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
"Nettofakturawert Hauswaehrung",
|
||||
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
||||
netDocumentLocalCurrency,
|
||||
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)));
|
||||
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)));
|
||||
|
||||
var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency));
|
||||
var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
|
||||
if (budgetChf != 0m)
|
||||
candidates.Add(new(
|
||||
"NetDocumentLocalCurrencyBudgetChf",
|
||||
"Nettofakturawert Hauswaehrung -> CHF Budget 2025",
|
||||
"CHF",
|
||||
budgetChf,
|
||||
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))));
|
||||
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf))));
|
||||
|
||||
return new NetSalesActual
|
||||
{
|
||||
@@ -198,14 +206,52 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ConvertHouseCurrencyNetToBudgetChf(NetSalesActualSourceRow row, decimal value)
|
||||
private static decimal ConvertHouseCurrencyNetToBudgetChf(
|
||||
NetSalesActualSourceRow row,
|
||||
decimal value,
|
||||
IReadOnlyDictionary<string, decimal> budgetRatesToChf)
|
||||
{
|
||||
var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return BudgetRatesToChf.TryGetValue(currency, out var rate)
|
||||
? value * rate
|
||||
: 0m;
|
||||
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
|
||||
}
|
||||
|
||||
private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules)
|
||||
{
|
||||
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
|
||||
var customerName = row.CustomerName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
|
||||
return false;
|
||||
|
||||
var normalizedCustomerName = NormalizeRuleText(customerName);
|
||||
var referenceKey = ResolveReferenceKey(row.Land, row.Tsc);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rule.ScopeKey) &&
|
||||
!rule.ScopeKey.Equals(referenceKey, StringComparison.OrdinalIgnoreCase) &&
|
||||
!rule.ScopeKey.Equals(row.Tsc, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.CustomerNumber) &&
|
||||
customerNumber.Equals(rule.CustomerNumber.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.CustomerNameContains) &&
|
||||
normalizedCustomerName.Contains(NormalizeRuleText(rule.CustomerNameContains), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeRuleText(string value)
|
||||
=> (value ?? string.Empty)
|
||||
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
|
||||
.Trim()
|
||||
.ToUpperInvariant();
|
||||
|
||||
private static string ResolveCurrencyLabel(IEnumerable<string> currencies)
|
||||
{
|
||||
var distinct = currencies
|
||||
@@ -223,40 +269,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
? $"{tsc}|{documentType}|{documentEntry}"
|
||||
: $"{tsc}|{documentType}|{invoiceNumber}";
|
||||
|
||||
private static bool IsLikelyIntercompanyCustomer(NetSalesActualSourceRow row)
|
||||
{
|
||||
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
|
||||
var customerName = row.CustomerName?.Trim() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
|
||||
return false;
|
||||
|
||||
var normalizedCustomerName = customerName
|
||||
.Replace("ä", "ae", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("ö", "oe", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("ü", "ue", StringComparison.OrdinalIgnoreCase)
|
||||
.ToUpperInvariant();
|
||||
|
||||
if (normalizedCustomerName.Contains("TRAFAG", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedCustomerName.Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedCustomerName.Contains("MAGNETS SENSE", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedCustomerName.Contains("GESELLSCHAFT FUER SENSORIK", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedCustomerName.Contains("GESELLSCHAFT FUR SENSORIK", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) ||
|
||||
customerNumber.Equals("C_CH01_0302179", StringComparison.OrdinalIgnoreCase) ||
|
||||
customerName.Equals("TRAFAG ITALIA S.R.L.", StringComparison.OrdinalIgnoreCase) ||
|
||||
customerName.Equals("Trafag AG", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildReferenceStatus(decimal? difference)
|
||||
{
|
||||
if (!difference.HasValue)
|
||||
@@ -315,12 +327,6 @@ public sealed class NetSalesCandidateRow
|
||||
public decimal? DifferenceExcludingIntercompany { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record NetSalesReferenceDefinition(
|
||||
string Key,
|
||||
string Label,
|
||||
decimal? LocalCurrencyValue,
|
||||
decimal? PowerBiValue);
|
||||
|
||||
internal sealed class NetSalesActual
|
||||
{
|
||||
public int RowCount { get; set; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Sap.Data.Hana;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
@@ -9,10 +8,12 @@ public class HanaQueryService : IHanaQueryService
|
||||
private const string TscParameterName = "tsc";
|
||||
private const string DateFilterParameterName = "dateFilter";
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
private readonly IMappedSalesRecordComposer _composer;
|
||||
|
||||
public HanaQueryService(IAppEventLogService appEventLogService)
|
||||
public HanaQueryService(IAppEventLogService appEventLogService, IMappedSalesRecordComposer composer)
|
||||
{
|
||||
_appEventLogService = appEventLogService;
|
||||
_composer = composer;
|
||||
}
|
||||
|
||||
public async Task<List<SalesRecord>> GetSalesRecordsAsync(HanaServer server,
|
||||
@@ -244,22 +245,7 @@ public class HanaQueryService : IHanaQueryService
|
||||
$"Alias={source.Alias} | Tabelle/View={source.EntitySet} | Zeilen={sourceRows[source.Alias].Count}");
|
||||
}
|
||||
|
||||
var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
|
||||
var composedRows = sourceRows[primarySource.Alias]
|
||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
||||
.ToList();
|
||||
|
||||
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
|
||||
{
|
||||
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
|
||||
continue;
|
||||
|
||||
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
|
||||
}
|
||||
|
||||
return composedRows
|
||||
.Select(row => MapToSalesRecord(site, row, mappings))
|
||||
.ToList();
|
||||
return _composer.Compose(site, activeSources, joins, mappings, sourceRows, "HANA");
|
||||
}
|
||||
|
||||
private async Task<List<SalesRecord>> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken)
|
||||
@@ -377,155 +363,6 @@ public class HanaQueryService : IHanaQueryService
|
||||
return Convert.ToInt32(count) > 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
||||
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static List<Dictionary<string, object?>> ApplyLeftJoin(
|
||||
List<Dictionary<string, object?>> leftRows,
|
||||
string leftAlias,
|
||||
string leftKeys,
|
||||
string rightAlias,
|
||||
string rightKeys,
|
||||
List<Dictionary<string, object?>> rightRows)
|
||||
{
|
||||
var leftKeyParts = SplitKeys(leftKeys);
|
||||
var rightKeyParts = SplitKeys(rightKeys);
|
||||
if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count)
|
||||
return leftRows;
|
||||
|
||||
var rightLookup = rightRows
|
||||
.GroupBy(r => BuildKey(r, rightKeyParts))
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var results = new List<Dictionary<string, object?>>();
|
||||
foreach (var leftRow in leftRows)
|
||||
{
|
||||
var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts);
|
||||
if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var merged = new Dictionary<string, object?>(leftRow, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in PrefixRow(rightAlias, match))
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
results.Add(merged);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(leftRow);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SalesRecord MapToSalesRecord(Site site, Dictionary<string, object?> row, IReadOnlyList<SapFieldMapping> mappings)
|
||||
{
|
||||
var record = new SalesRecord
|
||||
{
|
||||
ExtractionDate = DateTime.UtcNow,
|
||||
Tsc = site.TSC,
|
||||
Land = site.Land,
|
||||
DocumentType = "HANA"
|
||||
};
|
||||
|
||||
foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id))
|
||||
{
|
||||
var value = EvaluateExpression(row, mapping.SourceExpression);
|
||||
ApplyValue(record, mapping.TargetField, value);
|
||||
}
|
||||
|
||||
if (record.ExtractionDate == default)
|
||||
record.ExtractionDate = DateTime.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(record.Tsc))
|
||||
record.Tsc = site.TSC;
|
||||
if (string.IsNullOrWhiteSpace(record.Land))
|
||||
record.Land = site.Land;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static object? EvaluateExpression(Dictionary<string, object?> row, string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return null;
|
||||
|
||||
var value = expression.Trim();
|
||||
if (value.StartsWith('='))
|
||||
return value[1..];
|
||||
|
||||
if (row.TryGetValue(value, out var direct))
|
||||
return direct;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyValue(SalesRecord record, string targetField, object? value)
|
||||
{
|
||||
var property = typeof(SalesRecord).GetProperty(targetField);
|
||||
if (property is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(record, value?.ToString() ?? string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
|
||||
property.SetValue(record, intValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(decimal))
|
||||
{
|
||||
if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
property.SetValue(record, decimalValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
|
||||
{
|
||||
if (TryParseDate(value?.ToString(), out var date))
|
||||
property.SetValue(record, date);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid field mappings should not stop the remaining row mapping.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
return DateTime.TryParse(value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date)
|
||||
|| DateTime.TryParse(value.Trim(), CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date);
|
||||
}
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k =>
|
||||
{
|
||||
row.TryGetValue($"{alias}.{k}", out var value);
|
||||
return NormalizeKeyValue(value);
|
||||
}));
|
||||
|
||||
private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty;
|
||||
|
||||
private static List<string> SplitKeys(string keys)
|
||||
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
|
||||
private static string GetInvoiceQuery(string schema)
|
||||
{
|
||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public interface IConsolidatedExportService
|
||||
{
|
||||
Task<string?> ExportAsync(List<SalesRecord> records);
|
||||
Task<string?> ExportAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public interface IMappedSalesRecordComposer
|
||||
{
|
||||
List<SalesRecord> Compose(
|
||||
Site site,
|
||||
IReadOnlyList<SapSourceDefinition> sources,
|
||||
IReadOnlyList<SapJoinDefinition> joins,
|
||||
IReadOnlyList<SapFieldMapping> mappings,
|
||||
IReadOnlyDictionary<string, List<Dictionary<string, object?>>> sourceRows,
|
||||
string defaultDocumentType);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Globalization;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public sealed class MappedSalesRecordComposer : IMappedSalesRecordComposer
|
||||
{
|
||||
public List<SalesRecord> Compose(
|
||||
Site site,
|
||||
IReadOnlyList<SapSourceDefinition> sources,
|
||||
IReadOnlyList<SapJoinDefinition> joins,
|
||||
IReadOnlyList<SapFieldMapping> mappings,
|
||||
IReadOnlyDictionary<string, List<Dictionary<string, object?>>> sourceRows,
|
||||
string defaultDocumentType)
|
||||
{
|
||||
var activeSources = sources
|
||||
.Where(s => s.IsActive)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
if (activeSources.Count == 0)
|
||||
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven Mapping-Quellen.");
|
||||
if (!mappings.Any(m => m.IsActive))
|
||||
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven Feldmappings.");
|
||||
|
||||
var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
|
||||
if (!sourceRows.TryGetValue(primarySource.Alias, out var primaryRows))
|
||||
throw new InvalidOperationException($"Primaerquelle '{primarySource.Alias}' wurde nicht geladen.");
|
||||
|
||||
var composedRows = primaryRows
|
||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
||||
.ToList();
|
||||
|
||||
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
|
||||
{
|
||||
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
|
||||
continue;
|
||||
|
||||
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
|
||||
}
|
||||
|
||||
return composedRows
|
||||
.Select(row => MapToSalesRecord(site, row, mappings, defaultDocumentType))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
||||
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static List<Dictionary<string, object?>> ApplyLeftJoin(
|
||||
List<Dictionary<string, object?>> leftRows,
|
||||
string leftAlias,
|
||||
string leftKeys,
|
||||
string rightAlias,
|
||||
string rightKeys,
|
||||
List<Dictionary<string, object?>> rightRows)
|
||||
{
|
||||
var leftKeyParts = SplitKeys(leftKeys);
|
||||
var rightKeyParts = SplitKeys(rightKeys);
|
||||
if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count)
|
||||
return leftRows;
|
||||
|
||||
var rightLookup = rightRows
|
||||
.GroupBy(r => BuildKey(r, rightKeyParts))
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var results = new List<Dictionary<string, object?>>();
|
||||
foreach (var leftRow in leftRows)
|
||||
{
|
||||
var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts);
|
||||
if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var merged = new Dictionary<string, object?>(leftRow, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in PrefixRow(rightAlias, match))
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
results.Add(merged);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(leftRow);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SalesRecord MapToSalesRecord(
|
||||
Site site,
|
||||
Dictionary<string, object?> row,
|
||||
IReadOnlyList<SapFieldMapping> mappings,
|
||||
string defaultDocumentType)
|
||||
{
|
||||
var record = new SalesRecord
|
||||
{
|
||||
ExtractionDate = DateTime.UtcNow,
|
||||
Tsc = site.TSC,
|
||||
Land = site.Land,
|
||||
DocumentType = defaultDocumentType
|
||||
};
|
||||
|
||||
foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id))
|
||||
{
|
||||
var value = EvaluateExpression(row, mapping.SourceExpression);
|
||||
ApplyValue(record, mapping.TargetField, value);
|
||||
}
|
||||
|
||||
if (record.ExtractionDate == default)
|
||||
record.ExtractionDate = DateTime.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(record.Tsc))
|
||||
record.Tsc = site.TSC;
|
||||
if (string.IsNullOrWhiteSpace(record.Land))
|
||||
record.Land = site.Land;
|
||||
if (string.IsNullOrWhiteSpace(record.DocumentType))
|
||||
record.DocumentType = defaultDocumentType;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static object? EvaluateExpression(Dictionary<string, object?> row, string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return null;
|
||||
|
||||
var value = expression.Trim();
|
||||
if (value.StartsWith('='))
|
||||
return value[1..];
|
||||
|
||||
if (row.TryGetValue(value, out var direct))
|
||||
return direct;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyValue(SalesRecord record, string targetField, object? value)
|
||||
{
|
||||
var property = typeof(SalesRecord).GetProperty(targetField);
|
||||
if (property is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(record, value?.ToString() ?? string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(int))
|
||||
{
|
||||
if (TryConvertInt(value, out var intValue))
|
||||
property.SetValue(record, intValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(decimal))
|
||||
{
|
||||
if (TryConvertDecimal(value, out var decimalValue))
|
||||
property.SetValue(record, decimalValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
|
||||
{
|
||||
if (TryConvertDate(value, out var date))
|
||||
property.SetValue(record, date);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid field mappings should not stop the remaining row mapping.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryConvertInt(object? value, out int result)
|
||||
{
|
||||
result = default;
|
||||
if (TryConvertDecimal(value, out var decimalValue))
|
||||
{
|
||||
result = (int)Math.Round(decimalValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConvertDecimal(object? value, out decimal result)
|
||||
{
|
||||
result = default;
|
||||
if (value is null)
|
||||
return false;
|
||||
if (value is decimal decimalValue)
|
||||
{
|
||||
result = decimalValue;
|
||||
return true;
|
||||
}
|
||||
if (value is IConvertible convertible && value is not string)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = convertible.ToDecimal(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to culture-aware string parsing below.
|
||||
}
|
||||
}
|
||||
|
||||
var text = value.ToString()?.Trim();
|
||||
return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out result)
|
||||
|| decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out result)
|
||||
|| decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out result);
|
||||
}
|
||||
|
||||
private static bool TryConvertDate(object? value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
if (value is null)
|
||||
return false;
|
||||
if (value is DateTime dateTime)
|
||||
{
|
||||
date = dateTime;
|
||||
return true;
|
||||
}
|
||||
if (value is DateTimeOffset dateTimeOffset)
|
||||
{
|
||||
date = dateTimeOffset.DateTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
var text = value.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
if (text.StartsWith("/Date(", StringComparison.Ordinal) && text.EndsWith(")/", StringComparison.Ordinal))
|
||||
{
|
||||
var epochRaw = text[6..^2];
|
||||
var separator = epochRaw.IndexOfAny(['+', '-']);
|
||||
if (separator > 0)
|
||||
epochRaw = epochRaw[..separator];
|
||||
if (long.TryParse(epochRaw, out var ms))
|
||||
{
|
||||
date = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date)
|
||||
|| DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date)
|
||||
|| DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.AssumeLocal, out date);
|
||||
}
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k =>
|
||||
{
|
||||
row.TryGetValue($"{alias}.{k}", out var value);
|
||||
return NormalizeKeyValue(value);
|
||||
}));
|
||||
|
||||
private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty;
|
||||
|
||||
private static List<string> SplitKeys(string keys)
|
||||
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
@@ -6,11 +5,16 @@ namespace TrafagSalesExporter.Services;
|
||||
public class SapCompositionService : ISapCompositionService
|
||||
{
|
||||
private readonly ISapGatewayService _sapGatewayService;
|
||||
private readonly IMappedSalesRecordComposer _composer;
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
|
||||
public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService)
|
||||
public SapCompositionService(
|
||||
ISapGatewayService sapGatewayService,
|
||||
IMappedSalesRecordComposer composer,
|
||||
IAppEventLogService appEventLogService)
|
||||
{
|
||||
_sapGatewayService = sapGatewayService;
|
||||
_composer = composer;
|
||||
_appEventLogService = appEventLogService;
|
||||
}
|
||||
|
||||
@@ -46,192 +50,11 @@ public class SapCompositionService : ISapCompositionService
|
||||
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
|
||||
}
|
||||
|
||||
var composedRows = sourceRows[primarySource.Alias]
|
||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
||||
.ToList();
|
||||
await _appEventLogService.WriteDebugAsync("SAP", "Primärquelle vorbereitet", site.Id, site.Land,
|
||||
$"Alias={primarySource.Alias} | Startzeilen={composedRows.Count}");
|
||||
|
||||
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
|
||||
{
|
||||
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
|
||||
continue;
|
||||
|
||||
await _appEventLogService.WriteDebugAsync("SAP", "Join gestartet", site.Id, site.Land,
|
||||
$"{join.LeftAlias}({join.LeftKeys}) -> {join.RightAlias}({join.RightKeys}) | RightRows={rightRows.Count}");
|
||||
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
|
||||
await _appEventLogService.WriteDebugAsync("SAP", "Join beendet", site.Id, site.Land,
|
||||
$"{join.LeftAlias} -> {join.RightAlias} | Ergebniszeilen={composedRows.Count}");
|
||||
}
|
||||
|
||||
var result = composedRows
|
||||
.Select(row => MapToSalesRecord(site, row, mappings))
|
||||
.ToList();
|
||||
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema gestartet", site.Id, site.Land,
|
||||
$"Primaerquelle={primarySource.Alias} | Mappings={mappings.Count(x => x.IsActive)}");
|
||||
var result = _composer.Compose(site, activeSources, joins, mappings, sourceRows, "SAP");
|
||||
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land,
|
||||
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
||||
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static List<Dictionary<string, object?>> ApplyLeftJoin(
|
||||
List<Dictionary<string, object?>> leftRows,
|
||||
string leftAlias,
|
||||
string leftKeys,
|
||||
string rightAlias,
|
||||
string rightKeys,
|
||||
List<Dictionary<string, object?>> rightRows)
|
||||
{
|
||||
var leftKeyParts = SplitKeys(leftKeys);
|
||||
var rightKeyParts = SplitKeys(rightKeys);
|
||||
if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count)
|
||||
return leftRows;
|
||||
|
||||
var rightLookup = rightRows
|
||||
.GroupBy(r => BuildKey(r, rightKeyParts))
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var results = new List<Dictionary<string, object?>>();
|
||||
foreach (var leftRow in leftRows)
|
||||
{
|
||||
var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts);
|
||||
if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var merged = new Dictionary<string, object?>(leftRow, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in PrefixRow(rightAlias, match))
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
results.Add(merged);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(leftRow);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SalesRecord MapToSalesRecord(Site site, Dictionary<string, object?> row, IReadOnlyList<SapFieldMapping> mappings)
|
||||
{
|
||||
var record = new SalesRecord
|
||||
{
|
||||
ExtractionDate = DateTime.UtcNow,
|
||||
Tsc = site.TSC,
|
||||
Land = site.Land,
|
||||
DocumentType = "SAP"
|
||||
};
|
||||
|
||||
foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id))
|
||||
{
|
||||
var value = EvaluateExpression(row, mapping.SourceExpression);
|
||||
ApplyValue(record, mapping.TargetField, value);
|
||||
}
|
||||
|
||||
if (record.ExtractionDate == default)
|
||||
record.ExtractionDate = DateTime.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(record.Tsc))
|
||||
record.Tsc = site.TSC;
|
||||
if (string.IsNullOrWhiteSpace(record.Land))
|
||||
record.Land = site.Land;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static object? EvaluateExpression(Dictionary<string, object?> row, string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return null;
|
||||
|
||||
var value = expression.Trim();
|
||||
if (value.StartsWith('='))
|
||||
return value[1..];
|
||||
|
||||
if (row.TryGetValue(value, out var direct))
|
||||
return direct;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyValue(SalesRecord record, string targetField, object? value)
|
||||
{
|
||||
var property = typeof(SalesRecord).GetProperty(targetField);
|
||||
if (property is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(record, value?.ToString() ?? string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
|
||||
property.SetValue(record, intValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(decimal))
|
||||
{
|
||||
if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
property.SetValue(record, decimalValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
|
||||
{
|
||||
if (TryParseDate(value?.ToString(), out var date))
|
||||
property.SetValue(record, date);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore invalid mappings and continue with remaining fields
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("/Date(", StringComparison.Ordinal) && trimmed.EndsWith(")/", StringComparison.Ordinal))
|
||||
{
|
||||
var epochRaw = trimmed[6..^2];
|
||||
var separator = epochRaw.IndexOfAny(['+', '-']);
|
||||
if (separator > 0)
|
||||
epochRaw = epochRaw[..separator];
|
||||
if (long.TryParse(epochRaw, out var ms))
|
||||
{
|
||||
date = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date)
|
||||
|| DateTime.TryParse(trimmed, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date);
|
||||
}
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k =>
|
||||
{
|
||||
row.TryGetValue($"{alias}.{k}", out var value);
|
||||
return NormalizeKeyValue(value);
|
||||
}));
|
||||
|
||||
private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty;
|
||||
|
||||
private static List<string> SplitKeys(string keys)
|
||||
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.VisualBasic.FileIO;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
@@ -27,6 +28,7 @@ public sealed class StandortePageService : IStandortePageService
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly IHanaQueryService _hanaService;
|
||||
private readonly ISapGatewayService _sapGatewayService;
|
||||
private readonly IStandorteSapEditorService _sapEditorService;
|
||||
private readonly ISharePointUploadService _sharePointService;
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
|
||||
@@ -34,12 +36,14 @@ public sealed class StandortePageService : IStandortePageService
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
IHanaQueryService hanaService,
|
||||
ISapGatewayService sapGatewayService,
|
||||
IStandorteSapEditorService sapEditorService,
|
||||
ISharePointUploadService sharePointService,
|
||||
IAppEventLogService appEventLogService)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_hanaService = hanaService;
|
||||
_sapGatewayService = sapGatewayService;
|
||||
_sapEditorService = sapEditorService;
|
||||
_sharePointService = sharePointService;
|
||||
_appEventLogService = appEventLogService;
|
||||
}
|
||||
@@ -401,8 +405,8 @@ public sealed class StandortePageService : IStandortePageService
|
||||
var trimmedPath = manualImportFilePath.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedPath))
|
||||
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
|
||||
if (!string.Equals(Path.GetExtension(trimmedPath), ".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
|
||||
if (!IsSupportedManualImportFile(trimmedPath))
|
||||
throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv angeben.");
|
||||
|
||||
if (File.Exists(trimmedPath))
|
||||
return File.GetLastWriteTimeUtc(trimmedPath);
|
||||
@@ -440,18 +444,9 @@ public sealed class StandortePageService : IStandortePageService
|
||||
var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
using var workbook = new XLWorkbook(filePath);
|
||||
var worksheet = workbook.Worksheets.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
||||
var usedRange = worksheet.RangeUsed()
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
|
||||
|
||||
return usedRange.FirstRow().CellsUsed()
|
||||
.Select(cell => cell.GetString().Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return string.Equals(Path.GetExtension(manualImportFilePath?.Trim()), ".csv", StringComparison.OrdinalIgnoreCase)
|
||||
? LoadCsvHeaders(filePath)
|
||||
: LoadExcelHeaders(filePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -541,21 +536,45 @@ public sealed class StandortePageService : IStandortePageService
|
||||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void NormalizeSapConfigCollections(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
|
||||
{
|
||||
for (var i = 0; i < sapSources.Count; i++)
|
||||
sapSources[i].SortOrder = i;
|
||||
for (var i = 0; i < sapJoins.Count; i++)
|
||||
sapJoins[i].SortOrder = i;
|
||||
for (var i = 0; i < sapMappings.Count; i++)
|
||||
sapMappings[i].SortOrder = i;
|
||||
private static bool IsSupportedManualImportFile(string path)
|
||||
=> string.Equals(Path.GetExtension(path), ".xlsx", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var selectedPrimaryIndex = sapSources.FindIndex(s => s.IsPrimary);
|
||||
var primarySource = selectedPrimaryIndex >= 0 ? sapSources[selectedPrimaryIndex] : sapSources.FirstOrDefault();
|
||||
foreach (var source in sapSources)
|
||||
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
|
||||
if (sapSources.Count > 0 && sapSources.All(s => !s.IsPrimary))
|
||||
sapSources[0].IsPrimary = true;
|
||||
private static List<string> LoadExcelHeaders(string filePath)
|
||||
{
|
||||
using var workbook = new XLWorkbook(filePath);
|
||||
var worksheet = workbook.Worksheets.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
||||
var usedRange = worksheet.RangeUsed()
|
||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt keine Daten.");
|
||||
|
||||
return usedRange.FirstRow().CellsUsed()
|
||||
.Select(cell => cell.GetString().Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<string> LoadCsvHeaders(string filePath)
|
||||
{
|
||||
using var parser = new TextFieldParser(filePath)
|
||||
{
|
||||
TextFieldType = FieldType.Delimited,
|
||||
HasFieldsEnclosedInQuotes = true,
|
||||
TrimWhiteSpace = false
|
||||
};
|
||||
parser.SetDelimiters(";");
|
||||
|
||||
var header = parser.ReadFields()
|
||||
?? throw new InvalidOperationException("Die CSV-Datei enthaelt keine Kopfzeile.");
|
||||
|
||||
return header
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void NormalizeManualExcelMappings(List<ManualExcelColumnMapping> manualExcelMappings)
|
||||
@@ -564,7 +583,7 @@ public sealed class StandortePageService : IStandortePageService
|
||||
manualExcelMappings[i].SortOrder = i;
|
||||
}
|
||||
|
||||
private static async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
|
||||
private async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
|
||||
{
|
||||
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
|
||||
var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync();
|
||||
@@ -575,7 +594,7 @@ public sealed class StandortePageService : IStandortePageService
|
||||
|
||||
if (isSapSite)
|
||||
{
|
||||
NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings);
|
||||
_sapEditorService.NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings);
|
||||
foreach (var source in sapSources) source.SiteId = siteId;
|
||||
foreach (var join in sapJoins) join.SiteId = siteId;
|
||||
foreach (var mapping in sapMappings) mapping.SiteId = siteId;
|
||||
|
||||
Reference in New Issue
Block a user