diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index e2c6320..55b4c1d 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -284,6 +284,95 @@ + + + + + @T("Gesamtumsatz", "Total sales") + @FormatValue(_financeResult.ProductFinanceSummary.TotalValue, _financeResult.ProductFinanceSummary.DisplayCurrency) + + + + + @T("Zugeordneter Umsatz", "Assigned sales") + @FormatValue(_financeResult.ProductFinanceSummary.AssignedValue, _financeResult.ProductFinanceSummary.DisplayCurrency) + @FormatPercent(_financeResult.ProductFinanceSummary.AssignedValuePercent) + + + + + @T("Nicht zugeordnet", "Unassigned") + @FormatValue(_financeResult.ProductFinanceSummary.UnassignedValue, _financeResult.ProductFinanceSummary.DisplayCurrency) + @FormatPercent(_financeResult.ProductFinanceSummary.UnassignedValuePercent) + + + + + @T("Nicht im Stamm", "Not in master") + @FormatValue(_financeResult.ProductFinanceSummary.MissingReferenceValue, _financeResult.ProductFinanceSummary.DisplayCurrency) + @FormatPercent(_financeResult.ProductFinanceSummary.MissingReferenceValuePercent) + + + + + + @T("Umsatz nach Produktsparte", "Sales by product division") + + + @T("Produktsparte", "Product division") + @T("Produktfamilie", "Product family") + PAPH1 + @T("Umsatz", "Sales") + @T("Anteil", "Share") + @T("Materialien", "Materials") + @T("Zeilen", "Rows") + @T("Laender", "Countries") + + + @BuildCodeText(context.ProductDivisionCode, context.ProductDivisionText) + @BuildCodeText(context.ProductFamilyCode, context.ProductFamilyText) + @BuildCodeText(context.ProductHierarchyCode, context.ProductHierarchyText) + @FormatValue(context.NetSalesActual, context.Currency) + @FormatPercent(context.SharePercent) + @context.MaterialCount.ToString("N0") + @context.RowCount.ToString("N0") + @context.Countries + + + @T("Keine zugeordneten Spartenumsaetze fuer diese Filter.", "No assigned division sales for these filters.") + + + + + + @T("Umsatzabdeckung nach Land", "Sales coverage by country") + + + @T("Land", "Country") + TSC + @T("Gesamt", "Total") + @T("Zugeordnet", "Assigned") + @T("Nicht zugeordnet", "Unassigned") + @T("Nicht im Stamm", "Not in master") + @T("Material fehlt", "Material missing") + @T("Abdeckung", "Coverage") + + + @context.CountryKey + @context.Tsc + @FormatValue(context.TotalValue, context.Currency) + @FormatValue(context.AssignedValue, context.Currency) + @FormatValue(context.UnassignedValue, context.Currency) + @FormatValue(context.MissingReferenceValue, context.Currency) + @FormatValue(context.MissingMaterialValue, context.Currency) + @FormatPercent(context.AssignedValuePercent) + + + @T("Keine Umsatzabdeckung fuer diese Filter.", "No sales coverage for these filters.") + + + + diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs index 90bc973..39e9f4c 100644 --- a/TrafagSalesExporter/Models/ManagementCockpitModels.cs +++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs @@ -225,6 +225,48 @@ public class ManagementProductAssignmentSummary public int ReferenceMaterialCount { get; set; } } +public class ManagementProductFinanceSummary +{ + public decimal TotalValue { get; set; } + public decimal AssignedValue { get; set; } + public decimal UnassignedValue { get; set; } + public decimal MissingReferenceValue { get; set; } + public decimal MissingMaterialValue { get; set; } + public decimal AssignedValuePercent { get; set; } + public decimal UnassignedValuePercent { get; set; } + public decimal MissingReferenceValuePercent { get; set; } + public string DisplayCurrency { get; set; } = string.Empty; +} + +public class ManagementProductDivisionFinanceRow +{ + public string ProductDivisionCode { get; set; } = string.Empty; + public string ProductDivisionText { get; set; } = string.Empty; + public string ProductFamilyCode { get; set; } = string.Empty; + public string ProductFamilyText { get; set; } = string.Empty; + public string ProductHierarchyCode { get; set; } = string.Empty; + public string ProductHierarchyText { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public decimal NetSalesActual { get; set; } + public decimal SharePercent { get; set; } + public int MaterialCount { get; set; } + public int RowCount { get; set; } + public string Countries { get; set; } = string.Empty; +} + +public class ManagementProductFinanceCountryRow +{ + public string CountryKey { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public decimal TotalValue { get; set; } + public decimal AssignedValue { get; set; } + public decimal UnassignedValue { get; set; } + public decimal MissingReferenceValue { get; set; } + public decimal MissingMaterialValue { get; set; } + public decimal AssignedValuePercent { get; set; } +} + public class ManagementProductAssignmentCountryRow { public string CountryKey { get; set; } = string.Empty; @@ -278,6 +320,9 @@ public class ManagementFinanceSummaryResult public List DataStatusRows { get; set; } = []; public List CreditCandidates { get; set; } = []; public List DataQualityRows { get; set; } = []; + public ManagementProductFinanceSummary ProductFinanceSummary { get; set; } = new(); + public List ProductDivisionFinanceRows { get; set; } = []; + public List ProductFinanceCountryRows { get; set; } = []; public ManagementProductAssignmentSummary ProductAssignmentSummary { get; set; } = new(); public List ProductAssignmentCountryRows { get; set; } = []; public List ProductAssignmentRows { get; set; } = []; diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index e9f8076..a99f72f 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -460,6 +460,7 @@ public class ManagementCockpitService : IManagementCockpitService var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db); var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey); var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows); + var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies); return new ManagementFinanceSummaryResult { @@ -497,6 +498,9 @@ public class ManagementCockpitService : IManagementCockpitService DataStatusRows = dataStatusRows, CreditCandidates = BuildFinanceCreditCandidates(scopedRows), DataQualityRows = BuildFinanceDataQualityRows(scopedRows), + ProductFinanceSummary = productFinanceSummary, + ProductDivisionFinanceRows = BuildProductDivisionFinanceRows(productAssignmentRows), + ProductFinanceCountryRows = BuildProductFinanceCountryRows(productAssignmentRows), ProductAssignmentSummary = BuildProductAssignmentSummary(productAssignmentRows), ProductAssignmentCountryRows = BuildProductAssignmentCountryRows(productAssignmentRows), ProductAssignmentRows = productAssignmentRows @@ -711,6 +715,101 @@ public class ManagementCockpitService : IManagementCockpitService .Count() }; + private static ManagementProductFinanceSummary BuildProductFinanceSummary( + IReadOnlyCollection rows, + IReadOnlyCollection currencies) + { + var total = rows.Sum(row => row.NetSalesActual); + var assigned = rows.Where(row => row.Status == ProductAssignmentStatuses.Assigned).Sum(row => row.NetSalesActual); + var unassigned = rows.Where(row => row.Status == ProductAssignmentStatuses.Unassigned).Sum(row => row.NetSalesActual); + var missingReference = rows.Where(row => row.Status == ProductAssignmentStatuses.NoReference).Sum(row => row.NetSalesActual); + var missingMaterial = rows.Where(row => row.Status == ProductAssignmentStatuses.MissingMaterial).Sum(row => row.NetSalesActual); + + return new ManagementProductFinanceSummary + { + TotalValue = total, + AssignedValue = assigned, + UnassignedValue = unassigned, + MissingReferenceValue = missingReference, + MissingMaterialValue = missingMaterial, + AssignedValuePercent = PercentOf(assigned, total), + UnassignedValuePercent = PercentOf(unassigned, total), + MissingReferenceValuePercent = PercentOf(missingReference, total), + DisplayCurrency = BuildDisplayCurrencyLabel(currencies) + }; + } + + private static List BuildProductDivisionFinanceRows(IEnumerable rows) + { + var assignedRows = rows + .Where(row => row.Status == ProductAssignmentStatuses.Assigned) + .ToList(); + var totalsByCurrency = assignedRows + .GroupBy(row => row.Currency, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Sum(row => row.NetSalesActual), StringComparer.OrdinalIgnoreCase); + + return assignedRows + .GroupBy(row => new + { + row.ProductDivisionCode, + row.ProductDivisionText, + row.ProductFamilyCode, + row.ProductFamilyText, + row.ProductHierarchyCode, + row.ProductHierarchyText, + row.Currency + }) + .Select(group => + { + var value = group.Sum(row => row.NetSalesActual); + totalsByCurrency.TryGetValue(group.Key.Currency, out var total); + return new ManagementProductDivisionFinanceRow + { + ProductDivisionCode = group.Key.ProductDivisionCode, + ProductDivisionText = group.Key.ProductDivisionText, + ProductFamilyCode = group.Key.ProductFamilyCode, + ProductFamilyText = group.Key.ProductFamilyText, + ProductHierarchyCode = group.Key.ProductHierarchyCode, + ProductHierarchyText = group.Key.ProductHierarchyText, + Currency = group.Key.Currency, + NetSalesActual = value, + SharePercent = PercentOf(value, total), + MaterialCount = group.Select(row => row.Material).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + RowCount = group.Sum(row => row.RowCount), + Countries = JoinDistinct(group.Select(row => row.CountryKey)) + }; + }) + .OrderByDescending(row => Math.Abs(row.NetSalesActual)) + .ThenBy(row => row.ProductDivisionCode, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static List BuildProductFinanceCountryRows(IEnumerable rows) + => rows + .GroupBy(row => new { row.CountryKey, row.Tsc, row.Currency }) + .Select(group => + { + var rowList = group.ToList(); + var total = rowList.Sum(row => row.NetSalesActual); + var assigned = rowList.Where(row => row.Status == ProductAssignmentStatuses.Assigned).Sum(row => row.NetSalesActual); + return new ManagementProductFinanceCountryRow + { + CountryKey = group.Key.CountryKey, + Tsc = group.Key.Tsc, + Currency = group.Key.Currency, + TotalValue = total, + AssignedValue = assigned, + UnassignedValue = rowList.Where(row => row.Status == ProductAssignmentStatuses.Unassigned).Sum(row => row.NetSalesActual), + MissingReferenceValue = rowList.Where(row => row.Status == ProductAssignmentStatuses.NoReference).Sum(row => row.NetSalesActual), + MissingMaterialValue = rowList.Where(row => row.Status == ProductAssignmentStatuses.MissingMaterial).Sum(row => row.NetSalesActual), + AssignedValuePercent = PercentOf(assigned, total) + }; + }) + .OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.Tsc, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.Currency, StringComparer.OrdinalIgnoreCase) + .ToList(); + private static List BuildProductAssignmentCountryRows(IEnumerable rows) => rows .GroupBy(row => new { row.CountryKey, row.Tsc }) @@ -774,6 +873,9 @@ public class ManagementCockpitService : IManagementCockpitService private static string NormalizeMaterialKey(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); + private static decimal PercentOf(decimal value, decimal total) + => total == 0m ? 0m : value * 100m / total; + private static ManagementFinanceDataQualityRow BuildQualityRow(string issue, int count, int totalRows) { var share = totalRows == 0 ? 0m : count / (decimal)totalRows; diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs index 9da2858..0d9d555 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -368,6 +368,29 @@ public class ManagementCockpitServiceTests : IDisposable row.Tsc == "TRDE" && row.MatchedMaterialCount == 1 && row.UnassignedMaterialCount == 1); + + Assert.Equal(260m, result.ProductFinanceSummary.TotalValue); + Assert.Equal(180m, result.ProductFinanceSummary.AssignedValue); + Assert.Equal(30m, result.ProductFinanceSummary.UnassignedValue); + Assert.Equal(50m, result.ProductFinanceSummary.MissingReferenceValue); + Assert.Equal(180m * 100m / 260m, result.ProductFinanceSummary.AssignedValuePercent); + + Assert.Contains(result.ProductDivisionFinanceRows, row => + row.ProductDivisionCode == "0001" && + row.Currency == "EUR" && + row.NetSalesActual == 80m && + row.MaterialCount == 1 && + row.Countries == "DE"); + Assert.Contains(result.ProductDivisionFinanceRows, row => + row.ProductDivisionCode == "0001" && + row.Currency == "CHF" && + row.NetSalesActual == 100m); + + var deFinanceCoverage = Assert.Single(result.ProductFinanceCountryRows, row => row.CountryKey == "DE" && row.Tsc == "TRDE"); + Assert.Equal(100m, deFinanceCoverage.TotalValue); + Assert.Equal(80m, deFinanceCoverage.AssignedValue); + Assert.Equal(20m, deFinanceCoverage.UnassignedValue); + Assert.Equal(80m, deFinanceCoverage.AssignedValuePercent); } private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows)