From 0cecb1eddf871a1a44f3cfb21bf82924077ea8b7 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 11 Jun 2026 09:04:25 +0200 Subject: [PATCH] Keep finance references in expert analysis --- .../Components/Pages/ManagementCockpit.razor | 68 +++++++++++-- .../Services/ManagementCockpitService.cs | 96 +++++++++++++++---- .../ManagementCockpitServiceTests.cs | 44 +++++++++ 3 files changed, 182 insertions(+), 26 deletions(-) diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 957ffb2..071eb97 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -1446,6 +1446,28 @@ if (_financeResult is null) return []; + if (IsFinance3dReferenceYearIndicator(_finance3dIndicator)) + { + return _financeResult.CountryRows + .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var rows = group.ToList(); + var first = rows[0]; + return new + { + country = first.CountryKey, + year = first.Year, + currency = BuildDisplayCurrencyLabel(rows.Select(row => row.Currency).Where(value => value != "-")), + value = ResolveFinance3dCountryValue(rows) + }; + }) + .OrderBy(row => row.country, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.year) + .Cast() + .ToList(); + } + var countryRowsByKey = _financeResult.CountryRows .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) .ToDictionary( @@ -1453,9 +1475,7 @@ group => group.ToList(), StringComparer.OrdinalIgnoreCase); - var sourceRows = IsFinance3dReferenceYearIndicator(_finance3dIndicator) - ? _financeResult.Rows - : (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows); + var sourceRows = _financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows; return sourceRows .OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase) @@ -1481,6 +1501,22 @@ if (_financeResult is null) return 0m; + if (IsFinance3dReferenceYearIndicator(_finance3dIndicator)) + { + var referenceValues = _financeResult.CountryRows + .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) + .Select(group => ResolveFinance3dCountryValue(group.ToList())) + .ToList(); + + if (IsFinance3dPercentIndicator(_finance3dIndicator)) + { + var nonZeroValues = referenceValues.Where(value => value != 0m).ToList(); + return nonZeroValues.Count == 0 ? 0m : nonZeroValues.Average(); + } + + return referenceValues.Sum(); + } + var countryRowsByKey = _financeResult.CountryRows .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) .ToDictionary( @@ -1488,9 +1524,7 @@ group => group.ToList(), StringComparer.OrdinalIgnoreCase); - var sourceRows = IsFinance3dReferenceYearIndicator(_finance3dIndicator) - ? _financeResult.Rows - : (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows); + var sourceRows = _financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows; var values = sourceRows .Select(row => @@ -1529,6 +1563,15 @@ _ => Math.Abs(row.NetSalesActual) }; + private decimal ResolveFinance3dCountryValue(IReadOnlyCollection rows) + => _finance3dIndicator switch + { + Finance3dIndicators.ReferenceValue => Math.Abs(rows.Select(row => row.ReferenceValue).FirstOrDefault(value => value.HasValue) ?? 0m), + Finance3dIndicators.Deviation => Math.Abs(rows.Where(row => row.Difference.HasValue).Sum(row => row.Difference!.Value)), + Finance3dIndicators.DeviationPercent => Math.Abs(AverageNullablePercent(rows.Select(row => row.DifferencePercent))), + _ => 0m + }; + private static bool IsFinance3dReferenceYearIndicator(string indicator) => indicator is Finance3dIndicators.ReferenceValue or Finance3dIndicators.Deviation or Finance3dIndicators.DeviationPercent; @@ -1662,6 +1705,17 @@ ? value.ToString("N2") : $"{value:N2} {currency}"; + private static string BuildDisplayCurrencyLabel(IEnumerable currencies) + { + var distinct = currencies + .Where(currency => !string.IsNullOrWhiteSpace(currency) && currency != "-") + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(currency => currency, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return distinct.Count == 0 ? "-" : string.Join("/", distinct); + } + private static string FormatNullableValue(decimal? value, string currency) => value.HasValue ? FormatValue(value.Value, currency) : "-"; @@ -1714,6 +1768,8 @@ { if (!row.ReferenceValue.HasValue) return T("Kein Sollwert gepflegt.", "No reference value maintained."); + if (row.TotalRows == 0) + return T("Sollwert gepflegt, aber kein Ist im aktuellen Filter.", "Reference maintained, but no actuals in the current filter."); if (row.Status == "OK") return T("Freigabefaehig.", "Ready for approval."); if (row.Difference.HasValue) diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index 29208c6..7b9bb75 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -390,13 +390,43 @@ public class ManagementCockpitService : IManagementCockpitService }) .ToList(); + var references = await db.FinanceReferences + .AsNoTracking() + .Where(reference => reference.IsActive) + .ToListAsync(); + var referenceByKey = references + .Where(reference => reference.Year == year) + .GroupBy(reference => reference.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group + .Select(reference => new FinanceReferenceValue( + reference.Key, + reference.Label, + reference.CheckValue ?? reference.LocalCurrencyValue)) + .FirstOrDefault(reference => reference.Value.HasValue), + StringComparer.OrdinalIgnoreCase); + var yearOptions = allRows .Select(row => row.Year) + .Concat(references.Select(reference => reference.Year)) .Distinct() .OrderBy(yearValue => yearValue) .ToList(); if (year == 0) year = yearOptions.LastOrDefault(); + referenceByKey = references + .Where(reference => reference.Year == year) + .GroupBy(reference => reference.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group + .Select(reference => new FinanceReferenceValue( + reference.Key, + reference.Label, + reference.CheckValue ?? reference.LocalCurrencyValue)) + .FirstOrDefault(reference => reference.Value.HasValue), + StringComparer.OrdinalIgnoreCase); var countryFilter = NormalizeOptionalFilter(countryKey); var currencyFilter = NormalizeOptionalFilter(currency); @@ -452,19 +482,8 @@ public class ManagementCockpitService : IManagementCockpitService notices.Insert(0, "Fuer die gewaehlten Finance-Filter gibt es keine Datensaetze im aktuellen Zentraldatenbestand."); } - var references = await db.FinanceReferences - .AsNoTracking() - .Where(reference => reference.IsActive && reference.Year == year) - .ToListAsync(); - var referenceByKey = references - .GroupBy(reference => reference.Key, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(reference => reference.CheckValue ?? reference.LocalCurrencyValue).FirstOrDefault(value => value.HasValue), - StringComparer.OrdinalIgnoreCase); - var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db, records, settings.UseAuditCsvAsCentralSource); - var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey); + var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey, year, countryFilter, currencyFilter); var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows); var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies); notices.AddRange(BuildProductAssignmentNotices(productAssignmentRows, productFinanceSummary)); @@ -480,6 +499,7 @@ public class ManagementCockpitService : IManagementCockpitService YearOptions = yearOptions, CountryOptions = allRows .Select(row => row.CountryKey) + .Concat(references.Where(reference => reference.Year == year).Select(reference => reference.Key)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) .ToList(), @@ -647,15 +667,18 @@ public class ManagementCockpitService : IManagementCockpitService private static List BuildFinanceCountryStatusRows( IReadOnlyCollection rows, - IReadOnlyDictionary referenceByKey) - => rows + IReadOnlyDictionary referenceByKey, + int year, + string? countryFilter, + string? currencyFilter) + { + var actualRows = rows .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 => { var rowList = group.ToList(); - referenceByKey.TryGetValue(group.Key.CountryKey, out var referenceValue); + referenceByKey.TryGetValue(group.Key.CountryKey, out var reference); + var referenceValue = reference?.Value; var actual = rowList.Sum(row => row.Value); var intercompanyValue = rowList.Where(row => row.IsIntercompany).Sum(row => row.Value); var difference = referenceValue.HasValue ? actual - referenceValue.Value : (decimal?)null; @@ -674,11 +697,38 @@ public class ManagementCockpitService : IManagementCockpitService ReferenceValue = referenceValue, Difference = difference, DifferencePercent = referenceValue is > 0m && difference.HasValue ? difference.Value / referenceValue.Value * 100m : null, - Status = BuildFinanceStatus(difference) + Status = BuildFinanceStatus(referenceValue, rowList.Count, difference) }; }) .ToList(); + var actualCountryKeys = actualRows + .Select(row => row.CountryKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var referenceOnlyRows = currencyFilter is null + ? referenceByKey.Values + .Where(reference => reference?.Value.HasValue == true) + .Select(reference => reference!) + .Where(reference => countryFilter is null || reference.Key.Equals(countryFilter, StringComparison.OrdinalIgnoreCase)) + .Where(reference => !actualCountryKeys.Contains(reference.Key)) + .Select(reference => new ManagementFinanceCountryStatusRow + { + Year = year, + CountryKey = reference.Key, + Currency = "-", + ReferenceValue = reference.Value, + Status = BuildFinanceStatus(reference.Value, 0, null) + }) + .ToList() + : []; + + return actualRows + .Concat(referenceOnlyRows) + .OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.Currency, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static List BuildFinanceCreditCandidates(IEnumerable rows) => rows .Where(row => row.Value < 0m || row.RawSalesValue < 0m || LooksLikeCreditDocument(row.DocumentType, row.InvoiceNumber)) @@ -996,10 +1046,14 @@ public class ManagementCockpitService : IManagementCockpitService }; } - private static string BuildFinanceStatus(decimal? difference) + private static string BuildFinanceStatus(decimal? referenceValue, int actualRowCount, decimal? difference) { - if (!difference.HasValue) + if (!referenceValue.HasValue) return "Kein Sollwert"; + if (actualRowCount == 0) + return "Keine Daten"; + if (!difference.HasValue) + return "Pruefen"; return Math.Abs(difference.Value) <= 1m ? "OK" : "Pruefen"; } @@ -1793,6 +1847,8 @@ public class ManagementCockpitService : IManagementCockpitService public DateTime ExtractionDate { get; set; } } + private sealed record FinanceReferenceValue(string Key, string Label, decimal? Value); + private sealed record AggregationSelection( ValueFieldDefinition ValueField, IReadOnlyList AdditionalValueFields, diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs index 6fd1844..1955028 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -350,6 +350,50 @@ public class ManagementCockpitServiceTests : IDisposable Assert.Contains(result.DataQualityRows, row => row.Issue == "Nullwerte im Finance-Wert" && row.Count == 1); } + [Fact] + public async Task AnalyzeFinanceSummaryAsync_Keeps_Reference_Only_Countries_In_Expert_Mode() + { + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.FinanceReferences.RemoveRange(db.FinanceReferences); + db.FinanceReferences.AddRange( + new FinanceReference + { + Key = "DE", + Label = "Trafag DE", + Year = 2025, + LocalCurrencyValue = 120m, + IsActive = true + }, + new FinanceReference + { + Key = "IT", + Label = "Trafag IT", + Year = 2025, + LocalCurrencyValue = 7669840m, + IsActive = true + }); + await db.SaveChangesAsync(); + } + + await SeedCentralRowsAsync( + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "INV-1", "EUR", 100m, new DateTime(2025, 1, 10))); + + var result = await _service.AnalyzeFinanceSummaryAsync(2025, null, null); + + var italy = Assert.Single(result.CountryRows, row => row.CountryKey == "IT"); + Assert.Equal(7669840m, italy.ReferenceValue); + Assert.Equal(0m, italy.NetSalesActual); + Assert.Equal(0, italy.TotalRows); + Assert.Equal("Keine Daten", italy.Status); + Assert.Contains("IT", result.CountryOptions); + + var filteredResult = await _service.AnalyzeFinanceSummaryAsync(2025, "IT", null); + var filteredItaly = Assert.Single(filteredResult.CountryRows); + Assert.Equal("IT", filteredItaly.CountryKey); + Assert.Equal(7669840m, filteredItaly.ReferenceValue); + } + [Fact] public async Task AnalyzeFinanceSummaryAsync_Builds_Central_Product_Assignment_Tab_Data() {