Add configurable finance rules and dashboard basis indicators
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
@using TrafagSalesExporter.Security
|
@using TrafagSalesExporter.Security
|
||||||
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
|
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
|
||||||
|
@inject IConfiguration Configuration
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
@@ -11,9 +12,12 @@
|
|||||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
||||||
@T("Management Analyse", "Management analysis")
|
@T("Management Analyse", "Management analysis")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
|
@if (ShowFinanceComparison)
|
||||||
@T("Soll/Ist Vergleich", "Actual/reference comparison")
|
{
|
||||||
</MudNavLink>
|
<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">
|
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
|
||||||
@T("Manuelle Importe", "Manual imports")
|
@T("Manuelle Importe", "Manual imports")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
@@ -26,6 +30,9 @@
|
|||||||
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
||||||
@T("Transformationen", "Transformations")
|
@T("Transformationen", "Transformations")
|
||||||
</MudNavLink>
|
</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">
|
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||||
@T("Settings", "Settings")
|
@T("Settings", "Settings")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
@@ -49,6 +56,8 @@
|
|||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
|
||||||
|
|
||||||
private void LockFinanceCockpit()
|
private void LockFinanceCockpit()
|
||||||
{
|
{
|
||||||
FinanceAccess.Lock();
|
FinanceAccess.Lock();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>@T("Land", "Country")</MudTh>
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Basis", "Basis")</MudTh>
|
||||||
<MudTh>TSC</MudTh>
|
<MudTh>TSC</MudTh>
|
||||||
<MudTh>@T("Schema", "Schema")</MudTh>
|
<MudTh>@T("Schema", "Schema")</MudTh>
|
||||||
<MudTh>@T("Server", "Server")</MudTh>
|
<MudTh>@T("Server", "Server")</MudTh>
|
||||||
@@ -71,6 +72,14 @@
|
|||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd>@context.Land</MudTd>
|
<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.TSC</MudTd>
|
||||||
<MudTd>@context.Schema</MudTd>
|
<MudTd>@context.Schema</MudTd>
|
||||||
<MudTd>@context.ServerName</MudTd>
|
<MudTd>@context.ServerName</MudTd>
|
||||||
@@ -436,6 +445,36 @@
|
|||||||
private static string FormatException(Exception ex)
|
private static string FormatException(Exception ex)
|
||||||
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
|
=> 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 {
|
@code {
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase))
|
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.");
|
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))
|
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) ||
|
if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) ||
|
||||||
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
|
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
|
||||||
row.Key.Equals("US", 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
|
"finance-cockpit/vergleich" or
|
||||||
"standorte" or
|
"standorte" or
|
||||||
"transformations" or
|
"transformations" or
|
||||||
|
"finance-rules" or
|
||||||
"settings" or
|
"settings" or
|
||||||
"logs" or
|
"logs" or
|
||||||
"source-viewer";
|
"source-viewer";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
|
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
|
||||||
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
|
public DbSet<FinanceReference> FinanceReferences => Set<FinanceReference>();
|
||||||
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
|
public DbSet<FinanceIntercompanyRule> FinanceIntercompanyRules => Set<FinanceIntercompanyRule>();
|
||||||
|
public DbSet<FinanceRule> FinanceRules => Set<FinanceRule>();
|
||||||
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||||
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||||
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public class ConfigTransferPackage
|
|||||||
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
|
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
|
||||||
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
|
public List<ConfigTransferFinanceReference> FinanceReferences { get; set; } = [];
|
||||||
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
|
public List<ConfigTransferFinanceIntercompanyRule> FinanceIntercompanyRules { get; set; } = [];
|
||||||
|
public List<FinanceRule> FinanceRules { get; set; } = [];
|
||||||
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
|
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
|
||||||
public List<ConfigTransferSite> Sites { get; set; } = [];
|
public List<ConfigTransferSite> Sites { get; set; } = [];
|
||||||
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
||||||
|
|||||||
@@ -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
|
- Config-Import transaktional machen
|
||||||
- Legacy-Fallback fuer fehlendes `ConnectionKind` einbauen
|
- 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`.
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageS
|
|||||||
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
|
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
|
||||||
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
|
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
|
||||||
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
|
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
|
||||||
|
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
||||||
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
||||||
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
.ThenBy(x => x.CustomerNumber)
|
.ThenBy(x => x.CustomerNumber)
|
||||||
.ThenBy(x => x.CustomerNameContains)
|
.ThenBy(x => x.CustomerNameContains)
|
||||||
.ToListAsync();
|
.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 hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
|
||||||
var sites = await db.Sites.OrderBy(x => x.Land).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();
|
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,
|
Notes = rule.Notes,
|
||||||
IsActive = rule.IsActive
|
IsActive = rule.IsActive
|
||||||
}).ToList(),
|
}).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
|
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
|
||||||
{
|
{
|
||||||
Key = serverKeyMap[server.Id],
|
Key = serverKeyMap[server.Id],
|
||||||
@@ -206,6 +223,7 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
|
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
|
||||||
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
|
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
|
||||||
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
|
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
|
||||||
|
var existingFinanceRules = await db.FinanceRules.ToListAsync();
|
||||||
var existingSites = await db.Sites.ToListAsync();
|
var existingSites = await db.Sites.ToListAsync();
|
||||||
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
||||||
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
||||||
@@ -235,6 +253,8 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
db.FinanceReferences.RemoveRange(existingFinanceReferences);
|
db.FinanceReferences.RemoveRange(existingFinanceReferences);
|
||||||
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
|
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
|
||||||
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
|
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 (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
|
||||||
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
||||||
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
|
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);
|
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var server in package.HanaServers)
|
foreach (var server in package.HanaServers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public sealed class DashboardPageService : IDashboardPageService
|
|||||||
{
|
{
|
||||||
SiteId = s.Id,
|
SiteId = s.Id,
|
||||||
Land = s.Land,
|
Land = s.Land,
|
||||||
|
DataBasis = ResolveDataBasis(s, sourceSystem),
|
||||||
TSC = s.TSC,
|
TSC = s.TSC,
|
||||||
Schema = s.Schema,
|
Schema = s.Schema,
|
||||||
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
|
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;
|
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)
|
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
|
||||||
{
|
{
|
||||||
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
|
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
|
||||||
@@ -156,6 +183,7 @@ public sealed class DashboardRow
|
|||||||
{
|
{
|
||||||
public int SiteId { get; set; }
|
public int SiteId { get; set; }
|
||||||
public string Land { get; set; } = string.Empty;
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string DataBasis { get; set; } = string.Empty;
|
||||||
public string TSC { get; set; } = string.Empty;
|
public string TSC { get; set; } = string.Empty;
|
||||||
public string Schema { get; set; } = string.Empty;
|
public string Schema { get; set; } = string.Empty;
|
||||||
public string ServerName { get; set; } = string.Empty;
|
public string ServerName { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -192,4 +192,19 @@ CREATE TABLE FinanceIntercompanyRules (
|
|||||||
Notes TEXT NOT NULL DEFAULT '',
|
Notes TEXT NOT NULL DEFAULT '',
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1
|
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);
|
EnsureCurrencyExchangeRateTable(db);
|
||||||
EnsureFinanceReferenceTable(db);
|
EnsureFinanceReferenceTable(db);
|
||||||
EnsureFinanceIntercompanyRuleTable(db);
|
EnsureFinanceIntercompanyRuleTable(db);
|
||||||
|
EnsureFinanceRuleTable(db);
|
||||||
EnsureSourceSystemDefinitionTable(db);
|
EnsureSourceSystemDefinitionTable(db);
|
||||||
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||||
EnsureSapSourceTable(db);
|
EnsureSapSourceTable(db);
|
||||||
@@ -317,6 +318,17 @@ CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
|
|||||||
cmd.ExecuteNonQuery();
|
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)
|
private static void EnsureSapJoinTable(AppDbContext db)
|
||||||
{
|
{
|
||||||
var conn = db.Database.GetDbConnection();
|
var conn = db.Database.GetDbConnection();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class DatabaseSeedService : IDatabaseSeedService
|
|||||||
EnsureFinanceReferenceDefaults(db);
|
EnsureFinanceReferenceDefaults(db);
|
||||||
EnsureBudgetExchangeRateDefaults(db);
|
EnsureBudgetExchangeRateDefaults(db);
|
||||||
EnsureFinanceIntercompanyRuleDefaults(db);
|
EnsureFinanceIntercompanyRuleDefaults(db);
|
||||||
|
EnsureFinanceRuleDefaults(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SeedIfEmpty(AppDbContext db)
|
private static void SeedIfEmpty(AppDbContext db)
|
||||||
@@ -893,4 +894,39 @@ public class DatabaseSeedService : IDatabaseSeedService
|
|||||||
if (changed)
|
if (changed)
|
||||||
db.SaveChanges();
|
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 ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
namespace TrafagSalesExporter.Services;
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
public class ExcelExportService : IExcelExportService
|
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)
|
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outputDirectory);
|
Directory.CreateDirectory(outputDirectory);
|
||||||
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
|
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
|
||||||
var fullPath = Path.Combine(outputDirectory, fileName);
|
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||||
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: false);
|
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: false);
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +32,7 @@ public class ExcelExportService : IExcelExportService
|
|||||||
Directory.CreateDirectory(outputDirectory);
|
Directory.CreateDirectory(outputDirectory);
|
||||||
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
|
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
|
||||||
var fullPath = Path.Combine(outputDirectory, fileName);
|
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||||
WriteWorkbook(fullPath, records, includeFinanceHelpSheet: true);
|
WriteWorkbookWithConfiguredRules(fullPath, records, includeFinanceHelpSheet: true);
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +47,32 @@ public class ExcelExportService : IExcelExportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteWorkbook(string fullPath, List<SalesRecord> records, bool includeFinanceHelpSheet)
|
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();
|
using var workbook = new XLWorkbook();
|
||||||
var ws = workbook.Worksheets.Add("Sales");
|
var ws = workbook.Worksheets.Add("Sales");
|
||||||
|
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||||
|
|
||||||
var headers = new[]
|
var headers = new[]
|
||||||
{
|
{
|
||||||
@@ -93,7 +127,6 @@ public class ExcelExportService : IExcelExportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var row = 2;
|
var row = 2;
|
||||||
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
|
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, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||||
ws.Cell(row, 34).Value = record.Land;
|
ws.Cell(row, 34).Value = record.Land;
|
||||||
ws.Cell(row, 35).Value = record.DocumentType;
|
ws.Cell(row, 35).Value = record.DocumentType;
|
||||||
var financeDate = ResolveFinanceDate(record);
|
|
||||||
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
|
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
|
||||||
var financeInclude = ResolveFinanceInclude(record, financeCountryKey, italyBlankSupplierCountryRows);
|
var financeDate = financeRuleEngine.ResolveFinanceDate(record, financeCountryKey);
|
||||||
var financeNetSalesActual = ResolveFinanceNetSalesActual(record, financeCountryKey, financeInclude);
|
var financeInclude = financeRuleEngine.ShouldInclude(record, financeCountryKey);
|
||||||
|
var financeNetSalesActual = financeRuleEngine.ResolveNetSalesActual(record, financeCountryKey, financeInclude);
|
||||||
ws.Cell(row, 36).Value = financeDate.Year;
|
ws.Cell(row, 36).Value = financeDate.Year;
|
||||||
ws.Cell(row, 37).Value = financeCountryKey;
|
ws.Cell(row, 37).Value = financeCountryKey;
|
||||||
ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy");
|
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, 41).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
|
||||||
ws.Cell(row, 42).Value = financeInclude
|
ws.Cell(row, 42).Value = financeInclude
|
||||||
? "Sales Price/Value"
|
? "Sales Price/Value"
|
||||||
: ResolveFinanceExclusionReason(record, financeCountryKey);
|
: financeRuleEngine.ResolveExclusionReason(record, financeCountryKey);
|
||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Columns().AdjustToContents();
|
ws.Columns().AdjustToContents();
|
||||||
if (includeFinanceHelpSheet)
|
if (includeFinanceHelpSheet)
|
||||||
{
|
{
|
||||||
AddFinanceSummarySheet(workbook, records);
|
AddFinanceSummarySheet(workbook, records, financeRules);
|
||||||
AddFinanceHelpSheet(workbook);
|
AddFinanceHelpSheet(workbook);
|
||||||
}
|
}
|
||||||
|
|
||||||
workbook.SaveAs(fullPath);
|
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 ws = workbook.Worksheets.Add("Finance Summary");
|
||||||
|
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||||
ws.Position = 1;
|
ws.Position = 1;
|
||||||
ws.Cell(1, 1).Value = "Finance Summary";
|
ws.Cell(1, 1).Value = "Finance Summary";
|
||||||
ws.Cell(1, 1).Style.Font.Bold = true;
|
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;
|
ws.Cell(4, i + 1).Style.Font.Bold = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var italyBlankSupplierCountryRows = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var summaryRows = records
|
var summaryRows = records
|
||||||
.Select(record =>
|
.Select(record =>
|
||||||
{
|
{
|
||||||
var financeDate = ResolveFinanceDate(record);
|
|
||||||
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
|
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
|
||||||
var rawInclude = ResolveFinanceInclude(record, countryKey, italyBlankSupplierCountryRows);
|
var financeDate = financeRuleEngine.ResolveFinanceDate(record, countryKey);
|
||||||
var value = ResolveFinanceNetSalesActual(record, countryKey, rawInclude);
|
var rawInclude = financeRuleEngine.ShouldInclude(record, countryKey);
|
||||||
|
var value = financeRuleEngine.ResolveNetSalesActual(record, countryKey, rawInclude);
|
||||||
var include = rawInclude && value != 0m;
|
var include = rawInclude && value != 0m;
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
@@ -298,15 +331,6 @@ public class ExcelExportService : IExcelExportService
|
|||||||
ws.Columns().AdjustToContents();
|
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)
|
private static string ResolveFinanceCurrency(SalesRecord record)
|
||||||
=> ResolveFinanceCountryKey(record.Land, record.Tsc) switch
|
=> ResolveFinanceCountryKey(record.Land, record.Tsc) switch
|
||||||
{
|
{
|
||||||
@@ -340,108 +364,6 @@ public class ExcelExportService : IExcelExportService
|
|||||||
return normalizedTsc.Replace("TR", string.Empty);
|
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)
|
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
using var workbook = new XLWorkbook();
|
||||||
|
|||||||
@@ -31,35 +31,51 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(r => r.IsActive)
|
.Where(r => r.IsActive)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
var financeRules = await db.FinanceRules
|
||||||
var centralRows = await db.CentralSalesRecords
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
|
.Where(r => r.IsActive)
|
||||||
.Select(r => new NetSalesActualSourceRow(
|
.OrderBy(r => r.SortOrder)
|
||||||
r.Land,
|
.ThenBy(r => r.Id)
|
||||||
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))
|
|
||||||
.ToListAsync();
|
.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
|
var groupedActuals = centralRows
|
||||||
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -149,13 +165,50 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
return result;
|
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(
|
private static NetSalesActual BuildNetSalesActual(
|
||||||
string referenceKey,
|
string referenceKey,
|
||||||
IEnumerable<NetSalesActualSourceRow> rows,
|
IEnumerable<NetSalesActualSourceRow> rows,
|
||||||
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
|
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
|
||||||
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
|
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
|
||||||
{
|
{
|
||||||
var rowList = ApplyCountryFinanceRules(referenceKey, rows).ToList();
|
var rowList = rows.ToList();
|
||||||
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
|
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
|
||||||
var documentRows = rowList
|
var documentRows = rowList
|
||||||
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
|
.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;
|
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(
|
private static decimal ConvertHouseCurrencyNetToBudgetChf(
|
||||||
string houseCurrency,
|
string houseCurrency,
|
||||||
NetSalesActualSourceRow row,
|
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())
|
await using (var db = await _dbFactory.CreateDbContextAsync())
|
||||||
{
|
{
|
||||||
db.Sites.Add(BuildSite());
|
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(
|
db.CentralSalesRecords.AddRange(
|
||||||
BuildCentralRecord("TRDE", "Deutschland", 1, 1, 100m, new DateTime(2025, 1, 5), new DateTime(2024, 12, 31)),
|
BuildCentralRecord("TRFR", "Frankreich", 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", 2, 1, 999m, new DateTime(2024, 12, 31), new DateTime(2025, 1, 5)));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Navigation": {
|
||||||
|
"ShowFinanceComparison": true
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"DevelopmentBypass": 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.
|
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.
|
||||||
|
|||||||
@@ -1849,3 +1849,84 @@ Ergebnis:
|
|||||||
|
|
||||||
- Build erfolgreich.
|
- Build erfolgreich.
|
||||||
- 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`.
|
- 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`
|
||||||
|
|||||||
Reference in New Issue
Block a user