Keep finance references in expert analysis

This commit is contained in:
2026-06-11 09:04:25 +02:00
parent dcd845d337
commit 0cecb1eddf
3 changed files with 182 additions and 26 deletions
@@ -1446,6 +1446,28 @@
if (_financeResult is null) if (_financeResult is null)
return []; 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<object>()
.ToList();
}
var countryRowsByKey = _financeResult.CountryRows var countryRowsByKey = _financeResult.CountryRows
.GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase)
.ToDictionary( .ToDictionary(
@@ -1453,9 +1475,7 @@
group => group.ToList(), group => group.ToList(),
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
var sourceRows = IsFinance3dReferenceYearIndicator(_finance3dIndicator) var sourceRows = _financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows;
? _financeResult.Rows
: (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows);
return sourceRows return sourceRows
.OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase) .OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase)
@@ -1481,6 +1501,22 @@
if (_financeResult is null) if (_financeResult is null)
return 0m; 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 var countryRowsByKey = _financeResult.CountryRows
.GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase) .GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase)
.ToDictionary( .ToDictionary(
@@ -1488,9 +1524,7 @@
group => group.ToList(), group => group.ToList(),
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
var sourceRows = IsFinance3dReferenceYearIndicator(_finance3dIndicator) var sourceRows = _financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows;
? _financeResult.Rows
: (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows);
var values = sourceRows var values = sourceRows
.Select(row => .Select(row =>
@@ -1529,6 +1563,15 @@
_ => Math.Abs(row.NetSalesActual) _ => Math.Abs(row.NetSalesActual)
}; };
private decimal ResolveFinance3dCountryValue(IReadOnlyCollection<ManagementFinanceCountryStatusRow> 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) private static bool IsFinance3dReferenceYearIndicator(string indicator)
=> indicator is Finance3dIndicators.ReferenceValue or Finance3dIndicators.Deviation or Finance3dIndicators.DeviationPercent; => indicator is Finance3dIndicators.ReferenceValue or Finance3dIndicators.Deviation or Finance3dIndicators.DeviationPercent;
@@ -1662,6 +1705,17 @@
? value.ToString("N2") ? value.ToString("N2")
: $"{value:N2} {currency}"; : $"{value:N2} {currency}";
private static string BuildDisplayCurrencyLabel(IEnumerable<string> 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) private static string FormatNullableValue(decimal? value, string currency)
=> value.HasValue ? FormatValue(value.Value, currency) : "-"; => value.HasValue ? FormatValue(value.Value, currency) : "-";
@@ -1714,6 +1768,8 @@
{ {
if (!row.ReferenceValue.HasValue) if (!row.ReferenceValue.HasValue)
return T("Kein Sollwert gepflegt.", "No reference value maintained."); 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") if (row.Status == "OK")
return T("Freigabefaehig.", "Ready for approval."); return T("Freigabefaehig.", "Ready for approval.");
if (row.Difference.HasValue) if (row.Difference.HasValue)
@@ -390,13 +390,43 @@ public class ManagementCockpitService : IManagementCockpitService
}) })
.ToList(); .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 var yearOptions = allRows
.Select(row => row.Year) .Select(row => row.Year)
.Concat(references.Select(reference => reference.Year))
.Distinct() .Distinct()
.OrderBy(yearValue => yearValue) .OrderBy(yearValue => yearValue)
.ToList(); .ToList();
if (year == 0) if (year == 0)
year = yearOptions.LastOrDefault(); 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 countryFilter = NormalizeOptionalFilter(countryKey);
var currencyFilter = NormalizeOptionalFilter(currency); 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."); 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 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 productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies); var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies);
notices.AddRange(BuildProductAssignmentNotices(productAssignmentRows, productFinanceSummary)); notices.AddRange(BuildProductAssignmentNotices(productAssignmentRows, productFinanceSummary));
@@ -480,6 +499,7 @@ public class ManagementCockpitService : IManagementCockpitService
YearOptions = yearOptions, YearOptions = yearOptions,
CountryOptions = allRows CountryOptions = allRows
.Select(row => row.CountryKey) .Select(row => row.CountryKey)
.Concat(references.Where(reference => reference.Year == year).Select(reference => reference.Key))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToList(), .ToList(),
@@ -647,15 +667,18 @@ public class ManagementCockpitService : IManagementCockpitService
private static List<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows( private static List<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows(
IReadOnlyCollection<FinanceAggregationRow> rows, IReadOnlyCollection<FinanceAggregationRow> rows,
IReadOnlyDictionary<string, decimal?> referenceByKey) IReadOnlyDictionary<string, FinanceReferenceValue?> referenceByKey,
=> rows int year,
string? countryFilter,
string? currencyFilter)
{
var actualRows = rows
.GroupBy(row => new { row.Year, row.CountryKey, row.Currency }) .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 => .Select(group =>
{ {
var rowList = group.ToList(); 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 actual = rowList.Sum(row => row.Value);
var intercompanyValue = rowList.Where(row => row.IsIntercompany).Sum(row => row.Value); var intercompanyValue = rowList.Where(row => row.IsIntercompany).Sum(row => row.Value);
var difference = referenceValue.HasValue ? actual - referenceValue.Value : (decimal?)null; var difference = referenceValue.HasValue ? actual - referenceValue.Value : (decimal?)null;
@@ -674,11 +697,38 @@ public class ManagementCockpitService : IManagementCockpitService
ReferenceValue = referenceValue, ReferenceValue = referenceValue,
Difference = difference, Difference = difference,
DifferencePercent = referenceValue is > 0m && difference.HasValue ? difference.Value / referenceValue.Value * 100m : null, DifferencePercent = referenceValue is > 0m && difference.HasValue ? difference.Value / referenceValue.Value * 100m : null,
Status = BuildFinanceStatus(difference) Status = BuildFinanceStatus(referenceValue, rowList.Count, difference)
}; };
}) })
.ToList(); .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<ManagementFinanceCreditCandidateRow> BuildFinanceCreditCandidates(IEnumerable<FinanceAggregationRow> rows) private static List<ManagementFinanceCreditCandidateRow> BuildFinanceCreditCandidates(IEnumerable<FinanceAggregationRow> rows)
=> rows => rows
.Where(row => row.Value < 0m || row.RawSalesValue < 0m || LooksLikeCreditDocument(row.DocumentType, row.InvoiceNumber)) .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"; return "Kein Sollwert";
if (actualRowCount == 0)
return "Keine Daten";
if (!difference.HasValue)
return "Pruefen";
return Math.Abs(difference.Value) <= 1m ? "OK" : "Pruefen"; return Math.Abs(difference.Value) <= 1m ? "OK" : "Pruefen";
} }
@@ -1793,6 +1847,8 @@ public class ManagementCockpitService : IManagementCockpitService
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
} }
private sealed record FinanceReferenceValue(string Key, string Label, decimal? Value);
private sealed record AggregationSelection( private sealed record AggregationSelection(
ValueFieldDefinition ValueField, ValueFieldDefinition ValueField,
IReadOnlyList<ValueFieldDefinition> AdditionalValueFields, IReadOnlyList<ValueFieldDefinition> AdditionalValueFields,
@@ -350,6 +350,50 @@ public class ManagementCockpitServiceTests : IDisposable
Assert.Contains(result.DataQualityRows, row => row.Issue == "Nullwerte im Finance-Wert" && row.Count == 1); 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] [Fact]
public async Task AnalyzeFinanceSummaryAsync_Builds_Central_Product_Assignment_Tab_Data() public async Task AnalyzeFinanceSummaryAsync_Builds_Central_Product_Assignment_Tab_Data()
{ {