Add configurable finance rules and dashboard basis indicators

This commit is contained in:
2026-05-20 13:10:33 +02:00
parent 5e305ae396
commit d66074b740
23 changed files with 1028 additions and 204 deletions
@@ -35,6 +35,10 @@ public class ConfigTransferService : IConfigTransferService
.ThenBy(x => x.CustomerNumber)
.ThenBy(x => x.CustomerNameContains)
.ToListAsync();
var financeRules = await db.FinanceRules
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.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();
@@ -106,6 +110,19 @@ public class ConfigTransferService : IConfigTransferService
Notes = rule.Notes,
IsActive = rule.IsActive
}).ToList(),
FinanceRules = financeRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{
Key = serverKeyMap[server.Id],
@@ -206,6 +223,7 @@ public class ConfigTransferService : IConfigTransferService
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
var existingFinanceRules = await db.FinanceRules.ToListAsync();
var existingSites = await db.Sites.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync();
@@ -235,6 +253,8 @@ public class ConfigTransferService : IConfigTransferService
db.FinanceReferences.RemoveRange(existingFinanceReferences);
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
if (package.FinanceRules.Count > 0 && existingFinanceRules.Count > 0)
db.FinanceRules.RemoveRange(existingFinanceRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
@@ -321,6 +341,23 @@ public class ConfigTransferService : IConfigTransferService
}));
}
if (package.FinanceRules.Count > 0)
{
db.FinanceRules.AddRange(package.FinanceRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
@@ -46,6 +46,7 @@ public sealed class DashboardPageService : IDashboardPageService
{
SiteId = s.Id,
Land = s.Land,
DataBasis = ResolveDataBasis(s, sourceSystem),
TSC = s.TSC,
Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
@@ -110,6 +111,32 @@ public sealed class DashboardPageService : IDashboardPageService
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
}
private static string ResolveDataBasis(Site site, SourceSystemDefinition? sourceSystem)
{
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
{
var path = site.ManualImportFilePath ?? string.Empty;
var extension = Path.GetExtension(path).TrimStart('.').ToUpperInvariant();
if (extension is "CSV")
return "CSV-Datei";
if (extension is "XLS" or "XLSX" or "XLSM")
return "Excel-Datei";
if (!string.IsNullOrWhiteSpace(path))
return "Excel/CSV-Datei";
return "Manuelle Datei";
}
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return "SAP Service";
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
return "Server";
return string.IsNullOrWhiteSpace(site.SourceSystem) ? "-" : site.SourceSystem;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
@@ -156,6 +183,7 @@ public sealed class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string DataBasis { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Schema { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
@@ -192,4 +192,19 @@ CREATE TABLE FinanceIntercompanyRules (
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
internal static string GetFinanceRulesCreateSql() => @"
CREATE TABLE FinanceRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ScopeKey TEXT NOT NULL DEFAULT '',
Year INTEGER NULL,
RuleType TEXT NOT NULL DEFAULT 'Exclude',
FieldName TEXT NOT NULL DEFAULT '',
MatchType TEXT NOT NULL DEFAULT 'Contains',
MatchValue TEXT NOT NULL DEFAULT '',
NumericValue TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
}
@@ -36,6 +36,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureCurrencyExchangeRateTable(db);
EnsureFinanceReferenceTable(db);
EnsureFinanceIntercompanyRuleTable(db);
EnsureFinanceRuleTable(db);
EnsureSourceSystemDefinitionTable(db);
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
EnsureSapSourceTable(db);
@@ -317,6 +318,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
cmd.ExecuteNonQuery();
}
private static void EnsureFinanceRuleTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetFinanceRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
@@ -19,6 +19,7 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(db);
EnsureFinanceRuleDefaults(db);
}
private static void SeedIfEmpty(AppDbContext db)
@@ -893,4 +894,39 @@ public class DatabaseSeedService : IDatabaseSeedService
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceRuleDefaults(AppDbContext db)
{
if (!CanUseTable(db, "FinanceRules"))
return;
var changed = false;
foreach (var item in FinanceRuleEngine.CreateDefaultRules())
{
var exists = db.FinanceRules.Any(rule =>
rule.ScopeKey == item.ScopeKey &&
rule.RuleType == item.RuleType &&
rule.FieldName == item.FieldName &&
rule.MatchType == item.MatchType &&
rule.MatchValue == item.MatchValue);
if (exists)
continue;
db.FinanceRules.Add(item);
changed = true;
}
if (changed)
db.SaveChanges();
}
private static bool CanUseTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
return DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName).Count > 0;
}
}
@@ -1,18 +1,29 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService : IExcelExportService
{
private const int GermanyAlphaplanFinanceYear = 2025;
private readonly IDbContextFactory<AppDbContext>? _dbFactory;
public ExcelExportService()
{
}
public ExcelExportService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: false);
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: false);
return fullPath;
}
@@ -21,7 +32,7 @@ public class ExcelExportService : IExcelExportService
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: true);
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: true);
return fullPath;
}
@@ -36,9 +47,32 @@ public class ExcelExportService : IExcelExportService
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, FinanceRuleEngine.CreateDefaultRules());
private void WriteWorkbookWithConfiguredRules(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, LoadFinanceRules());
private IReadOnlyList<FinanceRule> LoadFinanceRules()
{
if (_dbFactory is null)
return FinanceRuleEngine.CreateDefaultRules();
using var db = _dbFactory.CreateDbContext();
var rules = db.FinanceRules
.AsNoTracking()
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
return rules.Count == 0 ? FinanceRuleEngine.CreateDefaultRules() : rules;
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet, IReadOnlyList<FinanceRule> financeRules)
{
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var headers = new[]
{
@@ -93,7 +127,6 @@ public class ExcelExportService : IExcelExportService
}
var row = 2;
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
@@ -131,10 +164,10 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
var financeDate = ResolveFinanceDate(record);
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeInclude = ResolveFinanceInclude(record, financeCountryKey, italyBlankSupplierCountryRows);
var financeNetSalesActual = ResolveFinanceNetSalesActual(record, financeCountryKey, financeInclude);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, financeCountryKey);
var financeInclude = financeRuleEngine.ShouldInclude(record, financeCountryKey);
var financeNetSalesActual = financeRuleEngine.ResolveNetSalesActual(record, financeCountryKey, financeInclude);
ws.Cell(row, 36).Value = financeDate.Year;
ws.Cell(row, 37).Value = financeCountryKey;
ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy");
@@ -143,23 +176,24 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 41).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
ws.Cell(row, 42).Value = financeInclude
? "Sales Price/Value"
: ResolveFinanceExclusionReason(record, financeCountryKey);
: financeRuleEngine.ResolveExclusionReason(record, financeCountryKey);
row++;
}
ws.Columns().AdjustToContents();
if (includeFinanceHelpSheet)
{
AddFinanceSummarySheet(workbook, records);
AddFinanceSummarySheet(workbook, records, financeRules);
AddFinanceHelpSheet(workbook);
}
workbook.SaveAs(fullPath);
}
private static void AddFinanceSummarySheet(XLWorkbook workbook, List<SalesRecord> records)
private static void AddFinanceSummarySheet(XLWorkbook workbook, List<SalesRecord> records, IReadOnlyList<FinanceRule> financeRules)
{
var ws = workbook.Worksheets.Add("Finance Summary");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
ws.Position = 1;
ws.Cell(1, 1).Value = "Finance Summary";
ws.Cell(1, 1).Style.Font.Bold = true;
@@ -183,14 +217,13 @@ public class ExcelExportService : IExcelExportService
ws.Cell(4, i + 1).Style.Font.Bold = true;
}
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summaryRows = records
.Select(record =>
{
var financeDate = ResolveFinanceDate(record);
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var rawInclude = ResolveFinanceInclude(record, countryKey, italyBlankSupplierCountryRows);
var value = ResolveFinanceNetSalesActual(record, countryKey, rawInclude);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, countryKey);
var rawInclude = financeRuleEngine.ShouldInclude(record, countryKey);
var value = financeRuleEngine.ResolveNetSalesActual(record, countryKey, rawInclude);
var include = rawInclude && value != 0m;
return new
{
@@ -298,15 +331,6 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
}
private static DateTime ResolveFinanceDate(SalesRecord record)
{
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
if (countryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return new DateTime(GermanyAlphaplanFinanceYear, 12, 31);
return record.PostingDate ?? record.InvoiceDate ?? record.ExtractionDate;
}
private static string ResolveFinanceCurrency(SalesRecord record)
=> ResolveFinanceCountryKey(record.Land, record.Tsc) switch
{
@@ -340,108 +364,6 @@ public class ExcelExportService : IExcelExportService
return normalizedTsc.Replace("TR", string.Empty);
}
private static bool ResolveFinanceInclude(SalesRecord record, string financeCountryKey, HashSet<string> italyBlankSupplierCountryRows)
{
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return IsIncludedGermanyFinanceRow(record);
if (!financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase))
return true;
if (IsExcludedItalyCustomer(record))
return false;
if (!string.IsNullOrWhiteSpace(record.SupplierCountry))
return true;
return italyBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(record));
}
private static string ResolveFinanceExclusionReason(SalesRecord record, string financeCountryKey)
{
if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && IsExcludedItalyCustomer(record))
return "Excluded IT customer: Trafag Italia";
if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(record.SupplierCountry))
return "Excluded IT duplicate without Supplier country";
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return ResolveGermanyExclusionReason(record);
return "Excluded";
}
private static decimal ResolveFinanceNetSalesActual(SalesRecord record, string financeCountryKey, bool financeInclude)
{
if (!financeInclude)
return 0m;
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase) && IsGermanyCreditNote(record))
return -Math.Abs(record.SalesPriceValue);
return record.SalesPriceValue;
}
private static bool IsIncludedGermanyFinanceRow(SalesRecord record)
=> !IsGermanyTrafagAgRecharge(record) &&
!IsGermanyMagneticSenseRecharge(record) &&
!IsGermanyCreditNoteAlreadyCapturedInPriorYear(record);
private static string ResolveGermanyExclusionReason(SalesRecord record)
{
if (IsGermanyTrafagAgRecharge(record))
return "Excluded DE Weiterberechnung Trafag AG";
if (IsGermanyMagneticSenseRecharge(record))
return "Excluded DE Weiterberechnung Magnetic Sense";
if (IsGermanyCreditNoteAlreadyCapturedInPriorYear(record))
return "Excluded DE GS2510095 already captured in 2024";
return "Excluded DE";
}
private static bool IsGermanyTrafagAgRecharge(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName) == "TRAFAG AG";
private static bool IsGermanyMagneticSenseRecharge(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName).Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase);
private static bool IsGermanyCreditNote(SalesRecord record)
=> (record.InvoiceNumber ?? string.Empty).Trim().StartsWith("GS", StringComparison.OrdinalIgnoreCase);
private static bool IsGermanyCreditNoteAlreadyCapturedInPriorYear(SalesRecord record)
=> (record.InvoiceNumber ?? string.Empty).Trim().Equals("GS2510095", StringComparison.OrdinalIgnoreCase);
private static bool IsExcludedItalyCustomer(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase);
private static string BuildItalyBlankSupplierCountryDeduplicationKey(SalesRecord record)
=> string.Join("|",
record.Tsc,
record.DocumentType,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.CustomerNumber,
record.CustomerName,
record.SalesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency,
record.PostingDate?.ToString("O") ?? string.Empty,
record.InvoiceDate?.ToString("O") ?? string.Empty);
private static string NormalizeFinanceText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
using var workbook = new XLWorkbook();
@@ -31,35 +31,51 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
.AsNoTracking()
.Where(r => r.IsActive)
.ToListAsync();
var centralRows = await db.CentralSalesRecords
var financeRules = await db.FinanceRules
.AsNoTracking()
.Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new NetSalesActualSourceRow(
r.Land,
r.Tsc,
r.DocumentEntry,
r.InvoiceNumber,
r.PositionOnInvoice,
r.Material,
r.Name,
r.Quantity,
r.DocumentType,
r.PostingDate,
r.InvoiceDate,
r.ExtractionDate,
r.CustomerNumber,
r.CustomerName,
r.SupplierCountry,
r.SalesCurrency,
r.DocumentCurrency,
r.CompanyCurrency,
r.SalesPriceValue,
r.DocumentTotalForeignCurrency,
r.DocumentTotalLocalCurrency,
r.VatSumForeignCurrency,
r.VatSumLocalCurrency))
.Where(r => r.IsActive)
.OrderBy(r => r.SortOrder)
.ThenBy(r => r.Id)
.ToListAsync();
if (financeRules.Count == 0)
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var centralRecords = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
var centralRows = centralRecords
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
.Where(row => row is not null)
.Select(row => row!)
.ToList();
var groupedActuals = centralRows
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
@@ -149,13 +165,50 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return result;
}
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
{
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
if (financeRuleEngine.ResolveFinanceDate(record, referenceKey).Year != year)
return null;
var include = financeRuleEngine.ShouldInclude(record, referenceKey);
if (!include)
return null;
var salesPriceValue = financeRuleEngine.ResolveNetSalesActual(record, referenceKey, include);
return new NetSalesActualSourceRow(
record.Land,
record.Tsc,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.DocumentType,
record.PostingDate,
record.InvoiceDate,
record.ExtractionDate,
record.CustomerNumber,
record.CustomerName,
record.SupplierCountry,
record.SalesCurrency,
record.DocumentCurrency,
record.CompanyCurrency,
salesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency);
}
private static NetSalesActual BuildNetSalesActual(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
{
var rowList = ApplyCountryFinanceRules(referenceKey, rows).ToList();
var rowList = rows.ToList();
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
var documentRows = rowList
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
@@ -244,50 +297,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
}
private static IEnumerable<NetSalesActualSourceRow> ApplyCountryFinanceRules(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows)
{
if (!referenceKey.Equals("IT", StringComparison.OrdinalIgnoreCase))
return rows;
var seenBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return rows.Where(row =>
{
if (IsExcludedItalyCustomer(row))
return false;
if (!string.IsNullOrWhiteSpace(row.SupplierCountry))
return true;
return seenBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(row));
});
}
private static bool IsExcludedItalyCustomer(NetSalesActualSourceRow row)
=> ResolveReferenceKey(row.Land, row.Tsc).Equals("IT", StringComparison.OrdinalIgnoreCase) &&
NormalizeRuleText(row.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase);
private static string BuildItalyBlankSupplierCountryDeduplicationKey(NetSalesActualSourceRow row)
=> string.Join("|",
row.Tsc,
row.DocumentType,
row.DocumentEntry,
row.InvoiceNumber,
row.PositionOnInvoice,
row.Material,
row.Name,
row.Quantity,
row.CustomerNumber,
row.CustomerName,
row.SalesPriceValue,
row.DocumentTotalForeignCurrency,
row.DocumentTotalLocalCurrency,
row.VatSumForeignCurrency,
row.VatSumLocalCurrency,
row.PostingDate?.ToString("O") ?? string.Empty,
row.InvoiceDate?.ToString("O") ?? string.Empty);
private static decimal ConvertHouseCurrencyNetToBudgetChf(
string houseCurrency,
NetSalesActualSourceRow row,
@@ -0,0 +1,238 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class FinanceRuleEngine
{
private readonly IReadOnlyList<FinanceRule> _rules;
private readonly Dictionary<string, HashSet<string>> _deduplicationKeys = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, PropertyInfo> SalesRecordProperties = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(x => x.Name, x => x, StringComparer.OrdinalIgnoreCase);
public FinanceRuleEngine(IEnumerable<FinanceRule> rules)
{
_rules = rules
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
}
public DateTime ResolveFinanceDate(SalesRecord record, string countryKey)
{
var forceYear = _rules.FirstOrDefault(rule =>
IsRuleInScope(rule, countryKey) &&
rule.RuleType.Equals(FinanceRuleTypes.ForceYear, StringComparison.OrdinalIgnoreCase) &&
RuleMatches(rule, record));
if (forceYear?.Year is > 0)
return new DateTime(forceYear.Year.Value, 12, 31);
return record.PostingDate ?? record.InvoiceDate ?? record.ExtractionDate;
}
public bool ShouldInclude(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return false;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
{
var seen = GetDeduplicationSet(rule, countryKey);
return seen.Add(BuildBlankSupplierCountryDeduplicationKey(record));
}
}
return true;
}
public decimal ResolveNetSalesActual(SalesRecord record, string countryKey, bool include)
{
if (!include)
return 0m;
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!rule.RuleType.Equals(FinanceRuleTypes.NegateAmount, StringComparison.OrdinalIgnoreCase) ||
!RuleMatches(rule, record))
continue;
return -Math.Abs(record.SalesPriceValue);
}
return record.SalesPriceValue;
}
public string ResolveExclusionReason(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey}" : rule.Notes;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey} duplicate without Supplier country" : rule.Notes;
}
return $"Excluded {countryKey}";
}
public static IReadOnlyList<FinanceRule> CreateDefaultRules()
=>
[
new FinanceRule
{
ScopeKey = "DE",
Year = 2025,
RuleType = FinanceRuleTypes.ForceYear,
MatchType = FinanceRuleMatchTypes.Always,
Notes = "DE Alphaplan Jahresfile 2025",
SortOrder = 100
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "Trafag AG",
Notes = "Excluded DE Weiterberechnung Trafag AG",
SortOrder = 110
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Magnetic Sense",
Notes = "Excluded DE Weiterberechnung Magnetic Sense",
SortOrder = 120
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "GS2510095",
Notes = "Excluded DE GS2510095 already captured in 2024",
SortOrder = 130
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.NegateAmount,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.StartsWith,
MatchValue = "GS",
Notes = "DE Gutschriften negativ",
SortOrder = 140
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Trafag Italia",
Notes = "Excluded IT customer: Trafag Italia",
SortOrder = 200
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.DeduplicateBlankSupplierCountry,
FieldName = nameof(SalesRecord.SupplierCountry),
MatchType = FinanceRuleMatchTypes.IsBlank,
Notes = "Excluded IT duplicate without Supplier country",
SortOrder = 210
}
];
private HashSet<string> GetDeduplicationSet(FinanceRule rule, string countryKey)
{
var key = $"{countryKey}|{rule.Id}|{rule.SortOrder}|{rule.RuleType}";
if (!_deduplicationKeys.TryGetValue(key, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_deduplicationKeys[key] = set;
}
return set;
}
private static bool IsRuleInScope(FinanceRule rule, string countryKey)
=> string.IsNullOrWhiteSpace(rule.ScopeKey) ||
rule.ScopeKey.Equals(countryKey, StringComparison.OrdinalIgnoreCase);
private static bool RuleMatches(FinanceRule rule, SalesRecord record)
{
if (rule.MatchType.Equals(FinanceRuleMatchTypes.Always, StringComparison.OrdinalIgnoreCase))
return true;
var value = ReadRecordValue(record, rule.FieldName);
var normalizedValue = NormalizeFinanceText(value);
var normalizedMatch = NormalizeFinanceText(rule.MatchValue);
return rule.MatchType switch
{
FinanceRuleMatchTypes.Equal => normalizedValue.Equals(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.Contains => normalizedValue.Contains(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.StartsWith => normalizedValue.StartsWith(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.IsBlank => string.IsNullOrWhiteSpace(value),
_ => false
};
}
private static string ReadRecordValue(SalesRecord record, string fieldName)
{
if (string.IsNullOrWhiteSpace(fieldName))
return string.Empty;
return SalesRecordProperties.TryGetValue(fieldName, out var property)
? property.GetValue(record)?.ToString() ?? string.Empty
: string.Empty;
}
private static string BuildBlankSupplierCountryDeduplicationKey(SalesRecord record)
=> string.Join("|",
record.Tsc,
record.DocumentType,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.CustomerNumber,
record.CustomerName,
record.SalesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency,
record.PostingDate?.ToString("O") ?? string.Empty,
record.InvoiceDate?.ToString("O") ?? string.Empty);
private static string NormalizeFinanceText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IFinanceRulesPageService
{
Task<List<FinanceRule>> LoadAsync();
Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules);
}
public sealed class FinanceRulesPageService : IFinanceRulesPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public FinanceRulesPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<FinanceRule>> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var rules = await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
if (rules.Count > 0)
return rules;
return FinanceRuleEngine.CreateDefaultRules()
.Select(CloneRule)
.ToList();
}
public async Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules)
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.FinanceRules.RemoveRange(db.FinanceRules);
db.FinanceRules.AddRange(rules
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.Select(CloneRule));
await db.SaveChangesAsync();
return await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
}
private static FinanceRule CloneRule(FinanceRule rule)
=> new()
{
ScopeKey = rule.ScopeKey.Trim().ToUpperInvariant(),
Year = rule.Year,
RuleType = string.IsNullOrWhiteSpace(rule.RuleType) ? FinanceRuleTypes.Exclude : rule.RuleType,
FieldName = rule.FieldName ?? string.Empty,
MatchType = string.IsNullOrWhiteSpace(rule.MatchType) ? FinanceRuleMatchTypes.Contains : rule.MatchType,
MatchValue = rule.MatchValue ?? string.Empty,
NumericValue = rule.NumericValue,
Notes = rule.Notes ?? string.Empty,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
};
}