239 lines
9.1 KiB
C#
239 lines
9.1 KiB
C#
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();
|
|
}
|