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`