using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; namespace TrafagSalesExporter.Services; public class ManagementCockpitService : IManagementCockpitService { private readonly IDbContextFactory _dbFactory; private readonly ICurrencyExchangeRateService _exchangeRateService; public ManagementCockpitService(IDbContextFactory dbFactory) : this(dbFactory, new CurrencyExchangeRateService(dbFactory)) { } public ManagementCockpitService(IDbContextFactory dbFactory, ICurrencyExchangeRateService exchangeRateService) { _dbFactory = dbFactory; _exchangeRateService = exchangeRateService; } private static readonly List ValueFieldDefinitions = [ new() { Key = ManagementCockpitValueFieldKeys.SalesPriceValue, Label = "Sales Price/Value", IsCurrencyAmount = true, CurrencySource = ValueCurrencySource.Sales }, new() { Key = ManagementCockpitValueFieldKeys.StandardCostTotal, Label = "Quantity * Standard cost", IsCurrencyAmount = true, CurrencySource = ValueCurrencySource.StandardCost }, new() { Key = ManagementCockpitValueFieldKeys.StandardCost, Label = "Standard cost", IsCurrencyAmount = true, CurrencySource = ValueCurrencySource.StandardCost }, new() { Key = ManagementCockpitValueFieldKeys.Quantity, Label = "Quantity", IsCurrencyAmount = false, CurrencySource = ValueCurrencySource.None } ]; public async Task> GetAvailableFilesAsync() { using var db = await _dbFactory.CreateDbContextAsync(); var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); var exportLogs = await db.ExportLogs .Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath)) .OrderByDescending(x => x.Timestamp) .Take(200) .ToListAsync(); var files = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var log in exportLogs) { if (!File.Exists(log.FilePath)) continue; files[log.FilePath] = new ManagementCockpitFileOption { Path = log.FilePath, DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}", LastModified = File.GetLastWriteTime(log.FilePath) }; } foreach (var directory in GetCandidateDirectories(settings)) { if (!Directory.Exists(directory)) continue; foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly)) { if (files.ContainsKey(file)) continue; var fileName = Path.GetFileName(file); files[file] = new ManagementCockpitFileOption { Path = file, DisplayName = fileName, LastModified = File.GetLastWriteTime(file) }; } } return files.Values .OrderByDescending(x => x.LastModified) .ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase) .ToList(); } public IReadOnlyList GetValueFieldOptions() => ValueFieldDefinitions .Select(ToValueFieldOption) .ToList(); public Task AnalyzeAsync(string filePath) => AnalyzeAsync(filePath, null); public Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options) { if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden."); var aggregation = ResolveAggregation(options); using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheets.First(); var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten."); var headerRow = usedRange.FirstRow(); var headers = headerRow.Cells() .Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) }) .Where(x => !string.IsNullOrWhiteSpace(x.Header)) .ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase); var rows = new List(); foreach (var row in usedRange.RowsUsed().Skip(1)) { if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString()))) continue; rows.Add(ReadRow(row, headers)); } if (rows.Count == 0) throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen."); ApplyAggregation(rows, aggregation); var result = new ManagementCockpitResult { FilePath = filePath, Summary = BuildSummary(rows, aggregation), Findings = BuildFindings(rows, aggregation), TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.AggregatedValue), TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.AggregatedValue), TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.AggregatedValue), DataQualityCounts = BuildDataQualityCounts(rows) }; return Task.FromResult(result); } public async Task> GetAvailableCentralYearsAsync() { using var db = await _dbFactory.CreateDbContextAsync(); var years = await db.CentralSalesRecords .Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year) .Distinct() .OrderBy(x => x) .ToListAsync(); return years; } public Task AnalyzeCentralAsync(int year, int? month) => AnalyzeCentralAsync(year, month, null); public async Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options) { var aggregation = ResolveAggregation(options); using var db = await _dbFactory.CreateDbContextAsync(); var baseRows = await db.CentralSalesRecords .Select(r => new CentralCockpitRow { SourceSystem = r.SourceSystem, Land = r.Land, Tsc = r.Tsc, InvoiceNumber = r.InvoiceNumber, SalesCurrency = string.IsNullOrWhiteSpace(r.SalesCurrency) ? "-" : r.SalesCurrency, StandardCostCurrency = string.IsNullOrWhiteSpace(r.StandardCostCurrency) ? "-" : r.StandardCostCurrency, Quantity = r.Quantity, StandardCost = r.StandardCost, SalesValue = r.SalesPriceValue, PeriodDate = r.InvoiceDate ?? r.ExtractionDate }) .ToListAsync(); if (baseRows.Count == 0) throw new InvalidOperationException("Die zentrale Tabelle enthält noch keine Datensätze."); var aggregatedRows = baseRows .Select(row => BuildCentralAggregationRow(row, aggregation)) .ToList(); var scopedRows = ApplyCentralDimensionFilters(aggregatedRows, options) .ToList(); var selectedRows = scopedRows .Where(r => r.PeriodDate.Year == year && (!month.HasValue || r.PeriodDate.Month == month.Value)) .ToList(); if (selectedRows.Count == 0) throw new InvalidOperationException("Für den gewählten Zeitraum gibt es keine Datensätze in der zentralen Tabelle."); var yearlyRows = scopedRows; var dailyBaseRows = selectedRows .Where(r => month.HasValue) .ToList(); return new ManagementCockpitCentralResult { Filter = new ManagementCockpitCentralFilter { Year = year, Month = month, ValueField = aggregation.ValueField.Key, TargetCurrency = aggregation.TargetCurrency, Land = NormalizeOptionalFilter(options?.LandFilter), Tsc = NormalizeOptionalFilter(options?.TscFilter) }, Summary = new ManagementCockpitCentralSummary { RowCount = selectedRows.Count, InvoiceCount = selectedRows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), SiteCount = selectedRows.Select(x => x.Tsc).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CountryCount = selectedRows.Select(x => x.Land).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CurrencyCount = selectedRows.Select(x => x.DisplayCurrency).Distinct(StringComparer.OrdinalIgnoreCase).Count(), ValueFieldKey = aggregation.ValueField.Key, ValueFieldLabel = aggregation.ValueField.Label, DisplayCurrency = BuildDisplayCurrencyLabel(selectedRows.Select(x => x.DisplayCurrency)), ValueTotal = selectedRows.Sum(x => x.Value), MissingExchangeRateCount = selectedRows.Count(x => x.MissingExchangeRate), PeriodStart = selectedRows.Min(x => x.PeriodDate), PeriodEnd = selectedRows.Max(x => x.PeriodDate) }, AdditionalValueFields = aggregation.AdditionalValueFields .Select(ToValueFieldOption) .ToList(), Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate), options), YearlyTotals = yearlyRows .GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => BuildTimeValueRow(g, aggregation, g.Key.Year.ToString(), g.Key.Year, null, null, g.Key.DisplayCurrency)) .ToList(), MonthlyTotals = selectedRows .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) .ThenBy(g => g.Key.Month) .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}", g.Key.Year, g.Key.Month, null, g.Key.DisplayCurrency)) .ToList(), DailyTotals = dailyBaseRows .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.PeriodDate.Day, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) .ThenBy(g => g.Key.Month) .ThenBy(g => g.Key.Day) .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}-{g.Key.Day:D2}", g.Key.Year, g.Key.Month, g.Key.Day, g.Key.DisplayCurrency)) .ToList(), SourceSystemTotals = selectedRows .GroupBy(x => new { x.SourceSystem, x.DisplayCurrency }) .OrderBy(g => g.Key.SourceSystem, StringComparer.OrdinalIgnoreCase) .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => new ManagementCockpitDimensionValueRow { Label = g.Key.SourceSystem, Currency = g.Key.DisplayCurrency, SalesValue = g.Sum(x => x.Value), RowCount = g.Count(), InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count() }) .ToList(), CountryTotals = selectedRows .GroupBy(x => new { x.Land, x.DisplayCurrency }) .OrderByDescending(g => g.Sum(x => x.Value)) .ThenBy(g => g.Key.Land, StringComparer.OrdinalIgnoreCase) .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => new ManagementCockpitDimensionValueRow { Label = g.Key.Land, Currency = g.Key.DisplayCurrency, SalesValue = g.Sum(x => x.Value), RowCount = g.Count(), InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count() }) .ToList() }; } public async Task AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency) { using var db = await _dbFactory.CreateDbContextAsync(); var financeRules = await db.FinanceRules .AsNoTracking() .Where(rule => rule.IsActive) .OrderBy(rule => rule.SortOrder) .ThenBy(rule => rule.Id) .ToListAsync(); if (financeRules.Count == 0) financeRules = FinanceRuleEngine.CreateDefaultRules().ToList(); var financeRuleEngine = new FinanceRuleEngine(financeRules); var records = 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, SupplierCountry = r.SupplierCountry, CustomerNumber = r.CustomerNumber, CustomerName = r.CustomerName, SalesCurrency = r.SalesCurrency, DocumentCurrency = r.DocumentCurrency, CompanyCurrency = r.CompanyCurrency, SalesPriceValue = r.SalesPriceValue, DocumentType = r.DocumentType, PostingDate = r.PostingDate, InvoiceDate = r.InvoiceDate, ExtractionDate = r.ExtractionDate }) .ToListAsync(); if (records.Count == 0) throw new InvalidOperationException("Die zentrale Tabelle enthaelt noch keine Datensaetze."); var allRows = records .Select(record => { var resolvedCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc); var financeDate = financeRuleEngine.ResolveFinanceDate(record, resolvedCountryKey); var rawInclude = financeRuleEngine.ShouldInclude(record, resolvedCountryKey); var value = financeRuleEngine.ResolveNetSalesActual(record, resolvedCountryKey, rawInclude); var include = rawInclude && value != 0m; return new FinanceAggregationRow { Year = financeDate.Year, CountryKey = resolvedCountryKey, Currency = ResolveFinanceCurrency(record), Include = include, Value = value }; }) .ToList(); var yearOptions = allRows .Select(row => row.Year) .Distinct() .OrderBy(yearValue => yearValue) .ToList(); if (year == 0) year = yearOptions.LastOrDefault(); var countryFilter = NormalizeOptionalFilter(countryKey); var currencyFilter = NormalizeOptionalFilter(currency); var scopedRows = allRows .Where(row => row.Year == year) .Where(row => countryFilter is null || row.CountryKey.Equals(countryFilter, StringComparison.OrdinalIgnoreCase)) .Where(row => currencyFilter is null || row.Currency.Equals(currencyFilter, StringComparison.OrdinalIgnoreCase)) .ToList(); var summaryRows = scopedRows .GroupBy(row => new { row.Year, row.CountryKey, row.Currency }) .OrderBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase) .ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase) .Select(group => BuildFinanceSummaryRow(group.Key.Year, group.Key.CountryKey, group.Key.Currency, group)) .ToList(); var yearRows = allRows .Where(row => countryFilter is null || row.CountryKey.Equals(countryFilter, StringComparison.OrdinalIgnoreCase)) .Where(row => currencyFilter is null || row.Currency.Equals(currencyFilter, StringComparison.OrdinalIgnoreCase)) .GroupBy(row => new { row.Year, row.Currency }) .OrderBy(group => group.Key.Year) .ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase) .Select(group => BuildFinanceSummaryRow(group.Key.Year, "Alle", group.Key.Currency, group)) .ToList(); var includedRows = scopedRows.Count(row => row.Include); var excludedRows = scopedRows.Count(row => !row.Include); var resultCurrencies = summaryRows .Select(row => row.Currency) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) .ToList(); var notices = new List { "Diese Sicht verwendet dieselbe FinanceRuleEngine wie das zentrale Excel-Blatt Finance Summary.", "Jahr, Land und Waehrung werden auf das Endergebnis angewendet.", "Finance-Jahr basiert auf PostingDate, danach InvoiceDate, danach ExtractionDate; DE-Regeln koennen das Jahr erzwingen.", "Include/Exclude, Gutschriften-Negierung und IT-Deduplizierung folgen den gepflegten Finance Regeln." }; if (scopedRows.Count == 0) { notices.Insert(0, "Fuer die gewaehlten Finance-Filter gibt es keine Datensaetze im aktuellen Zentraldatenbestand."); } return new ManagementFinanceSummaryResult { Filter = new ManagementFinanceSummaryFilter { Year = year, CountryKey = countryFilter, Currency = currencyFilter }, YearOptions = yearOptions, CountryOptions = allRows .Select(row => row.CountryKey) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) .ToList(), CurrencyOptions = allRows .Select(row => row.Currency) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) .ToList(), Rows = summaryRows, YearRows = yearRows, IncludedRows = includedRows, ExcludedRows = excludedRows, CountryCount = summaryRows.Select(row => row.CountryKey).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CurrencyCount = resultCurrencies.Count, NetSalesActual = summaryRows.Sum(row => row.NetSalesActual), DisplayCurrency = BuildDisplayCurrencyLabel(resultCurrencies), Notices = notices }; } private static IEnumerable ApplyCentralDimensionFilters( IEnumerable rows, ManagementCockpitAnalysisOptions? options) { var landFilter = NormalizeOptionalFilter(options?.LandFilter); var tscFilter = NormalizeOptionalFilter(options?.TscFilter); return rows.Where(row => (landFilter is null || string.Equals(row.Land, landFilter, StringComparison.OrdinalIgnoreCase)) && (tscFilter is null || string.Equals(row.Tsc, tscFilter, StringComparison.OrdinalIgnoreCase))); } private static ManagementFinanceSummaryRow BuildFinanceSummaryRow( int year, string countryKey, string currency, IEnumerable rows) { var rowList = rows.ToList(); return new ManagementFinanceSummaryRow { Year = year, CountryKey = countryKey, Currency = currency, IncludedRows = rowList.Count(row => row.Include), ExcludedRows = rowList.Count(row => !row.Include), NetSalesActual = rowList.Sum(row => row.Value) }; } private static string ResolveFinanceCurrency(SalesRecord record) => ResolveFinanceCountryKey(record.Land, record.Tsc) switch { "CH" => "CHF", "AT" => "EUR", "DE" => "EUR", "ES" => "EUR", "FR" => "EUR", "IN" => "INR", "IT" => "EUR", "UK" => "GBP", "US" => "USD", _ => string.IsNullOrWhiteSpace(record.CompanyCurrency) ? record.SalesCurrency : record.CompanyCurrency }; private static string ResolveFinanceCountryKey(string land, string tsc) { var normalizedLand = (land ?? string.Empty).Trim().ToUpperInvariant(); var normalizedTsc = (tsc ?? string.Empty).Trim().ToUpperInvariant(); if (normalizedLand is "AT" or "AUT" || normalizedLand.Contains("OESTER") || normalizedLand.Contains("OSTER") || normalizedLand.Contains("AUSTRIA")) return "AT"; if (normalizedLand is "CH" or "CHE" || normalizedLand.Contains("SCHWE") || normalizedLand.Contains("SWITZER")) return "CH"; if (normalizedLand.Contains("FRANK") || normalizedTsc.Contains("FR")) return "FR"; if (normalizedLand.Contains("IND") || normalizedTsc.Contains("IN")) return "IN"; if (normalizedLand.Contains("ITAL") || normalizedTsc.Contains("IT")) return "IT"; if (normalizedLand.Contains("ENGL") || normalizedLand.Contains("KINGDOM") || normalizedTsc.Contains("UK") || normalizedTsc.Contains("GB")) return "UK"; if (normalizedLand.Contains("USA") || normalizedLand.Contains("UNITED STATES") || normalizedTsc.Contains("US")) return "US"; if (normalizedLand.Contains("DEUT") || normalizedTsc.Contains("DE")) return "DE"; if (normalizedLand.Contains("SPAN") || normalizedTsc is "SE" or "ES") return "ES"; return normalizedTsc.Replace("TR", string.Empty); } private static IEnumerable GetCandidateDirectories(ExportSettings settings) { yield return Path.Combine(AppContext.BaseDirectory, "output"); if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder)) yield return settings.LocalSiteExportFolder.Trim(); if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder)) yield return settings.LocalConsolidatedExportFolder.Trim(); } private AggregationSelection ResolveAggregation(ManagementCockpitAnalysisOptions? options) { var selectedField = ValueFieldDefinitions.FirstOrDefault(x => string.Equals(x.Key, options?.ValueField, StringComparison.OrdinalIgnoreCase)) ?? ValueFieldDefinitions.First(x => x.Key == ManagementCockpitValueFieldKeys.SalesPriceValue); var additionalFields = (options?.AdditionalValueFields ?? []) .Select(key => ValueFieldDefinitions.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase))) .Where(x => x is not null && !string.Equals(x.Key, selectedField.Key, StringComparison.OrdinalIgnoreCase)) .Cast() .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToList(); var targetCurrency = (options?.TargetCurrency ?? ManagementCockpitCurrencyOptions.Native).Trim().ToUpperInvariant(); if (targetCurrency is not ManagementCockpitCurrencyOptions.Eur and not ManagementCockpitCurrencyOptions.Usd) targetCurrency = ManagementCockpitCurrencyOptions.Native; return new AggregationSelection( selectedField, additionalFields, targetCurrency, new Dictionary(StringComparer.OrdinalIgnoreCase)); } private void ApplyAggregation(List rows, AggregationSelection aggregation) { foreach (var row in rows) { var value = ResolveValue(row, aggregation.ValueField); var currency = ResolveCurrency(row, aggregation.ValueField); var converted = ConvertValue(value, currency, aggregation.ValueField, aggregation, row.InvoiceDate ?? row.OrderDate ?? row.ExtractionDate); row.AggregatedValue = converted.Value; row.AggregatedCurrency = converted.DisplayCurrency; row.MissingExchangeRate = converted.MissingExchangeRate; } } private CentralAggregationRow BuildCentralAggregationRow(CentralCockpitRow row, AggregationSelection aggregation) { var value = ResolveValue(row, aggregation.ValueField); var currency = ResolveCurrency(row, aggregation.ValueField); var converted = ConvertValue(value, currency, aggregation.ValueField, aggregation, row.PeriodDate); var additionalValues = aggregation.AdditionalValueFields.ToDictionary( field => field.Key, field => { var additionalValue = ResolveValue(row, field); var additionalCurrency = ResolveCurrency(row, field); return ConvertValue(additionalValue, additionalCurrency, field, aggregation, row.PeriodDate); }, StringComparer.OrdinalIgnoreCase); return new CentralAggregationRow { SourceSystem = row.SourceSystem, Land = row.Land, Tsc = row.Tsc, InvoiceNumber = row.InvoiceNumber, PeriodDate = row.PeriodDate, Value = converted.Value, DisplayCurrency = converted.DisplayCurrency, MissingExchangeRate = converted.MissingExchangeRate, AdditionalValues = additionalValues }; } private ConvertedValue ConvertValue(decimal value, string sourceCurrency, ValueFieldDefinition field, AggregationSelection aggregation, DateTime? effectiveDate) { if (!field.IsCurrencyAmount) return new ConvertedValue(value, "-", false); var normalizedSource = _exchangeRateService.NormalizeCurrencyCode(sourceCurrency); if (string.IsNullOrWhiteSpace(normalizedSource) || normalizedSource == "-") { normalizedSource = "-"; if (aggregation.TargetCurrency != ManagementCockpitCurrencyOptions.Native) return new ConvertedValue(0m, aggregation.TargetCurrency, true); } if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native) return new ConvertedValue(value, normalizedSource, false); if (string.Equals(normalizedSource, aggregation.TargetCurrency, StringComparison.OrdinalIgnoreCase)) return new ConvertedValue(value, aggregation.TargetCurrency, false); var rateDate = (effectiveDate ?? DateTime.UtcNow).Date; var cacheKey = BuildRateCacheKey(normalizedSource, aggregation.TargetCurrency, rateDate); if (!aggregation.RateCache.TryGetValue(cacheKey, out var rate)) { rate = _exchangeRateService.ResolveRate(normalizedSource, aggregation.TargetCurrency, rateDate); aggregation.RateCache[cacheKey] = rate; } if (!rate.HasValue) return new ConvertedValue(0m, aggregation.TargetCurrency, true); return new ConvertedValue(value * rate.Value, aggregation.TargetCurrency, false); } private static string BuildRateCacheKey(string fromCurrency, string toCurrency, DateTime date) => $"{fromCurrency}|{toCurrency}|{date:yyyy-MM-dd}"; private static decimal ResolveValue(CockpitRow row, ValueFieldDefinition field) => field.Key switch { ManagementCockpitValueFieldKeys.Quantity => row.Quantity, ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost, ManagementCockpitValueFieldKeys.StandardCostTotal => row.EstimatedCostTotal, _ => row.SalesValueTotal }; private static decimal ResolveValue(CentralCockpitRow row, ValueFieldDefinition field) => field.Key switch { ManagementCockpitValueFieldKeys.Quantity => row.Quantity, ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost, ManagementCockpitValueFieldKeys.StandardCostTotal => row.Quantity != 0m ? row.Quantity * row.StandardCost : row.StandardCost, _ => row.SalesValue }; private static string ResolveCurrency(CockpitRow row, ValueFieldDefinition field) => field.CurrencySource switch { ValueCurrencySource.StandardCost => row.StandardCostCurrency, ValueCurrencySource.Sales => row.SalesCurrency, _ => "-" }; private static string ResolveCurrency(CentralCockpitRow row, ValueFieldDefinition field) => field.CurrencySource switch { ValueCurrencySource.StandardCost => row.StandardCostCurrency, ValueCurrencySource.Sales => row.SalesCurrency, _ => "-" }; private static string BuildDisplayCurrencyLabel(IEnumerable currencies) { var distinct = currencies .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); return distinct.Count switch { 0 => "-", 1 => distinct[0], _ => "Mixed" }; } private static List BuildCentralNotices( AggregationSelection aggregation, int missingExchangeRateCount, ManagementCockpitAnalysisOptions? options) { var notices = new List { "Roh-Auswertung aus CentralSalesRecords.", $"Summenfeld: {aggregation.ValueField.Label}.", "Keine Intercompany-Bereinigung angewendet.", "Kein Budget- und kein Spartemapping angewendet.", "Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date." }; var landFilter = NormalizeOptionalFilter(options?.LandFilter); var tscFilter = NormalizeOptionalFilter(options?.TscFilter); if (landFilter is not null || tscFilter is not null) { notices.Add($"Filter aus Auswahl: Land {(landFilter ?? "alle")}, TSC {(tscFilter ?? "alle")}."); } if (aggregation.AdditionalValueFields.Count > 0) notices.Add($"Weitere Summenfelder: {string.Join(", ", aggregation.AdditionalValueFields.Select(x => x.Label))}."); if (!aggregation.ValueField.IsCurrencyAmount) { notices.Add("Das gewaehlte Summenfeld ist kein Waehrungsbetrag; die Anzeige-Waehrung wird ignoriert."); } else if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native) { notices.Add("Keine Waehrungsumrechnung angewendet; Werte bleiben in der jeweiligen Quellwaehrung."); } else { notices.Add($"Betragswerte werden in {aggregation.TargetCurrency} angezeigt."); if (missingExchangeRateCount > 0) notices.Add($"{missingExchangeRateCount} Zeilen hatten keinen passenden Wechselkurs und sind in den Summen mit 0 enthalten."); } return notices; } private static string? NormalizeOptionalFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static ManagementCockpitTimeValueRow BuildTimeValueRow( IEnumerable groupRows, AggregationSelection aggregation, string label, int? year, int? month, int? day, string currency) { var rows = groupRows.ToList(); return new ManagementCockpitTimeValueRow { Label = label, Year = year, Month = month, Day = day, Currency = currency, SalesValue = rows.Sum(x => x.Value), AdditionalValues = BuildAdditionalValues(rows, aggregation), RowCount = rows.Count }; } private static Dictionary BuildAdditionalValues( IReadOnlyCollection rows, AggregationSelection aggregation) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var field in aggregation.AdditionalValueFields) { var values = rows .Select(row => row.AdditionalValues.TryGetValue(field.Key, out var value) ? value : new ConvertedValue(0m, "-", false)) .ToList(); result[field.Key] = new ManagementCockpitAggregatedFieldValue { FieldKey = field.Key, Label = field.Label, Currency = BuildDisplayCurrencyLabel(values.Select(x => x.DisplayCurrency)), Value = values.Sum(x => x.Value), MissingExchangeRateCount = values.Count(x => x.MissingExchangeRate) }; } return result; } private static ManagementCockpitValueFieldOption ToValueFieldOption(ValueFieldDefinition field) => new() { Key = field.Key, Label = field.Label, IsCurrencyAmount = field.IsCurrencyAmount }; private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary headers) { var quantity = GetDecimal(row, headers, "quantity"); var standardCost = GetDecimal(row, headers, "standardcost"); var salesValue = GetDecimal(row, headers, "salespricevalue"); var estimatedCostTotal = quantity != 0m ? quantity * standardCost : standardCost; return new CockpitRow { ExtractionDate = GetDate(row, headers, "extractiondate"), Tsc = GetText(row, headers, "tsc"), InvoiceNumber = GetText(row, headers, "invoicenumber"), PositionOnInvoice = GetText(row, headers, "positiononinvoice"), Material = GetText(row, headers, "material"), Name = GetText(row, headers, "name"), ProductGroup = GetText(row, headers, "productgroup"), Quantity = quantity, SupplierNumber = GetText(row, headers, "suppliernumber"), SupplierName = GetText(row, headers, "suppliername"), SupplierCountry = GetText(row, headers, "suppliercountry"), CustomerNumber = GetText(row, headers, "customernumber"), CustomerName = GetText(row, headers, "customername"), CustomerCountry = GetText(row, headers, "customercountry"), CustomerIndustry = GetText(row, headers, "customerindustry"), StandardCost = standardCost, StandardCostCurrency = GetText(row, headers, "standardcostcurrency"), SalesValueTotal = salesValue, SalesCurrency = GetText(row, headers, "salescurrency"), Incoterms2020 = GetText(row, headers, "incoterms2020"), SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"), InvoiceDate = GetDate(row, headers, "invoicedate"), OrderDate = GetDate(row, headers, "orderdate"), Land = GetText(row, headers, "land"), EstimatedCostTotal = estimatedCostTotal, EstimatedMarginTotal = salesValue - estimatedCostTotal }; } private static ManagementCockpitSummary BuildSummary(List rows, AggregationSelection aggregation) { var aggregatedTotal = rows.Sum(x => x.AggregatedValue); var salesTotal = rows.Sum(x => x.SalesValueTotal); var costTotal = rows.Sum(x => x.EstimatedCostTotal); var marginTotal = rows.Sum(x => x.EstimatedMarginTotal); var serviceRows = rows.Where(x => x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) || x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) || x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList(); return new ManagementCockpitSummary { Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-", Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-", ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue), RowCount = rows.Count, InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), ValueFieldKey = aggregation.ValueField.Key, ValueFieldLabel = aggregation.ValueField.Label, DisplayCurrency = BuildDisplayCurrencyLabel(rows.Select(x => x.AggregatedCurrency)), MissingExchangeRateCount = rows.Count(x => x.MissingExchangeRate), AggregatedValueTotal = aggregatedTotal, SalesValueTotal = aggregatedTotal, EstimatedCostTotal = costTotal, EstimatedMarginTotal = marginTotal, EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m, ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m, MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count, MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count }; } private static List BuildFindings(List rows, AggregationSelection aggregation) { var findings = new List(); var salesTotal = rows.Sum(x => x.AggregatedValue); var topCustomer = rows .Where(x => !string.IsNullOrWhiteSpace(x.CustomerName)) .GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase) .Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.AggregatedValue) }) .OrderByDescending(x => x.Sales) .FirstOrDefault(); if (topCustomer is not null && salesTotal > 0) { var share = topCustomer.Sales / salesTotal * 100m; findings.Add(new ManagementCockpitFinding { Severity = share >= 50 ? "Warning" : "Info", Title = "Kundenkonzentration", Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes." }); } var missingExchangeRateRows = rows.Count(x => x.MissingExchangeRate); if (missingExchangeRateRows > 0) { findings.Add(new ManagementCockpitFinding { Severity = "Warning", Title = "Fehlende Wechselkurse", Detail = $"{missingExchangeRateRows} Zeilen konnten nicht in die gewaehlte Anzeige-Waehrung umgerechnet werden." }); } var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList(); if (zeroValueRows.Count > 0) { findings.Add(new ManagementCockpitFinding { Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info", Title = "Nullwerte in Kosten oder Umsatz", Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden." }); } var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue); if (missingOrderDates > 0) { findings.Add(new ManagementCockpitFinding { Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info", Title = "Fehlende Durchlaufzeit", Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar." }); } var orderLeadTimes = rows .Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue) .Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays) .Where(x => x >= 0) .ToList(); if (orderLeadTimes.Count > 0) { findings.Add(new ManagementCockpitFinding { Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info", Title = "Durchschnittliche Fakturierungszeit", Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage." }); } var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)); if (missingIndustries > 0) { findings.Add(new ManagementCockpitFinding { Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info", Title = "Stammdatenlücke Customer Industry", Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig." }); } var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020)); if (missingIncoterms > 0) { findings.Add(new ManagementCockpitFinding { Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info", Title = "Incoterms unvollständig", Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe." }); } if (findings.Count == 0) { findings.Add(new ManagementCockpitFinding { Severity = "Info", Title = "Keine auffälligen Datenqualitätsprobleme", Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug." }); } return findings; } private static List BuildTopItems( List rows, Func keySelector, Func valueSelector) { var total = rows.Sum(valueSelector); return rows .Select(x => new { Label = keySelector(x), Value = valueSelector(x) }) .Where(x => !string.IsNullOrWhiteSpace(x.Label)) .GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase) .Select(g => new ManagementCockpitTopItem { Label = g.Key, Value = g.Sum(x => x.Value), SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m }) .OrderByDescending(x => x.Value) .Take(5) .ToList(); } private static Dictionary BuildDataQualityCounts(List rows) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)), ["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)), ["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue), ["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue), ["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0) }; } private static string NormalizeHeader(string value) { var chars = value .ToLowerInvariant() .Where(char.IsLetterOrDigit) .ToArray(); return new string(chars); } private static string GetText(IXLRangeRow row, IReadOnlyDictionary headers, string key) => headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty; private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary headers, string key) { if (!headers.TryGetValue(key, out var index)) return 0m; var text = row.Cell(index).GetFormattedString().Trim(); if (decimal.TryParse(text, out var direct)) return direct; if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant)) return invariant; if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local)) return local; return 0m; } private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary headers, string key) { if (!headers.TryGetValue(key, out var index)) return null; var cell = row.Cell(index); if (cell.DataType == XLDataType.DateTime) return cell.GetDateTime(); var text = cell.GetString().Trim(); if (string.IsNullOrWhiteSpace(text)) return null; if (DateTime.TryParse(text, out var direct)) return direct; if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant)) return invariant; if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local)) return local; return null; } private class CockpitRow { public DateTime? ExtractionDate { get; set; } public string Tsc { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty; public string PositionOnInvoice { get; set; } = string.Empty; public string Material { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string ProductGroup { get; set; } = string.Empty; public decimal Quantity { get; set; } public string SupplierNumber { get; set; } = string.Empty; public string SupplierName { get; set; } = string.Empty; public string SupplierCountry { get; set; } = string.Empty; public string CustomerNumber { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty; public string CustomerCountry { get; set; } = string.Empty; public string CustomerIndustry { get; set; } = string.Empty; public decimal StandardCost { get; set; } public string StandardCostCurrency { get; set; } = string.Empty; public decimal SalesValueTotal { get; set; } public string SalesCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; public DateTime? InvoiceDate { get; set; } public DateTime? OrderDate { get; set; } public string Land { get; set; } = string.Empty; public decimal EstimatedCostTotal { get; set; } public decimal EstimatedMarginTotal { get; set; } public decimal AggregatedValue { get; set; } public string AggregatedCurrency { get; set; } = string.Empty; public bool MissingExchangeRate { get; set; } } private class CentralCockpitRow { public string SourceSystem { get; set; } = string.Empty; public string Land { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty; public string StandardCostCurrency { get; set; } = string.Empty; public decimal Quantity { get; set; } public decimal StandardCost { get; set; } public decimal SalesValue { get; set; } public DateTime PeriodDate { get; set; } } private class CentralAggregationRow { public string SourceSystem { get; set; } = string.Empty; public string Land { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty; public DateTime PeriodDate { get; set; } public decimal Value { get; set; } public string DisplayCurrency { get; set; } = string.Empty; public bool MissingExchangeRate { get; set; } public Dictionary AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase); } private class FinanceAggregationRow { public int Year { get; set; } public string CountryKey { get; set; } = string.Empty; public string Currency { get; set; } = string.Empty; public bool Include { get; set; } public decimal Value { get; set; } } private sealed record AggregationSelection( ValueFieldDefinition ValueField, IReadOnlyList AdditionalValueFields, string TargetCurrency, Dictionary RateCache); private sealed record ConvertedValue(decimal Value, string DisplayCurrency, bool MissingExchangeRate); private sealed class ValueFieldDefinition { public string Key { get; set; } = string.Empty; public string Label { get; set; } = string.Empty; public bool IsCurrencyAmount { get; set; } public ValueCurrencySource CurrencySource { get; set; } } private enum ValueCurrencySource { None, Sales, StandardCost } }