Add configurable finance rules and dashboard basis indicators

This commit is contained in:
2026-05-20 13:10:33 +02:00
parent 5e305ae396
commit d66074b740
23 changed files with 1028 additions and 204 deletions
@@ -1,6 +1,7 @@
@using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@inject IConfiguration Configuration
@inject NavigationManager Navigation
<MudNavMenu>
@@ -11,9 +12,12 @@
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
@T("Management Analyse", "Management analysis")
</MudNavLink>
@if (ShowFinanceComparison)
{
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink>
}
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
@T("Manuelle Importe", "Manual imports")
</MudNavLink>
@@ -26,6 +30,9 @@
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations")
</MudNavLink>
<MudNavLink Href="/finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
@T("Finance Regeln", "Finance rules")
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings")
</MudNavLink>
@@ -49,6 +56,8 @@
</MudNavMenu>
@code {
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private void LockFinanceCockpit()
{
FinanceAccess.Lock();
@@ -59,6 +59,7 @@
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Basis", "Basis")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
@@ -71,6 +72,14 @@
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>
<MudTooltip Text="@context.DataBasis">
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
</MudStack>
</MudTooltip>
</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
@@ -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 {
@@ -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))
@@ -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
<PageTitle>@T("Finance Regeln", "Finance rules")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Regeln", "Finance rules")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
@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.")
</MudAlert>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
@T("Regel hinzufuegen", "Add rule")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
@T("Alle speichern", "Save all")
</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Restore" OnClick="LoadDefaults">
@T("Default-Regeln laden", "Load default rules")
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>Land</MudTh>
<MudTh>Jahr</MudTh>
<MudTh>Regeltyp</MudTh>
<MudTh>Feld</MudTh>
<MudTh>Vergleich</MudTh>
<MudTh>Wert</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Notiz</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd><MudTextField @bind-Value="context.ScopeKey" Placeholder="DE" Style="width:80px" /></MudTd>
<MudTd><MudNumericField T="int?" @bind-Value="context.Year" Style="width:90px" /></MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.RuleType" Dense>
@foreach (var type in FinanceRuleTypes.All)
{
<MudSelectItem Value="@type">@GetRuleTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.FieldName" Dense Disabled="@UsesNoField(context)">
<MudSelectItem Value="@string.Empty">-</MudSelectItem>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.MatchType" Dense>
@foreach (var type in FinanceRuleMatchTypes.All)
{
<MudSelectItem Value="@type">@GetMatchTypeLabel(type)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.MatchValue" Disabled="@UsesNoMatchValue(context)" /></MudTd>
<MudTd><MudNumericField T="int" @bind-Value="context.SortOrder" Style="width:80px" /></MudTd>
<MudTd><MudTextField @bind-Value="context.Notes" /></MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(property => property.Name)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private List<FinanceRule> _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);
}
@@ -46,6 +46,7 @@
"finance-cockpit/vergleich" or
"standorte" or
"transformations" or
"finance-rules" or
"settings" or
"logs" or
"source-viewer";
+1
View File
@@ -18,6 +18,7 @@ public class AppDbContext : DbContext
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
public DbSet<FinanceRule> FinanceRules => Set<FinanceRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
@@ -11,6 +11,7 @@ public class ConfigTransferPackage
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
public List<FinanceRule> FinanceRules { get; set; } = [];
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
+50
View File
@@ -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
];
}
@@ -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`.
+1
View File
@@ -106,6 +106,7 @@ builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageS
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
@@ -35,6 +35,10 @@ public class ConfigTransferService : IConfigTransferService
.ThenBy(x => x.CustomerNumber)
.ThenBy(x => x.CustomerNameContains)
.ToListAsync();
var financeRules = await db.FinanceRules
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.ToListAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
@@ -106,6 +110,19 @@ public class ConfigTransferService : IConfigTransferService
Notes = rule.Notes,
IsActive = rule.IsActive
}).ToList(),
FinanceRules = financeRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{
Key = serverKeyMap[server.Id],
@@ -206,6 +223,7 @@ public class ConfigTransferService : IConfigTransferService
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
var existingFinanceRules = await db.FinanceRules.ToListAsync();
var existingSites = await db.Sites.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync();
@@ -235,6 +253,8 @@ public class ConfigTransferService : IConfigTransferService
db.FinanceReferences.RemoveRange(existingFinanceReferences);
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
if (package.FinanceRules.Count > 0 && existingFinanceRules.Count > 0)
db.FinanceRules.RemoveRange(existingFinanceRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
@@ -321,6 +341,23 @@ public class ConfigTransferService : IConfigTransferService
}));
}
if (package.FinanceRules.Count > 0)
{
db.FinanceRules.AddRange(package.FinanceRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
@@ -46,6 +46,7 @@ public sealed class DashboardPageService : IDashboardPageService
{
SiteId = s.Id,
Land = s.Land,
DataBasis = ResolveDataBasis(s, sourceSystem),
TSC = s.TSC,
Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
@@ -110,6 +111,32 @@ public sealed class DashboardPageService : IDashboardPageService
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
}
private static string ResolveDataBasis(Site site, SourceSystemDefinition? sourceSystem)
{
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
{
var path = site.ManualImportFilePath ?? string.Empty;
var extension = Path.GetExtension(path).TrimStart('.').ToUpperInvariant();
if (extension is "CSV")
return "CSV-Datei";
if (extension is "XLS" or "XLSX" or "XLSM")
return "Excel-Datei";
if (!string.IsNullOrWhiteSpace(path))
return "Excel/CSV-Datei";
return "Manuelle Datei";
}
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return "SAP Service";
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
return "Server";
return string.IsNullOrWhiteSpace(site.SourceSystem) ? "-" : site.SourceSystem;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
@@ -156,6 +183,7 @@ public sealed class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string DataBasis { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Schema { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
@@ -192,4 +192,19 @@ CREATE TABLE FinanceIntercompanyRules (
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
internal static string GetFinanceRulesCreateSql() => @"
CREATE TABLE FinanceRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ScopeKey TEXT NOT NULL DEFAULT '',
Year INTEGER NULL,
RuleType TEXT NOT NULL DEFAULT 'Exclude',
FieldName TEXT NOT NULL DEFAULT '',
MatchType TEXT NOT NULL DEFAULT 'Contains',
MatchValue TEXT NOT NULL DEFAULT '',
NumericValue TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
}
@@ -36,6 +36,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureCurrencyExchangeRateTable(db);
EnsureFinanceReferenceTable(db);
EnsureFinanceIntercompanyRuleTable(db);
EnsureFinanceRuleTable(db);
EnsureSourceSystemDefinitionTable(db);
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
EnsureSapSourceTable(db);
@@ -317,6 +318,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
cmd.ExecuteNonQuery();
}
private static void EnsureFinanceRuleTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetFinanceRulesCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
@@ -19,6 +19,7 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(db);
EnsureFinanceRuleDefaults(db);
}
private static void SeedIfEmpty(AppDbContext db)
@@ -893,4 +894,39 @@ public class DatabaseSeedService : IDatabaseSeedService
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceRuleDefaults(AppDbContext db)
{
if (!CanUseTable(db, "FinanceRules"))
return;
var changed = false;
foreach (var item in FinanceRuleEngine.CreateDefaultRules())
{
var exists = db.FinanceRules.Any(rule =>
rule.ScopeKey == item.ScopeKey &&
rule.RuleType == item.RuleType &&
rule.FieldName == item.FieldName &&
rule.MatchType == item.MatchType &&
rule.MatchValue == item.MatchValue);
if (exists)
continue;
db.FinanceRules.Add(item);
changed = true;
}
if (changed)
db.SaveChanges();
}
private static bool CanUseTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
return DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName).Count > 0;
}
}
@@ -1,18 +1,29 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService : IExcelExportService
{
private const int GermanyAlphaplanFinanceYear = 2025;
private readonly IDbContextFactory<AppDbContext>? _dbFactory;
public ExcelExportService()
{
}
public ExcelExportService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: false);
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: false);
return fullPath;
}
@@ -21,7 +32,7 @@ public class ExcelExportService : IExcelExportService
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: true);
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: true);
return fullPath;
}
@@ -36,9 +47,32 @@ public class ExcelExportService : IExcelExportService
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, FinanceRuleEngine.CreateDefaultRules());
private void WriteWorkbookWithConfiguredRules(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
=> WriteWorkbook(fullPath, records, includeFinanceHelpSheet, LoadFinanceRules());
private IReadOnlyList<FinanceRule> LoadFinanceRules()
{
if (_dbFactory is null)
return FinanceRuleEngine.CreateDefaultRules();
using var db = _dbFactory.CreateDbContext();
var rules = db.FinanceRules
.AsNoTracking()
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
return rules.Count == 0 ? FinanceRuleEngine.CreateDefaultRules() : rules;
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet, IReadOnlyList<FinanceRule> financeRules)
{
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var headers = new[]
{
@@ -93,7 +127,6 @@ public class ExcelExportService : IExcelExportService
}
var row = 2;
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
@@ -131,10 +164,10 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
var financeDate = ResolveFinanceDate(record);
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeInclude = ResolveFinanceInclude(record, financeCountryKey, italyBlankSupplierCountryRows);
var financeNetSalesActual = ResolveFinanceNetSalesActual(record, financeCountryKey, financeInclude);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, financeCountryKey);
var financeInclude = financeRuleEngine.ShouldInclude(record, financeCountryKey);
var financeNetSalesActual = financeRuleEngine.ResolveNetSalesActual(record, financeCountryKey, financeInclude);
ws.Cell(row, 36).Value = financeDate.Year;
ws.Cell(row, 37).Value = financeCountryKey;
ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy");
@@ -143,23 +176,24 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 41).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
ws.Cell(row, 42).Value = financeInclude
? "Sales Price/Value"
: ResolveFinanceExclusionReason(record, financeCountryKey);
: financeRuleEngine.ResolveExclusionReason(record, financeCountryKey);
row++;
}
ws.Columns().AdjustToContents();
if (includeFinanceHelpSheet)
{
AddFinanceSummarySheet(workbook, records);
AddFinanceSummarySheet(workbook, records, financeRules);
AddFinanceHelpSheet(workbook);
}
workbook.SaveAs(fullPath);
}
private static void AddFinanceSummarySheet(XLWorkbook workbook, List<SalesRecord> records)
private static void AddFinanceSummarySheet(XLWorkbook workbook, List<SalesRecord> records, IReadOnlyList<FinanceRule> financeRules)
{
var ws = workbook.Worksheets.Add("Finance Summary");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
ws.Position = 1;
ws.Cell(1, 1).Value = "Finance Summary";
ws.Cell(1, 1).Style.Font.Bold = true;
@@ -183,14 +217,13 @@ public class ExcelExportService : IExcelExportService
ws.Cell(4, i + 1).Style.Font.Bold = true;
}
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summaryRows = records
.Select(record =>
{
var financeDate = ResolveFinanceDate(record);
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var rawInclude = ResolveFinanceInclude(record, countryKey, italyBlankSupplierCountryRows);
var value = ResolveFinanceNetSalesActual(record, countryKey, rawInclude);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, countryKey);
var rawInclude = financeRuleEngine.ShouldInclude(record, countryKey);
var value = financeRuleEngine.ResolveNetSalesActual(record, countryKey, rawInclude);
var include = rawInclude && value != 0m;
return new
{
@@ -298,15 +331,6 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
}
private static DateTime ResolveFinanceDate(SalesRecord record)
{
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
if (countryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return new DateTime(GermanyAlphaplanFinanceYear, 12, 31);
return record.PostingDate ?? record.InvoiceDate ?? record.ExtractionDate;
}
private static string ResolveFinanceCurrency(SalesRecord record)
=> ResolveFinanceCountryKey(record.Land, record.Tsc) switch
{
@@ -340,108 +364,6 @@ public class ExcelExportService : IExcelExportService
return normalizedTsc.Replace("TR", string.Empty);
}
private static bool ResolveFinanceInclude(SalesRecord record, string financeCountryKey, HashSet<string> italyBlankSupplierCountryRows)
{
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return IsIncludedGermanyFinanceRow(record);
if (!financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase))
return true;
if (IsExcludedItalyCustomer(record))
return false;
if (!string.IsNullOrWhiteSpace(record.SupplierCountry))
return true;
return italyBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(record));
}
private static string ResolveFinanceExclusionReason(SalesRecord record, string financeCountryKey)
{
if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && IsExcludedItalyCustomer(record))
return "Excluded IT customer: Trafag Italia";
if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(record.SupplierCountry))
return "Excluded IT duplicate without Supplier country";
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase))
return ResolveGermanyExclusionReason(record);
return "Excluded";
}
private static decimal ResolveFinanceNetSalesActual(SalesRecord record, string financeCountryKey, bool financeInclude)
{
if (!financeInclude)
return 0m;
if (financeCountryKey.Equals("DE", StringComparison.OrdinalIgnoreCase) && IsGermanyCreditNote(record))
return -Math.Abs(record.SalesPriceValue);
return record.SalesPriceValue;
}
private static bool IsIncludedGermanyFinanceRow(SalesRecord record)
=> !IsGermanyTrafagAgRecharge(record) &&
!IsGermanyMagneticSenseRecharge(record) &&
!IsGermanyCreditNoteAlreadyCapturedInPriorYear(record);
private static string ResolveGermanyExclusionReason(SalesRecord record)
{
if (IsGermanyTrafagAgRecharge(record))
return "Excluded DE Weiterberechnung Trafag AG";
if (IsGermanyMagneticSenseRecharge(record))
return "Excluded DE Weiterberechnung Magnetic Sense";
if (IsGermanyCreditNoteAlreadyCapturedInPriorYear(record))
return "Excluded DE GS2510095 already captured in 2024";
return "Excluded DE";
}
private static bool IsGermanyTrafagAgRecharge(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName) == "TRAFAG AG";
private static bool IsGermanyMagneticSenseRecharge(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName).Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase);
private static bool IsGermanyCreditNote(SalesRecord record)
=> (record.InvoiceNumber ?? string.Empty).Trim().StartsWith("GS", StringComparison.OrdinalIgnoreCase);
private static bool IsGermanyCreditNoteAlreadyCapturedInPriorYear(SalesRecord record)
=> (record.InvoiceNumber ?? string.Empty).Trim().Equals("GS2510095", StringComparison.OrdinalIgnoreCase);
private static bool IsExcludedItalyCustomer(SalesRecord record)
=> NormalizeFinanceText(record.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase);
private static string BuildItalyBlankSupplierCountryDeduplicationKey(SalesRecord record)
=> string.Join("|",
record.Tsc,
record.DocumentType,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.CustomerNumber,
record.CustomerName,
record.SalesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency,
record.PostingDate?.ToString("O") ?? string.Empty,
record.InvoiceDate?.ToString("O") ?? string.Empty);
private static string NormalizeFinanceText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
using var workbook = new XLWorkbook();
@@ -31,35 +31,51 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
.AsNoTracking()
.Where(r => r.IsActive)
.ToListAsync();
var centralRows = await db.CentralSalesRecords
var financeRules = await db.FinanceRules
.AsNoTracking()
.Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new NetSalesActualSourceRow(
r.Land,
r.Tsc,
r.DocumentEntry,
r.InvoiceNumber,
r.PositionOnInvoice,
r.Material,
r.Name,
r.Quantity,
r.DocumentType,
r.PostingDate,
r.InvoiceDate,
r.ExtractionDate,
r.CustomerNumber,
r.CustomerName,
r.SupplierCountry,
r.SalesCurrency,
r.DocumentCurrency,
r.CompanyCurrency,
r.SalesPriceValue,
r.DocumentTotalForeignCurrency,
r.DocumentTotalLocalCurrency,
r.VatSumForeignCurrency,
r.VatSumLocalCurrency))
.Where(r => r.IsActive)
.OrderBy(r => r.SortOrder)
.ThenBy(r => r.Id)
.ToListAsync();
if (financeRules.Count == 0)
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var centralRecords = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
var centralRows = centralRecords
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
.Where(row => row is not null)
.Select(row => row!)
.ToList();
var groupedActuals = centralRows
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
@@ -149,13 +165,50 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return result;
}
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
{
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
if (financeRuleEngine.ResolveFinanceDate(record, referenceKey).Year != year)
return null;
var include = financeRuleEngine.ShouldInclude(record, referenceKey);
if (!include)
return null;
var salesPriceValue = financeRuleEngine.ResolveNetSalesActual(record, referenceKey, include);
return new NetSalesActualSourceRow(
record.Land,
record.Tsc,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.DocumentType,
record.PostingDate,
record.InvoiceDate,
record.ExtractionDate,
record.CustomerNumber,
record.CustomerName,
record.SupplierCountry,
record.SalesCurrency,
record.DocumentCurrency,
record.CompanyCurrency,
salesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency);
}
private static NetSalesActual BuildNetSalesActual(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
{
var rowList = ApplyCountryFinanceRules(referenceKey, rows).ToList();
var rowList = rows.ToList();
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
var documentRows = rowList
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
@@ -244,50 +297,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
}
private static IEnumerable<NetSalesActualSourceRow> ApplyCountryFinanceRules(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows)
{
if (!referenceKey.Equals("IT", StringComparison.OrdinalIgnoreCase))
return rows;
var seenBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return rows.Where(row =>
{
if (IsExcludedItalyCustomer(row))
return false;
if (!string.IsNullOrWhiteSpace(row.SupplierCountry))
return true;
return seenBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(row));
});
}
private static bool IsExcludedItalyCustomer(NetSalesActualSourceRow row)
=> ResolveReferenceKey(row.Land, row.Tsc).Equals("IT", StringComparison.OrdinalIgnoreCase) &&
NormalizeRuleText(row.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase);
private static string BuildItalyBlankSupplierCountryDeduplicationKey(NetSalesActualSourceRow row)
=> string.Join("|",
row.Tsc,
row.DocumentType,
row.DocumentEntry,
row.InvoiceNumber,
row.PositionOnInvoice,
row.Material,
row.Name,
row.Quantity,
row.CustomerNumber,
row.CustomerName,
row.SalesPriceValue,
row.DocumentTotalForeignCurrency,
row.DocumentTotalLocalCurrency,
row.VatSumForeignCurrency,
row.VatSumLocalCurrency,
row.PostingDate?.ToString("O") ?? string.Empty,
row.InvoiceDate?.ToString("O") ?? string.Empty);
private static decimal ConvertHouseCurrencyNetToBudgetChf(
string houseCurrency,
NetSalesActualSourceRow row,
@@ -0,0 +1,238 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class FinanceRuleEngine
{
private readonly IReadOnlyList<FinanceRule> _rules;
private readonly Dictionary<string, HashSet<string>> _deduplicationKeys = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, PropertyInfo> SalesRecordProperties = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(x => x.Name, x => x, StringComparer.OrdinalIgnoreCase);
public FinanceRuleEngine(IEnumerable<FinanceRule> rules)
{
_rules = rules
.Where(rule => rule.IsActive)
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToList();
}
public DateTime ResolveFinanceDate(SalesRecord record, string countryKey)
{
var forceYear = _rules.FirstOrDefault(rule =>
IsRuleInScope(rule, countryKey) &&
rule.RuleType.Equals(FinanceRuleTypes.ForceYear, StringComparison.OrdinalIgnoreCase) &&
RuleMatches(rule, record));
if (forceYear?.Year is > 0)
return new DateTime(forceYear.Year.Value, 12, 31);
return record.PostingDate ?? record.InvoiceDate ?? record.ExtractionDate;
}
public bool ShouldInclude(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return false;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
{
var seen = GetDeduplicationSet(rule, countryKey);
return seen.Add(BuildBlankSupplierCountryDeduplicationKey(record));
}
}
return true;
}
public decimal ResolveNetSalesActual(SalesRecord record, string countryKey, bool include)
{
if (!include)
return 0m;
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!rule.RuleType.Equals(FinanceRuleTypes.NegateAmount, StringComparison.OrdinalIgnoreCase) ||
!RuleMatches(rule, record))
continue;
return -Math.Abs(record.SalesPriceValue);
}
return record.SalesPriceValue;
}
public string ResolveExclusionReason(SalesRecord record, string countryKey)
{
foreach (var rule in _rules.Where(rule => IsRuleInScope(rule, countryKey)))
{
if (!RuleMatches(rule, record))
continue;
if (rule.RuleType.Equals(FinanceRuleTypes.Exclude, StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey}" : rule.Notes;
if (rule.RuleType.Equals(FinanceRuleTypes.DeduplicateBlankSupplierCountry, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(record.SupplierCountry))
return string.IsNullOrWhiteSpace(rule.Notes) ? $"Excluded {countryKey} duplicate without Supplier country" : rule.Notes;
}
return $"Excluded {countryKey}";
}
public static IReadOnlyList<FinanceRule> CreateDefaultRules()
=>
[
new FinanceRule
{
ScopeKey = "DE",
Year = 2025,
RuleType = FinanceRuleTypes.ForceYear,
MatchType = FinanceRuleMatchTypes.Always,
Notes = "DE Alphaplan Jahresfile 2025",
SortOrder = 100
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "Trafag AG",
Notes = "Excluded DE Weiterberechnung Trafag AG",
SortOrder = 110
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Magnetic Sense",
Notes = "Excluded DE Weiterberechnung Magnetic Sense",
SortOrder = 120
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.Equal,
MatchValue = "GS2510095",
Notes = "Excluded DE GS2510095 already captured in 2024",
SortOrder = 130
},
new FinanceRule
{
ScopeKey = "DE",
RuleType = FinanceRuleTypes.NegateAmount,
FieldName = nameof(SalesRecord.InvoiceNumber),
MatchType = FinanceRuleMatchTypes.StartsWith,
MatchValue = "GS",
Notes = "DE Gutschriften negativ",
SortOrder = 140
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.Exclude,
FieldName = nameof(SalesRecord.CustomerName),
MatchType = FinanceRuleMatchTypes.Contains,
MatchValue = "Trafag Italia",
Notes = "Excluded IT customer: Trafag Italia",
SortOrder = 200
},
new FinanceRule
{
ScopeKey = "IT",
RuleType = FinanceRuleTypes.DeduplicateBlankSupplierCountry,
FieldName = nameof(SalesRecord.SupplierCountry),
MatchType = FinanceRuleMatchTypes.IsBlank,
Notes = "Excluded IT duplicate without Supplier country",
SortOrder = 210
}
];
private HashSet<string> GetDeduplicationSet(FinanceRule rule, string countryKey)
{
var key = $"{countryKey}|{rule.Id}|{rule.SortOrder}|{rule.RuleType}";
if (!_deduplicationKeys.TryGetValue(key, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_deduplicationKeys[key] = set;
}
return set;
}
private static bool IsRuleInScope(FinanceRule rule, string countryKey)
=> string.IsNullOrWhiteSpace(rule.ScopeKey) ||
rule.ScopeKey.Equals(countryKey, StringComparison.OrdinalIgnoreCase);
private static bool RuleMatches(FinanceRule rule, SalesRecord record)
{
if (rule.MatchType.Equals(FinanceRuleMatchTypes.Always, StringComparison.OrdinalIgnoreCase))
return true;
var value = ReadRecordValue(record, rule.FieldName);
var normalizedValue = NormalizeFinanceText(value);
var normalizedMatch = NormalizeFinanceText(rule.MatchValue);
return rule.MatchType switch
{
FinanceRuleMatchTypes.Equal => normalizedValue.Equals(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.Contains => normalizedValue.Contains(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.StartsWith => normalizedValue.StartsWith(normalizedMatch, StringComparison.OrdinalIgnoreCase),
FinanceRuleMatchTypes.IsBlank => string.IsNullOrWhiteSpace(value),
_ => false
};
}
private static string ReadRecordValue(SalesRecord record, string fieldName)
{
if (string.IsNullOrWhiteSpace(fieldName))
return string.Empty;
return SalesRecordProperties.TryGetValue(fieldName, out var property)
? property.GetValue(record)?.ToString() ?? string.Empty
: string.Empty;
}
private static string BuildBlankSupplierCountryDeduplicationKey(SalesRecord record)
=> string.Join("|",
record.Tsc,
record.DocumentType,
record.DocumentEntry,
record.InvoiceNumber,
record.PositionOnInvoice,
record.Material,
record.Name,
record.Quantity,
record.CustomerNumber,
record.CustomerName,
record.SalesPriceValue,
record.DocumentTotalForeignCurrency,
record.DocumentTotalLocalCurrency,
record.VatSumForeignCurrency,
record.VatSumLocalCurrency,
record.PostingDate?.ToString("O") ?? string.Empty,
record.InvoiceDate?.ToString("O") ?? string.Empty);
private static string NormalizeFinanceText(string value)
=> (value ?? string.Empty)
.Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToUpperInvariant();
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IFinanceRulesPageService
{
Task<List<FinanceRule>> LoadAsync();
Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules);
}
public sealed class FinanceRulesPageService : IFinanceRulesPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public FinanceRulesPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<FinanceRule>> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var rules = await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
if (rules.Count > 0)
return rules;
return FinanceRuleEngine.CreateDefaultRules()
.Select(CloneRule)
.ToList();
}
public async Task<List<FinanceRule>> SaveAllAsync(List<FinanceRule> rules)
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.FinanceRules.RemoveRange(db.FinanceRules);
db.FinanceRules.AddRange(rules
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.Select(CloneRule));
await db.SaveChangesAsync();
return await db.FinanceRules
.AsNoTracking()
.OrderBy(rule => rule.SortOrder)
.ThenBy(rule => rule.Id)
.ToListAsync();
}
private static FinanceRule CloneRule(FinanceRule rule)
=> new()
{
ScopeKey = rule.ScopeKey.Trim().ToUpperInvariant(),
Year = rule.Year,
RuleType = string.IsNullOrWhiteSpace(rule.RuleType) ? FinanceRuleTypes.Exclude : rule.RuleType,
FieldName = rule.FieldName ?? string.Empty,
MatchType = string.IsNullOrWhiteSpace(rule.MatchType) ? FinanceRuleMatchTypes.Contains : rule.MatchType,
MatchValue = rule.MatchValue ?? string.Empty,
NumericValue = rule.NumericValue,
Notes = rule.Notes ?? string.Empty,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
};
}
@@ -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();
}
+3
View File
@@ -5,6 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"Navigation": {
"ShowFinanceComparison": true
},
"Security": {
"Enabled": false,
"DevelopmentBypass": false,
@@ -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
<environmentVariable name="ASPNETCORE_PATHBASE" value="/BiDashboard" />
```
- `Program.cs` liest `ASPNETCORE_PATHBASE` und ruft `UsePathBase(...)` auf.
- `Components/App.razor` setzt `<base href>` 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.
+81
View File
@@ -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`