From d66074b74014422731ae9e7bcbd4c50ec57a1c91 Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 20 May 2026 13:10:33 +0200 Subject: [PATCH] Add configurable finance rules and dashboard basis indicators --- .../Components/Layout/NavMenu.razor | 15 +- .../Components/Pages/Dashboard.razor | 39 +++ .../Components/Pages/FinanceComparison.razor | 2 +- .../Components/Pages/FinanceRules.razor | 167 ++++++++++++ TrafagSalesExporter/Components/Routes.razor | 1 + TrafagSalesExporter/Data/AppDbContext.cs | 1 + .../Models/ConfigTransferPackage.cs | 1 + TrafagSalesExporter/Models/FinanceRule.cs | 50 ++++ TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 69 +++++ TrafagSalesExporter/Program.cs | 1 + .../Services/ConfigTransferService.cs | 37 +++ .../Services/DashboardPageService.cs | 28 +++ ...DatabaseInitializationService.SchemaSql.cs | 15 ++ .../DatabaseSchemaMaintenanceService.cs | 12 + .../Services/DatabaseSeedService.cs | 36 +++ .../Services/ExcelExportService.cs | 172 ++++--------- .../Services/FinanceReconciliationService.cs | 153 +++++------ .../Services/FinanceRuleEngine.cs | 238 ++++++++++++++++++ .../Services/FinanceRulesPageService.cs | 70 ++++++ .../FinanceReconciliationServiceTests.cs | 6 +- TrafagSalesExporter/appsettings.json | 3 + .../docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md | 35 +++ TrafagSalesExporter/lastchange.md | 81 ++++++ 23 files changed, 1028 insertions(+), 204 deletions(-) create mode 100644 TrafagSalesExporter/Components/Pages/FinanceRules.razor create mode 100644 TrafagSalesExporter/Models/FinanceRule.cs create mode 100644 TrafagSalesExporter/Services/FinanceRuleEngine.cs create mode 100644 TrafagSalesExporter/Services/FinanceRulesPageService.cs diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index 0613374..9a1d2fd 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -1,6 +1,7 @@ @using TrafagSalesExporter.Security @inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess +@inject IConfiguration Configuration @inject NavigationManager Navigation @@ -11,9 +12,12 @@ @T("Management Analyse", "Management analysis") - - @T("Soll/Ist Vergleich", "Actual/reference comparison") - + @if (ShowFinanceComparison) + { + + @T("Soll/Ist Vergleich", "Actual/reference comparison") + + } @T("Manuelle Importe", "Manual imports") @@ -26,6 +30,9 @@ @T("Transformationen", "Transformations") + + @T("Finance Regeln", "Finance rules") + @T("Settings", "Settings") @@ -49,6 +56,8 @@ @code { + private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true); + private void LockFinanceCockpit() { FinanceAccess.Lock(); diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index 41e1a17..6645e76 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -59,6 +59,7 @@ @T("Land", "Country") + @T("Basis", "Basis") TSC @T("Schema", "Schema") @T("Server", "Server") @@ -71,6 +72,14 @@ @context.Land + + + + + @context.DataBasis + + + @context.TSC @context.Schema @context.ServerName @@ -436,6 +445,36 @@ private static string FormatException(Exception ex) => ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}"; + private static string GetDataBasisIcon(string dataBasis) + { + if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.TableView; + if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) || + dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.Description; + if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.CloudSync; + if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.Storage; + + return Icons.Material.Filled.Source; + } + + private static Color GetDataBasisColor(string dataBasis) + { + if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase)) + return Color.Success; + if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) || + dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase)) + return Color.Info; + if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase)) + return Color.Primary; + if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase)) + return Color.Secondary; + + return Color.Default; + } + } @code { diff --git a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor index ecbda7d..dd675f9 100644 --- a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor +++ b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor @@ -191,7 +191,7 @@ if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase)) return T("Bestaetigte IT-Regel: Trafag Italia ausgeschlossen; doppelte Zeilen ohne Supplier country nur einmal.", "Confirmed IT rule: Trafag Italia excluded; duplicate rows without supplier country counted once."); if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase)) - return T("Alphaplan Excel; Kundenlaender/Filter fuer offiziellen DE-Istwert noch fachlich offen.", "Alphaplan Excel; customer countries/filters for official DE actual are still open."); + return T("Alphaplan Excel; Finance-Regeln gemäss Deutschland-Rueckmeldung: Weiterberechnungen ausgeschlossen, GS negativ, GS2510095 2024.", "Alphaplan Excel; finance rules per Germany response: recharges excluded, credit notes negative, GS2510095 in 2024."); if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) || row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) || row.Key.Equals("US", StringComparison.OrdinalIgnoreCase)) diff --git a/TrafagSalesExporter/Components/Pages/FinanceRules.razor b/TrafagSalesExporter/Components/Pages/FinanceRules.razor new file mode 100644 index 0000000..5cc0c78 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/FinanceRules.razor @@ -0,0 +1,167 @@ +@page "/finance-rules" +@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] +@using System.Reflection +@using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services +@inject IFinanceRulesPageService FinanceRulesPageActions +@inject ISnackbar Snackbar +@inject IUiTextService UiText + +@T("Finance Regeln", "Finance rules") + +@T("Finance Regeln", "Finance rules") + + + + @T("Diese Regeln wirken nur auf die Finance-Sicht im zentralen Excel und im Abgleich. Rohdaten und Spaltenmapping bleiben unveraendert.", + "These rules only affect the finance view in the central Excel and reconciliation. Raw data and column mappings remain unchanged.") + + + + + @T("Regel hinzufuegen", "Add rule") + + + @T("Alle speichern", "Save all") + + + @T("Default-Regeln laden", "Load default rules") + + + + + + Aktiv + Land + Jahr + Regeltyp + Feld + Vergleich + Wert + Sort + Notiz + + + + + + + + + @foreach (var type in FinanceRuleTypes.All) + { + @GetRuleTypeLabel(type) + } + + + + + - + @foreach (var field in _recordFields) + { + @field + } + + + + + @foreach (var type in FinanceRuleMatchTypes.All) + { + @GetMatchTypeLabel(type) + } + + + + + + + + + + + + +@code { + private readonly string[] _recordFields = typeof(SalesRecord) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => property.Name) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private List _rules = []; + + protected override async Task OnInitializedAsync() + { + _rules = await FinanceRulesPageActions.LoadAsync(); + } + + private void AddRule() + { + _rules.Add(new FinanceRule + { + ScopeKey = "DE", + RuleType = FinanceRuleTypes.Exclude, + FieldName = nameof(SalesRecord.CustomerName), + MatchType = FinanceRuleMatchTypes.Contains, + SortOrder = _rules.Count == 0 ? 100 : _rules.Max(rule => rule.SortOrder) + 10, + IsActive = true + }); + } + + private void RemoveRule(FinanceRule rule) => _rules.Remove(rule); + + private void LoadDefaults() + { + _rules = FinanceRuleEngine.CreateDefaultRules() + .Select(rule => new FinanceRule + { + ScopeKey = rule.ScopeKey, + Year = rule.Year, + RuleType = rule.RuleType, + FieldName = rule.FieldName, + MatchType = rule.MatchType, + MatchValue = rule.MatchValue, + Notes = rule.Notes, + SortOrder = rule.SortOrder, + IsActive = rule.IsActive + }) + .ToList(); + } + + private async Task SaveAllAsync() + { + _rules = await FinanceRulesPageActions.SaveAllAsync(_rules); + Snackbar.Add(T("Finance-Regeln gespeichert.", "Finance rules saved."), Severity.Success); + } + + private static bool UsesNoField(FinanceRule rule) + => rule.RuleType == FinanceRuleTypes.ForceYear || + rule.MatchType == FinanceRuleMatchTypes.Always; + + private static bool UsesNoMatchValue(FinanceRule rule) + => rule.MatchType is FinanceRuleMatchTypes.Always or FinanceRuleMatchTypes.IsBlank; + + private string GetRuleTypeLabel(string type) + => type switch + { + FinanceRuleTypes.Exclude => T("Ausschliessen", "Exclude"), + FinanceRuleTypes.NegateAmount => T("Betrag negativ", "Negate amount"), + FinanceRuleTypes.ForceYear => T("Jahr erzwingen", "Force year"), + FinanceRuleTypes.DeduplicateBlankSupplierCountry => T("Duplikate ohne Supplier Country", "Deduplicate blank supplier country"), + _ => type + }; + + private string GetMatchTypeLabel(string type) + => type switch + { + FinanceRuleMatchTypes.Always => T("Immer", "Always"), + FinanceRuleMatchTypes.Equal => T("gleich", "equals"), + FinanceRuleMatchTypes.Contains => T("enthaelt", "contains"), + FinanceRuleMatchTypes.StartsWith => T("beginnt mit", "starts with"), + FinanceRuleMatchTypes.IsBlank => T("ist leer", "is blank"), + _ => type + }; + + private string T(string german, string english) => UiText.Text(german, english); +} diff --git a/TrafagSalesExporter/Components/Routes.razor b/TrafagSalesExporter/Components/Routes.razor index 3283b10..f31bb99 100644 --- a/TrafagSalesExporter/Components/Routes.razor +++ b/TrafagSalesExporter/Components/Routes.razor @@ -46,6 +46,7 @@ "finance-cockpit/vergleich" or "standorte" or "transformations" or + "finance-rules" or "settings" or "logs" or "source-viewer"; diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs index 21bd8a6..64e1f58 100644 --- a/TrafagSalesExporter/Data/AppDbContext.cs +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -18,6 +18,7 @@ public class AppDbContext : DbContext public DbSet CurrencyExchangeRates => Set(); public DbSet FinanceReferences => Set(); public DbSet FinanceIntercompanyRules => Set(); + public DbSet FinanceRules => Set(); public DbSet SapSourceDefinitions => Set(); public DbSet SapJoinDefinitions => Set(); public DbSet SapFieldMappings => Set(); diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs index f5afc2a..890cce3 100644 --- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -11,6 +11,7 @@ public class ConfigTransferPackage public List CurrencyExchangeRates { get; set; } = []; public List FinanceReferences { get; set; } = []; public List FinanceIntercompanyRules { get; set; } = []; + public List FinanceRules { get; set; } = []; public List HanaServers { get; set; } = []; public List Sites { get; set; } = []; public List FieldTransformationRules { get; set; } = []; diff --git a/TrafagSalesExporter/Models/FinanceRule.cs b/TrafagSalesExporter/Models/FinanceRule.cs new file mode 100644 index 0000000..7dc9328 --- /dev/null +++ b/TrafagSalesExporter/Models/FinanceRule.cs @@ -0,0 +1,50 @@ +namespace TrafagSalesExporter.Models; + +public class FinanceRule +{ + public int Id { get; set; } + public string ScopeKey { get; set; } = string.Empty; + public int? Year { get; set; } + public string RuleType { get; set; } = FinanceRuleTypes.Exclude; + public string FieldName { get; set; } = string.Empty; + public string MatchType { get; set; } = FinanceRuleMatchTypes.Contains; + public string MatchValue { get; set; } = string.Empty; + public decimal? NumericValue { get; set; } + public string Notes { get; set; } = string.Empty; + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; +} + +public static class FinanceRuleTypes +{ + public const string Exclude = "Exclude"; + public const string NegateAmount = "NegateAmount"; + public const string ForceYear = "ForceYear"; + public const string DeduplicateBlankSupplierCountry = "DeduplicateBlankSupplierCountry"; + + public static readonly string[] All = + [ + Exclude, + NegateAmount, + ForceYear, + DeduplicateBlankSupplierCountry + ]; +} + +public static class FinanceRuleMatchTypes +{ + public const string Always = "Always"; + public const string Equal = "Equals"; + public const string Contains = "Contains"; + public const string StartsWith = "StartsWith"; + public const string IsBlank = "IsBlank"; + + public static readonly string[] All = + [ + Always, + Equal, + Contains, + StartsWith, + IsBlank + ]; +} diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 23f929d..dfe4c3a 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -1274,3 +1274,72 @@ Naechste saubere Haertung fuer dieses Thema: - Config-Import transaktional machen - Legacy-Fallback fuer fehlendes `ConnectionKind` einbauen + +## 10. Nachtrag 2026-05-20: Finance-Regeln statt harte Laenderlogik + +Aktueller Stand: + +- Es gibt jetzt `Admin -> Finance Regeln`. +- Die fachliche Abgrenzung fuer Finance wird dort als Regel gepflegt: + - Land/Scope + - Jahr + - Regeltyp + - Feld + - Vergleich + - Wert + - Notiz + - Sortierung/Aktiv +- Diese Regeln wirken auf: + - zentrales Excel (`Finance | ...` und `Finance Summary`) + - Soll/Ist Vergleich +- Sie veraendern nicht: + - Rohdatenimport + - Mapping in `Admin -> Standorte` + - technische Transformationen in `Admin -> Transformationen` + +UI-Logik fuer Keyuser: + +```text +Admin -> Standorte = Quelle und Spaltenmapping +Admin -> Transformationen = technische Feldnormalisierung/-berechnung +Admin -> Finance Regeln = CFO-/Finance-Abgrenzung +``` + +Wichtige Default-Regeln: + +- DE: + - Alphaplan-Jahresfile -> Finance-Jahr 2025 + - Trafag AG ausschliessen + - Magnetic Sense ausschliessen + - GS2510095 ausschliessen + - GS-Gutschriften negativ +- IT: + - Trafag Italia ausschliessen + - doppelte Blank-Supplier-Country-Zeilen deduplizieren + +Nach jedem Regelwechsel testen: + +1. passenden Standort exportieren +2. zentrale Datei neu erzeugen +3. im Endexcel `Finance Summary` kontrollieren +4. `Soll/Ist Vergleich` kontrollieren + +Letzter DE-Pruefstand: + +```text +DE 2025 im zentralen Excel: 3'652'394.46 +``` + +## 11. Nachtrag 2026-05-20: Export Dashboard Datenbasis + +Im Export Dashboard steht direkt nach `Land` die Spalte `Basis`. + +Angezeigt wird: + +- `Excel-Datei` mit Tabellen-Icon +- `CSV-Datei` mit Datei-Icon +- `SAP Service` mit Cloud-Sync-Icon +- `Server` mit Storage-Icon +- `Manuelle Datei`, falls manuelle Quelle ohne erkennbaren Pfad + +Die Spalte kommt aus `DashboardPageService.ResolveDataBasis`. diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index c0b6492..b3498d9 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -106,6 +106,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index 48e9f12..91247ad 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -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(StringComparer.OrdinalIgnoreCase); foreach (var server in package.HanaServers) { diff --git a/TrafagSalesExporter/Services/DashboardPageService.cs b/TrafagSalesExporter/Services/DashboardPageService.cs index 42bb010..7f62922 100644 --- a/TrafagSalesExporter/Services/DashboardPageService.cs +++ b/TrafagSalesExporter/Services/DashboardPageService.cs @@ -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 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; diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index ed35fba..a7ff796 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -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 +);"; } diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index 8caaebb..d0c3f65 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -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(); diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index 8a6b621..35c2362 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -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; + } } diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs index 3187ae2..c4da3d0 100644 --- a/TrafagSalesExporter/Services/ExcelExportService.cs +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -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? _dbFactory; + + public ExcelExportService() + { + } + + public ExcelExportService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List 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 records, bool includeFinanceHelpSheet) + => WriteWorkbook(fullPath, records, includeFinanceHelpSheet, FinanceRuleEngine.CreateDefaultRules()); + + private void WriteWorkbookWithConfiguredRules(string fullPath, List records, bool includeFinanceHelpSheet) + => WriteWorkbook(fullPath, records, includeFinanceHelpSheet, LoadFinanceRules()); + + private IReadOnlyList 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 records, bool includeFinanceHelpSheet, IReadOnlyList 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(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 records) + private static void AddFinanceSummarySheet(XLWorkbook workbook, List records, IReadOnlyList 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(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 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> rows) { using var workbook = new XLWorkbook(); diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index dbbc4fd..da853b1 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -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 rows, IReadOnlyDictionary budgetRatesToChf, IReadOnlyList 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 ApplyCountryFinanceRules( - string referenceKey, - IEnumerable rows) - { - if (!referenceKey.Equals("IT", StringComparison.OrdinalIgnoreCase)) - return rows; - - var seenBlankSupplierCountryRows = new HashSet(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, diff --git a/TrafagSalesExporter/Services/FinanceRuleEngine.cs b/TrafagSalesExporter/Services/FinanceRuleEngine.cs new file mode 100644 index 0000000..1b38c62 --- /dev/null +++ b/TrafagSalesExporter/Services/FinanceRuleEngine.cs @@ -0,0 +1,238 @@ +using System.Reflection; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public sealed class FinanceRuleEngine +{ + private readonly IReadOnlyList _rules; + private readonly Dictionary> _deduplicationKeys = new(StringComparer.OrdinalIgnoreCase); + + private static readonly Dictionary SalesRecordProperties = typeof(SalesRecord) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .ToDictionary(x => x.Name, x => x, StringComparer.OrdinalIgnoreCase); + + public FinanceRuleEngine(IEnumerable 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 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 GetDeduplicationSet(FinanceRule rule, string countryKey) + { + var key = $"{countryKey}|{rule.Id}|{rule.SortOrder}|{rule.RuleType}"; + if (!_deduplicationKeys.TryGetValue(key, out var set)) + { + set = new HashSet(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(); +} diff --git a/TrafagSalesExporter/Services/FinanceRulesPageService.cs b/TrafagSalesExporter/Services/FinanceRulesPageService.cs new file mode 100644 index 0000000..d8cb13a --- /dev/null +++ b/TrafagSalesExporter/Services/FinanceRulesPageService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IFinanceRulesPageService +{ + Task> LoadAsync(); + Task> SaveAllAsync(List rules); +} + +public sealed class FinanceRulesPageService : IFinanceRulesPageService +{ + private readonly IDbContextFactory _dbFactory; + + public FinanceRulesPageService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task> 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> SaveAllAsync(List 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 + }; +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs index b50636a..ac37ec5 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs @@ -37,10 +37,10 @@ public class FinanceReconciliationServiceTests : IDisposable await using (var db = await _dbFactory.CreateDbContextAsync()) { db.Sites.Add(BuildSite()); - db.FinanceReferences.Add(new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, CheckValue = 100m, IsActive = true }); + db.FinanceReferences.Add(new FinanceReference { Key = "FR", Label = "Trafag FR", Year = 2025, CheckValue = 100m, IsActive = true }); db.CentralSalesRecords.AddRange( - BuildCentralRecord("TRDE", "Deutschland", 1, 1, 100m, new DateTime(2025, 1, 5), new DateTime(2024, 12, 31)), - BuildCentralRecord("TRDE", "Deutschland", 2, 1, 999m, new DateTime(2024, 12, 31), new DateTime(2025, 1, 5))); + BuildCentralRecord("TRFR", "Frankreich", 1, 1, 100m, new DateTime(2025, 1, 5), new DateTime(2024, 12, 31)), + BuildCentralRecord("TRFR", "Frankreich", 2, 1, 999m, new DateTime(2024, 12, 31), new DateTime(2025, 1, 5))); await db.SaveChangesAsync(); } diff --git a/TrafagSalesExporter/appsettings.json b/TrafagSalesExporter/appsettings.json index 0c9888b..032d57c 100644 --- a/TrafagSalesExporter/appsettings.json +++ b/TrafagSalesExporter/appsettings.json @@ -5,6 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, + "Navigation": { + "ShowFinanceComparison": true + }, "Security": { "Enabled": false, "DevelopmentBypass": false, diff --git a/TrafagSalesExporter/docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md b/TrafagSalesExporter/docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md index 4f3ddc3..c117b3d 100644 --- a/TrafagSalesExporter/docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md +++ b/TrafagSalesExporter/docs/DEPLOYMENT_IIS_HANDOFF_2026-05-19.md @@ -308,3 +308,38 @@ mainapp.*.log ``` Ausserdem sind mehrere alte Dateien als geloescht markiert. Nicht blind committen, bevor klar ist, ob sie wirklich entfernt werden sollen. + +## Nachtrag 2026-05-20 IIS /BiDashboard PathBase + +Die App ist fuer Betrieb unter `/BiDashboard` vorbereitet. + +Relevant: + +- `web.config` setzt: + +```xml + +``` + +- `Program.cs` liest `ASPNETCORE_PATHBASE` und ruft `UsePathBase(...)` auf. +- `Components/App.razor` setzt `` dynamisch: + - lokal ohne PathBase: `/` + - Server mit PathBase: `/BiDashboard/` + +Damit ist die erwartete Server-URL: + +```text +https://trch-webapp-bidashboard.trafagch.local/BiDashboard/ +``` + +Wenn die App im stdout-Log startet, aber Browser weiter `404` zeigt, zuerst IIS Application/Binding pruefen: + +- Ist `BiDashboard` eine echte IIS Application und nicht nur ein Ordner? +- Zeigt sie auf `C:\inetpub\wwwcust\BiDashboard` bzw. den aktuellen Publish-Ordner? +- Stimmen Hostname, Port 443 und Zertifikat? + +Bekannter Login-Stand: + +- Wenn ein Browser-Popup `Diese Website fordert Sie auf, sich anzumelden` erscheint, ist das IIS/Windows Authentication. +- Die App selbst hat in `appsettings.json` aktuell `Security.Enabled=false`. +- Ein Login-Popup kommt daher von IIS, nicht von den App-internen HR-/Finance-Passwortseiten. diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 7044f5e..44660ad 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1849,3 +1849,84 @@ Ergebnis: - Build erfolgreich. - 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`. + +## Finance-Regeln und Dashboard-Basis-Spalte 2026-05-20 + +Geaendert: + +- Neuer Admin-Reiter `Finance Regeln` angelegt. + - Route: `/finance-rules` + - Navigation: `Admin -> Finance Regeln` + - Zugriff wie andere Admin-Seiten ueber `AdminOnly`. +- Neue Tabelle/Model `FinanceRules`. +- Finance-Regeln werden beim Start geseedet und sind danach in der UI pflegbar. +- Die Regeln wirken auf die Finance-Sicht, nicht auf Rohdaten und nicht auf das technische Spaltenmapping. +- DE- und IT-Sonderlogik wurde aus dem zentralen Excel-Export in eine generische Regel-Engine verschoben. +- `ConfigTransferService` exportiert/importiert `FinanceRules` mit. +- `FinanceReconciliationService` nutzt dieselbe Regel-Engine wie das zentrale Excel, damit Soll/Ist-Vergleich und Endexcel dieselbe Finance-Sicht verwenden. +- Export Dashboard: + - neue Spalte `Basis` direkt nach `Land` + - zeigt Datenbasis mit Icon und Text: + - `Excel-Datei` + - `CSV-Datei` + - `SAP Service` + - `Server` + - `Manuelle Datei` + +Aktuelle Default-Finance-Regeln: + +- `DE` + - Jahr auf `2025` erzwingen fuer das Alphaplan-Jahresfile. + - `CustomerName = Trafag AG` ausschliessen. + - `CustomerName contains Magnetic Sense` ausschliessen. + - `InvoiceNumber = GS2510095` ausschliessen, weil bereits 2024 erfasst. + - `InvoiceNumber starts with GS` als negativer Betrag zaehlen. +- `IT` + - `CustomerName contains Trafag Italia` ausschliessen. + - doppelte Zeilen ohne `SupplierCountry` deduplizieren. + +DE-Fachabgleich nach Rueckmeldung Deutschland: + +```text +Gesamtumsatz NettoPreisGesamtX: 4'154'690.05 +- Weiterberechnungen Trafag AG: 391'655.88 +- Weiterberechnungen Magnetic Sense 2025: 55'648.21 +- Gutschriften GS als negativ statt positiv: 28'205.60 doppelte Wirkung +- GS2510095 nicht in 2025: 1'419.70 += DE Jahresabschluss-Umsatz: 3'652'394.46 +``` + +Verifikation: + +```text +dotnet test TrafagSalesExporter.sln --verbosity minimal +``` + +Ergebnis: + +- 76/76 Tests erfolgreich. + +Echter DE-Import und zentrale Excel erneut geprueft: + +```text +CentralSalesRecords DE 2025 rows: 4'430 +CentralSalesRecords DE 2025 SalesPriceValue: 3'652'394.46 +Central Excel Sales sheet Finance DE 2025 sum: 3'652'394.46 +Central Excel Finance Summary DE 2025 sum: 3'652'394.46 +``` + +Technische Hauptdateien: + +- `Models/FinanceRule.cs` +- `Services/FinanceRuleEngine.cs` +- `Services/FinanceRulesPageService.cs` +- `Components/Pages/FinanceRules.razor` +- `Services/ExcelExportService.cs` +- `Services/FinanceReconciliationService.cs` +- `Services/DashboardPageService.cs` +- `Components/Pages/Dashboard.razor` +- `Data/AppDbContext.cs` +- `Services/DatabaseInitializationService.SchemaSql.cs` +- `Services/DatabaseSchemaMaintenanceService.cs` +- `Services/DatabaseSeedService.cs` +- `Services/ConfigTransferService.cs`