Add product division finance analysis

This commit is contained in:
2026-05-29 10:40:46 +02:00
parent 6593bf41be
commit aeb20fc565
4 changed files with 259 additions and 0 deletions
@@ -284,6 +284,95 @@
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Sparten-Finanzanalyse", "Division finance")" Icon="@Icons.Material.Filled.PieChart">
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Gesamtumsatz", "Total sales")</MudText>
<MudText Typo="Typo.h6">@FormatValue(_financeResult.ProductFinanceSummary.TotalValue, _financeResult.ProductFinanceSummary.DisplayCurrency)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Zugeordneter Umsatz", "Assigned sales")</MudText>
<MudText Typo="Typo.h6">@FormatValue(_financeResult.ProductFinanceSummary.AssignedValue, _financeResult.ProductFinanceSummary.DisplayCurrency)</MudText>
<MudText Typo="Typo.caption">@FormatPercent(_financeResult.ProductFinanceSummary.AssignedValuePercent)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Nicht zugeordnet", "Unassigned")</MudText>
<MudText Typo="Typo.h6">@FormatValue(_financeResult.ProductFinanceSummary.UnassignedValue, _financeResult.ProductFinanceSummary.DisplayCurrency)</MudText>
<MudText Typo="Typo.caption">@FormatPercent(_financeResult.ProductFinanceSummary.UnassignedValuePercent)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Nicht im Stamm", "Not in master")</MudText>
<MudText Typo="Typo.h6">@FormatValue(_financeResult.ProductFinanceSummary.MissingReferenceValue, _financeResult.ProductFinanceSummary.DisplayCurrency)</MudText>
<MudText Typo="Typo.caption">@FormatPercent(_financeResult.ProductFinanceSummary.MissingReferenceValuePercent)</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Produktsparte", "Sales by product division")</MudText>
<MudTable Items="_financeResult.ProductDivisionFinanceRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Produktsparte", "Product division")</MudTh>
<MudTh>@T("Produktfamilie", "Product family")</MudTh>
<MudTh>PAPH1</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@T("Anteil", "Share")</MudTh>
<MudTh>@T("Materialien", "Materials")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Laender", "Countries")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@BuildCodeText(context.ProductDivisionCode, context.ProductDivisionText)</MudTd>
<MudTd>@BuildCodeText(context.ProductFamilyCode, context.ProductFamilyText)</MudTd>
<MudTd>@BuildCodeText(context.ProductHierarchyCode, context.ProductHierarchyText)</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@FormatPercent(context.SharePercent)</MudTd>
<MudTd>@context.MaterialCount.ToString("N0")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@context.Countries</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine zugeordneten Spartenumsaetze fuer diese Filter.", "No assigned division sales for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatzabdeckung nach Land", "Sales coverage by country")</MudText>
<MudTable Items="_financeResult.ProductFinanceCountryRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
<MudTh>@T("Zugeordnet", "Assigned")</MudTh>
<MudTh>@T("Nicht zugeordnet", "Unassigned")</MudTh>
<MudTh>@T("Nicht im Stamm", "Not in master")</MudTh>
<MudTh>@T("Material fehlt", "Material missing")</MudTh>
<MudTh>@T("Abdeckung", "Coverage")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Tsc</MudTd>
<MudTd>@FormatValue(context.TotalValue, context.Currency)</MudTd>
<MudTd>@FormatValue(context.AssignedValue, context.Currency)</MudTd>
<MudTd>@FormatValue(context.UnassignedValue, context.Currency)</MudTd>
<MudTd>@FormatValue(context.MissingReferenceValue, context.Currency)</MudTd>
<MudTd>@FormatValue(context.MissingMaterialValue, context.Currency)</MudTd>
<MudTd>@FormatPercent(context.AssignedValuePercent)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Umsatzabdeckung fuer diese Filter.", "No sales coverage for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Zentrale Spartenzuordnung", "Central division mapping")" Icon="@Icons.Material.Filled.AccountTree">
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="2">
@@ -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<ManagementFinanceDataStatusRow> DataStatusRows { get; set; } = [];
public List<ManagementFinanceCreditCandidateRow> CreditCandidates { get; set; } = [];
public List<ManagementFinanceDataQualityRow> DataQualityRows { get; set; } = [];
public ManagementProductFinanceSummary ProductFinanceSummary { get; set; } = new();
public List<ManagementProductDivisionFinanceRow> ProductDivisionFinanceRows { get; set; } = [];
public List<ManagementProductFinanceCountryRow> ProductFinanceCountryRows { get; set; } = [];
public ManagementProductAssignmentSummary ProductAssignmentSummary { get; set; } = new();
public List<ManagementProductAssignmentCountryRow> ProductAssignmentCountryRows { get; set; } = [];
public List<ManagementProductAssignmentRow> ProductAssignmentRows { get; set; } = [];
@@ -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<ManagementProductAssignmentRow> rows,
IReadOnlyCollection<string> 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<ManagementProductDivisionFinanceRow> BuildProductDivisionFinanceRows(IEnumerable<ManagementProductAssignmentRow> 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<ManagementProductFinanceCountryRow> BuildProductFinanceCountryRows(IEnumerable<ManagementProductAssignmentRow> 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<ManagementProductAssignmentCountryRow> BuildProductAssignmentCountryRows(IEnumerable<ManagementProductAssignmentRow> 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;
@@ -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)