Enhance management cockpit analysis

This commit is contained in:
2026-04-29 07:00:29 +02:00
parent 49c03b9673
commit 3ac03a4782
15 changed files with 2651 additions and 384 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
$exe = Join-Path $PSScriptRoot 'bin\x86\Release\net48\SapProbe.exe'
$log = Join-Path $PSScriptRoot 'sap_probe_last_run.log'
if (-not (Test-Path -LiteralPath $exe)) {
Write-Host "SapProbe.exe was not found:"
Write-Host $exe
Read-Host "Press Enter to close"
exit 2
}
if (Test-Path -LiteralPath $log) {
Remove-Item -LiteralPath $log -Force
}
Start-Transcript -Path $log -Force | Out-Null
try {
& $exe @args
$exitCode = $LASTEXITCODE
Write-Host ''
Write-Host "Exit code: $exitCode"
}
finally {
Stop-Transcript | Out-Null
}
if (Test-Path -LiteralPath $log) {
$content = Get-Content -LiteralPath $log -Raw
$content = [regex]::Replace($content, '(?m)^Password for .*$','Password prompt: [masked input omitted]')
Set-Content -LiteralPath $log -Value $content -Encoding UTF8
}
Write-Host ''
Write-Host "Log file: $log"
Read-Host "Press Enter to close"
exit $exitCode
@@ -1,11 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>SapProbe</AssemblyName>
<RootNamespace>SapProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<Reference Include="sapnco">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="sapnco_utils">
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco_utils\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco_utils.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
@@ -11,7 +11,7 @@
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="8">
<MudItem xs="12" md="6">
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense>
@foreach (var file in _files)
{
@@ -19,7 +19,23 @@
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileValueField" Label="@T("Summenfeld", "Value field")" Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
@@ -37,10 +53,10 @@
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik.", "This view works directly on `CentralSalesRecords` and shows only neutral raw metrics. No intercompany filter, no CHF conversion, no budget, no divisional logic.")
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
</MudAlert>
<MudGrid>
<MudItem xs="12" md="4">
<MudItem xs="12" md="2">
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
@foreach (var year in _centralYears)
{
@@ -48,7 +64,7 @@
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
@foreach (var month in Enumerable.Range(1, 12))
{
@@ -56,7 +72,36 @@
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedCentralValueField" Label="@T("Summenfeld", "Value field")" Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string"
SelectedValues="_selectedCentralAdditionalValueFields"
SelectedValuesChanged="SetSelectedCentralAdditionalValueFields"
MultiSelection="true"
Label="@T("Weitere Summenfelder", "Additional value fields")"
Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_selectedCentralTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
@@ -70,8 +115,8 @@
<MudGrid Class="mb-4">
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Land", "Country")</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Umsatz", "Sales")</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Geschaetzte Marge", "Estimated margin")</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_result.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_result.Summary.AggregatedValueTotal, _result.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_result.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
@@ -90,7 +135,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
@foreach (var item in _result.TopCustomers)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
@@ -99,7 +144,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText>
@foreach (var item in _result.TopProductGroups)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
@@ -108,7 +153,7 @@
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText>
@foreach (var item in _result.TopSalesEmployees)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
@@ -130,50 +175,10 @@
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Waehrungen", "Currencies")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CurrencyCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_centralResult.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_centralResult.Summary.ValueTotal, _centralResult.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Cockpit Manometer", "Cockpit gauges")</MudText>
<MudText Typo="Typo.caption" Class="d-block mb-3">
@T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.")
</MudText>
<MudGrid>
@foreach (var gauge in BuildCentralGauges(_centralResult))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 cockpit-gauge-card" Elevation="0">
<MudText Typo="Typo.caption" Class="d-block mb-1">@gauge.Title</MudText>
<div class="cockpit-gauge-wrap">
<svg viewBox="0 0 220 140" class="cockpit-gauge" role="img" aria-label="@gauge.Title">
<path d="@GaugeArcPath"
fill="none"
stroke="#d7e2ea"
stroke-width="16"
stroke-linecap="round" />
<path d="@GaugeArcPath"
fill="none"
stroke="@gauge.Color"
stroke-width="16"
stroke-linecap="round"
pathLength="100"
stroke-dasharray="@BuildGaugeDashArray(gauge.Percent)" />
<line x1="110" y1="110" x2="@BuildGaugeNeedleX(gauge.Percent)" y2="@BuildGaugeNeedleY(gauge.Percent)"
stroke="#23313d"
stroke-width="5"
stroke-linecap="round" />
<circle cx="110" cy="110" r="8" fill="#23313d" />
<text x="110" y="76" text-anchor="middle" class="cockpit-gauge-value">@gauge.DisplayValue</text>
<text x="110" y="96" text-anchor="middle" class="cockpit-gauge-subtitle">@gauge.Subtitle</text>
</svg>
</div>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices)
@@ -185,18 +190,26 @@
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahresumsatz 2025/2026", "Yearly sales 2025/2026")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahreswerte", "Yearly values")</MudText>
<MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
@@ -204,18 +217,26 @@
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Monatsumsatz", "Monthly sales")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@T("Monatswerte", "Monthly values")</MudText>
<MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Monat", "Month")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
@@ -226,18 +247,26 @@
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Tagesumsatz im ausgewaehlten Monat", "Daily sales in selected month")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@T("Tageswerte im ausgewaehlten Monat", "Daily values in selected month")</MudText>
<MudTable Items="_centralResult.DailyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Tag", "Day")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
@@ -248,18 +277,18 @@
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Quelle", "Sales by source")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Quelle", "Values by source")</MudText>
<MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
@@ -268,19 +297,19 @@
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Land", "Sales by country")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Land", "Values by country")</MudText>
<MudTable Items="_centralResult.CountryTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
@@ -288,47 +317,26 @@
</MudPaper>
}
<style>
.cockpit-gauge-card {
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fa 100%);
border: 1px solid #dce7ee;
border-radius: 18px;
min-height: 220px;
}
.cockpit-gauge-wrap {
display: flex;
justify-content: center;
align-items: center;
}
.cockpit-gauge {
width: 100%;
max-width: 240px;
height: auto;
}
.cockpit-gauge-value {
font-size: 22px;
font-weight: 700;
fill: #153047;
}
.cockpit-gauge-subtitle {
font-size: 11px;
fill: #607587;
}
</style>
@code {
private List<ManagementCockpitFileOption> _files = [];
private List<int> _centralYears = [];
private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110";
private List<ManagementCockpitValueFieldOption> _valueFieldOptions = [];
private readonly List<CurrencySelectOption> _currencyOptions =
[
new(ManagementCockpitCurrencyOptions.Eur, "EUR"),
new(ManagementCockpitCurrencyOptions.Usd, "USD"),
new(ManagementCockpitCurrencyOptions.Native, "Original")
];
private string? _selectedFilePath;
private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult;
private int _selectedCentralYear;
private int? _selectedCentralMonth;
private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private IEnumerable<string> _selectedCentralAdditionalValueFields = [];
private string _selectedFileTargetCurrency = ManagementCockpitCurrencyOptions.Eur;
private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Eur;
private bool _loadingFiles;
private bool _analyzing;
private bool _analyzingCentral;
@@ -337,6 +345,7 @@
{
var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear);
_files = state.Files;
_valueFieldOptions = state.ValueFieldOptions;
_centralYears = state.CentralYears;
_selectedFilePath = state.SelectedFilePath;
_selectedCentralYear = state.SelectedCentralYear;
@@ -371,7 +380,11 @@
_analyzing = true;
try
{
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath);
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedFileValueField,
TargetCurrency = _selectedFileTargetCurrency
});
}
catch (Exception ex)
{
@@ -391,7 +404,12 @@
_analyzingCentral = true;
try
{
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth);
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedCentralValueField,
AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(),
TargetCurrency = _selectedCentralTargetCurrency
});
}
catch (Exception ex)
{
@@ -418,180 +436,31 @@
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
}
private List<CentralGaugeModel> BuildCentralGauges(ManagementCockpitCentralResult result)
{
var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount;
var sourceDominance = result.SourceSystemTotals.Count == 0
? 0m
: result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var countryDominance = result.CountryTotals.Count == 0
? 0m
: result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var periodCoverage = BuildPeriodCoveragePercent(result);
var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals);
var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals);
var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m);
var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result);
private static string FormatValue(decimal value, string currency)
=> string.IsNullOrWhiteSpace(currency) || currency == "-"
? value.ToString("N2")
: $"{value:N2} {currency}";
return
[
new CentralGaugeModel
private void SetSelectedCentralAdditionalValueFields(IEnumerable<string> values)
{
Title = T("Rechnungsdichte", "Invoice density"),
Percent = invoiceDensity,
DisplayValue = $"{invoiceDensity:F0}%",
Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"),
Color = "#1f8a70"
},
new CentralGaugeModel
{
Title = T("Quellen-Dominanz", "Source dominance"),
Percent = sourceDominance,
DisplayValue = $"{sourceDominance:F0}%",
Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"),
Color = "#d9822b"
},
new CentralGaugeModel
{
Title = T("Land-Dominanz", "Country dominance"),
Percent = countryDominance,
DisplayValue = $"{countryDominance:F0}%",
Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"),
Color = "#c4496b"
},
new CentralGaugeModel
{
Title = T("Perioden-Abdeckung", "Period coverage"),
Percent = periodCoverage,
DisplayValue = $"{periodCoverage:F0}%",
Subtitle = BuildPeriodGaugeSubtitle(result),
Color = "#3d7ff0"
},
new CentralGaugeModel
{
Title = T("Top-Land Umsatz", "Top country sales"),
Percent = topCountrySalesShare,
DisplayValue = $"{topCountrySalesShare:F0}%",
Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"),
Color = "#7f56d9"
},
new CentralGaugeModel
{
Title = T("Top-Quelle Umsatz", "Top source sales"),
Percent = topSourceSalesShare,
DisplayValue = $"{topSourceSalesShare:F0}%",
Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"),
Color = "#0f9fb5"
},
new CentralGaugeModel
{
Title = T("Waehrungs-Komplexitaet", "Currency complexity"),
Percent = currencyComplexity,
DisplayValue = result.Summary.CurrencyCount.ToString("N0"),
Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"),
Color = "#b54708"
},
new CentralGaugeModel
{
Title = T("Monat gegen Peak", "Month vs peak"),
Percent = peakVsAverageMonth,
DisplayValue = $"{peakVsAverageMonth:F0}%",
Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"),
Color = "#d92d20"
}
];
}
private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result)
{
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
return 0m;
if (result.Filter.Month.HasValue)
{
var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value);
var coveredDays = result.DailyTotals
.Select(x => x.Day)
.Where(x => x.HasValue)
.Distinct()
.Count();
return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth;
}
var coveredMonths = result.MonthlyTotals
.Select(x => x.Month)
.Where(x => x.HasValue)
.Distinct()
.Count();
return coveredMonths * 100m / 12m;
}
private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result)
=> result.Filter.Month.HasValue
? T("Tage mit Daten im Monat", "Days with data in month")
: T("Monate mit Daten im Jahr", "Months with data in year");
private static decimal BuildTopSalesSharePercent(IEnumerable<ManagementCockpitDimensionValueRow> rows)
{
var materialized = rows.ToList();
if (materialized.Count == 0)
return 0m;
var total = materialized.Sum(x => x.SalesValue);
if (total == 0)
return 0m;
return materialized.Max(x => x.SalesValue) * 100m / total;
}
private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result)
{
var monthRows = result.MonthlyTotals.ToList();
if (monthRows.Count == 0)
return 0m;
var groupedMonths = monthRows
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Sum(x => x.SalesValue))
_selectedCentralAdditionalValueFields = values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupedMonths.Count == 0)
return 0m;
var peak = groupedMonths.Max();
if (peak == 0)
return 0m;
var average = groupedMonths.Average();
return Math.Min(100m, average * 100m / peak);
}
private static string BuildGaugeDashArray(decimal percent)
=> $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100";
private static string BuildGaugeNeedleX(decimal percent)
=> GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static string BuildGaugeNeedleY(decimal percent)
=> GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d)
private static string FormatAdditionalValue(ManagementCockpitTimeValueRow row, string fieldKey)
{
var clamped = Math.Clamp((double)percent, 0d, 100d);
var angle = Math.PI * (1d - clamped / 100d);
var x = 110d + radius * Math.Cos(angle);
var y = 110d - radius * Math.Sin(angle);
return (x, y);
if (!row.AdditionalValues.TryGetValue(fieldKey, out var value))
return "-";
var formattedValue = FormatValue(value.Value, value.Currency);
return value.MissingExchangeRateCount == 0
? formattedValue
: $"{formattedValue} / {value.MissingExchangeRateCount} ohne Kurs";
}
private sealed class CentralGaugeModel
{
public string Title { get; set; } = string.Empty;
public decimal Percent { get; set; }
public string DisplayValue { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public string Color { get; set; } = "#3d7ff0";
}
private sealed record CurrencySelectOption(string Key, string Label);
}
@code {
+111
View File
@@ -2,6 +2,117 @@
Stand: 2026-04-15
## Nachtrag 2026-04-29 Management-Cockpit-Auswertung
Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden.
### Neue Auswertlogik
Das Cockpit ist nicht mehr nur auf Umsatz als feste Kennzahl beschraenkt.
Neu gibt es auswählbare Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Diese Auswahl gilt fuer:
- dateibasierte Analyse vorhandener Excel-Exporte
- zentrale Roh-Auswertung aus `CentralSalesRecords`
### Anzeige-Waehrung und Wechselkurse
Fuer betragliche Summenfelder kann jetzt eine Anzeige-Waehrung gewaehlt werden:
- `EUR`
- `USD`
- `Original`
Die Umrechnung nutzt `CurrencyExchangeRateService`.
Wichtig:
- nicht-betragliche Werte wie `Quantity` werden nicht umgerechnet
- bei `Original` bleiben Werte in der jeweiligen Quellwaehrung
- bei fehlendem Wechselkurs wird der betroffene Wert mit `0` in die Zielwaehrung eingerechnet
- fehlende Kurse werden als Anzahl `Nicht umgerechnet` bzw. in Hinweisen/Finding sichtbar gemacht
- Wechselkurse werden pro Quellwaehrung, Zielwaehrung und Datum gecacht, damit grosse Auswertungen nicht unnoetig oft die gleiche Rate aufloesen
### Zusätzliche Summenfelder in der zentralen Sicht
Die zentrale Roh-Auswertung kann neben dem Haupt-Summenfeld weitere Summenfelder anzeigen.
Diese Zusatzwerte werden aktuell in den Zeitreihen ausgegeben:
- Jahreswerte
- Monatswerte
- Tageswerte im gewaehlten Monat
Beispiel:
- Hauptwert: `Sales Price/Value`
- Zusatzwerte: `Quantity`, `Quantity * Standard cost`
Damit kann die zentrale Sicht Umsatz, Mengen und Kostennaeherung nebeneinander darstellen.
### UI-Stand
`Components/Pages/ManagementCockpit.razor` hat neue Controls:
- Summenfeld fuer Excel-Dateianalyse
- Anzeige-Waehrung fuer Excel-Dateianalyse
- Summenfeld fuer zentrale Roh-Auswertung
- weitere Summenfelder fuer zentrale Roh-Auswertung per Mehrfachauswahl
- Anzeige-Waehrung fuer zentrale Roh-Auswertung
Die Tabellen wurden von festem Text `Umsatz` auf generische `Werte` / `Jahreswerte` / `Monatswerte` umgestellt.
Die vorher dokumentierte Manometer-/Gauge-Sicht ist im aktuellen Arbeitsstand nicht mehr aktiv sichtbar. Stattdessen liegt der Fokus wieder auf Kennzahlen, Hinweisen und tabellarischen Auswertungen.
### Technische Umsetzung
Betroffene Dateien:
- `Components/Pages/ManagementCockpit.razor`
- `Models/ManagementCockpitModels.cs`
- `Services/IManagementCockpitService.cs`
- `Services/ManagementCockpitPageService.cs`
- `Services/ManagementCockpitService.cs`
- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
Neue bzw. erweiterte Modelle:
- `ManagementCockpitValueFieldKeys`
- `ManagementCockpitCurrencyOptions`
- `ManagementCockpitValueFieldOption`
- `ManagementCockpitAnalysisOptions`
- `ManagementCockpitAggregatedFieldValue`
Neue Felder in Ergebnissen:
- gewaehltes Summenfeld
- Anzeige-Waehrung
- Anzahl fehlender Wechselkurse
- Zusatzwerte pro Zeitreihe
### Testabdeckung
Die `ManagementCockpitServiceTests` wurden erweitert um Tests fuer:
- Umrechnung zentraler Werte in EUR
- Caching von Wechselkursauflösungen
- Mengen-Summe ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Jahres- und Monatswerten
Noch offen:
- UI manuell pruefen
- genaue fachliche Zielwaehrung fuer Standardberichte bestaetigen
- entscheiden, ob `CHF` ebenfalls als direkte Anzeige-Waehrung angeboten werden soll
- klaeren, ob fehlende Wechselkurse langfristig mit `0`, Originalwert oder separater Fehlergruppe dargestellt werden sollen
## Nachtrag 2026-04-17 Refactoring- und HANA-Stand
Der Stand aus den frueheren Nachtraegen ist fuer Architektur und HANA-Zugriff nicht mehr vollstaendig.
+36 -3
View File
@@ -228,12 +228,32 @@ Zwei Betriebsarten:
1. Dateibasiert
- vorhandene `.xlsx` waehlen
- Datei mit ClosedXML lesen
- Summenfeld waehlen
- Anzeige-Waehrung waehlen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords`
- Jahr/Monat Filter
- Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik
- Summenfeld waehlen
- optionale weitere Summenfelder fuer Zeitreihen waehlen
- Anzeige-Waehrung waehlen
- Rohsicht ohne Intercompany-, Budget- oder Spartelogik
Aktuelle Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Aktuelle Anzeige-Waehrungen:
- `EUR`
- `USD`
- `Original`
Die Waehrungsumrechnung nutzt `CurrencyExchangeRateService`. Bei `Original` bleiben Werte in Quellwaehrungen gruppiert. Nicht-betragliche Summenfelder wie `Quantity` haben keine Waehrung. Fehlende Wechselkurse werden gezaehlt und in Hinweisen bzw. Findings sichtbar; betroffene Werte werden in der Zielwaehrung mit `0` einbezogen.
## Quellsystemlogik
@@ -323,11 +343,14 @@ Vorhanden:
- `ExchangeRateImportService` fuer ECB-Tageskurse
- `NormalizeCurrencyCode`
- `ConvertCurrency`
- `ManagementCockpitService` kann betragliche Cockpit-Kennzahlen in `EUR` oder `USD` umrechnen
Wichtig:
- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um
- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht
- die Rohsicht im `Management Cockpit` kann jetzt Anzeige-Waehrungen nutzen
- `CHF` ist im Cockpit aktuell nicht als direkte Anzeige-Waehrung in der UI angeboten
- CHF bleibt weiterhin Teil des allgemeinen Transformationssystems
- fachlich ist noch zu klaeren, ob CHF als Standard- oder zusaetzliche Cockpit-Anzeige-Waehrung gebraucht wird
## SharePoint-Rolle im Gesamtsystem
@@ -429,6 +452,16 @@ Aktuell vorhandene Schwerpunkte:
- ConfigTransferService
- DatabaseInitializationService
`ManagementCockpitServiceTests` decken inzwischen auch ab:
- zentrale Analyse nach Jahr/Monat
- Tages-, Monats-, Jahres-, Quellen- und Laenderwerte
- waehlbare Summenfelder
- Waehrungsumrechnung in EUR
- Wechselkurs-Caching
- Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Zeitreihen
Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit`
@@ -7,6 +7,35 @@ public class ManagementCockpitFileOption
public DateTime LastModified { get; set; }
}
public static class ManagementCockpitValueFieldKeys
{
public const string SalesPriceValue = nameof(SalesPriceValue);
public const string Quantity = nameof(Quantity);
public const string StandardCost = nameof(StandardCost);
public const string StandardCostTotal = nameof(StandardCostTotal);
}
public static class ManagementCockpitCurrencyOptions
{
public const string Native = "NATIVE";
public const string Eur = "EUR";
public const string Usd = "USD";
}
public class ManagementCockpitValueFieldOption
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool IsCurrencyAmount { get; set; }
}
public class ManagementCockpitAnalysisOptions
{
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public List<string> AdditionalValueFields { get; set; } = [];
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
}
public class ManagementCockpitSummary
{
public string Land { get; set; } = string.Empty;
@@ -15,6 +44,11 @@ public class ManagementCockpitSummary
public int RowCount { get; set; }
public int InvoiceCount { get; set; }
public int CustomerCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public int MissingExchangeRateCount { get; set; }
public decimal AggregatedValueTotal { get; set; }
public decimal SalesValueTotal { get; set; }
public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
@@ -53,6 +87,8 @@ public class ManagementCockpitCentralFilter
{
public int Year { get; set; }
public int? Month { get; set; }
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
}
public class ManagementCockpitCentralSummary
@@ -62,6 +98,11 @@ public class ManagementCockpitCentralSummary
public int SiteCount { get; set; }
public int CountryCount { get; set; }
public int CurrencyCount { get; set; }
public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string ValueFieldLabel { get; set; } = "Sales Price/Value";
public string DisplayCurrency { get; set; } = string.Empty;
public decimal ValueTotal { get; set; }
public int MissingExchangeRateCount { get; set; }
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
}
@@ -74,9 +115,19 @@ public class ManagementCockpitTimeValueRow
public int? Day { get; set; }
public string Currency { get; set; } = string.Empty;
public decimal SalesValue { get; set; }
public Dictionary<string, ManagementCockpitAggregatedFieldValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int RowCount { get; set; }
}
public class ManagementCockpitAggregatedFieldValue
{
public string FieldKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal Value { get; set; }
public int MissingExchangeRateCount { get; set; }
}
public class ManagementCockpitDimensionValueRow
{
public string Label { get; set; } = string.Empty;
@@ -91,6 +142,7 @@ public class ManagementCockpitCentralResult
public ManagementCockpitCentralFilter Filter { get; set; } = new();
public ManagementCockpitCentralSummary Summary { get; set; } = new();
public List<string> Notices { get; set; } = [];
public List<ManagementCockpitValueFieldOption> AdditionalValueFields { get; set; } = [];
public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = [];
@@ -2,6 +2,54 @@
Stand: 2026-04-15
## Nachtrag 2026-04-29 Management Cockpit
Seit dem 2026-04-17 wurden im `Management Cockpit` weitere Auswertmoeglichkeiten umgesetzt und nachtraeglich aus dem aktuellen Code rekonstruiert.
Aktueller neuer Stand:
- Summenfeld ist waehbar statt fest auf Umsatz:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
- Anzeige-Waehrung ist waehbar:
- `EUR`
- `USD`
- `Original`
- betragliche Werte werden ueber `CurrencyExchangeRateService` umgerechnet
- nicht-betragliche Werte wie `Quantity` bleiben ohne Waehrung
- fehlende Wechselkurse werden gezaehlt und in der UI/Hinweisen sichtbar
- zentrale Roh-Auswertung kann weitere Summenfelder als Zusatzspalten in Jahres-, Monats- und Tageswerten anzeigen
- dateibasierte Excel-Analyse nutzt ebenfalls Summenfeld und Anzeige-Waehrung
Betroffene Dateien:
- `Components/Pages/ManagementCockpit.razor`
- `Models/ManagementCockpitModels.cs`
- `Services/IManagementCockpitService.cs`
- `Services/ManagementCockpitPageService.cs`
- `Services/ManagementCockpitService.cs`
- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
Neue Tests:
- Umrechnung zentraler Werte in EUR
- Wechselkurs-Cache pro Waehrung/Ziel/Datum
- Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatzwerte in Zeitreihen
### Jetzt sinnvoll zu pruefen
1. `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
2. Management Cockpit in der App oeffnen
3. zentrale Auswertung mit `Sales Price/Value` in `EUR` pruefen
4. zentrale Auswertung mit `Quantity` pruefen und bestaetigen, dass keine Waehrung angezeigt wird
5. Zusatzfelder `Quantity` und `Quantity * Standard cost` in Jahres-/Monatswerten pruefen
6. Dateianalyse einer exportierten Excel mit unterschiedlichen Summenfeldern pruefen
7. fachlich klaeren, ob `CHF` neben `EUR` und `USD` als Anzeige-Waehrung angeboten werden soll
8. fachlich klaeren, ob fehlende Wechselkurse als `0` in Zielwaehrung korrekt sind oder separat ausgewiesen werden sollen
## Nachtrag 2026-04-17 Refactoring-Fortschritt
Mehrere frueher als hoch priorisiert markierte Architekturpunkte sind inzwischen bereits umgesetzt.
+5
View File
@@ -6,6 +6,11 @@ using TrafagSalesExporter.Services.DataSources;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -5,7 +5,10 @@ namespace TrafagSalesExporter.Services;
public interface IManagementCockpitService
{
Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync();
IReadOnlyList<ManagementCockpitValueFieldOption> GetValueFieldOptions();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options);
Task<List<int>> GetAvailableCentralYearsAsync();
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month);
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options);
}
@@ -7,8 +7,8 @@ public interface IManagementCockpitPageService
Task<ManagementCockpitPageState> InitializeAsync(string? selectedFilePath, int selectedCentralYear);
Task<List<ManagementCockpitFileOption>> LoadFilesAsync();
Task<List<int>> LoadCentralYearsAsync();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month);
Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions options);
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options);
}
public sealed class ManagementCockpitPageService : IManagementCockpitPageService
@@ -28,6 +28,7 @@ public sealed class ManagementCockpitPageService : IManagementCockpitPageService
return new ManagementCockpitPageState
{
Files = files,
ValueFieldOptions = _cockpitService.GetValueFieldOptions().ToList(),
CentralYears = years,
SelectedFilePath = selectedFilePath ?? files.FirstOrDefault()?.Path,
SelectedCentralYear = selectedCentralYear == 0 ? years.LastOrDefault() : selectedCentralYear
@@ -40,16 +41,17 @@ public sealed class ManagementCockpitPageService : IManagementCockpitPageService
public Task<List<int>> LoadCentralYearsAsync()
=> _cockpitService.GetAvailableCentralYearsAsync();
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
=> _cockpitService.AnalyzeAsync(filePath);
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions options)
=> _cockpitService.AnalyzeAsync(filePath, options);
public Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month)
=> _cockpitService.AnalyzeCentralAsync(year, month);
public Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options)
=> _cockpitService.AnalyzeCentralAsync(year, month, options);
}
public sealed class ManagementCockpitPageState
{
public List<ManagementCockpitFileOption> Files { get; set; } = [];
public List<ManagementCockpitValueFieldOption> ValueFieldOptions { get; set; } = [];
public List<int> CentralYears { get; set; } = [];
public string? SelectedFilePath { get; set; }
public int SelectedCentralYear { get; set; }
@@ -8,12 +8,51 @@ namespace TrafagSalesExporter.Services;
public class ManagementCockpitService : IManagementCockpitService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICurrencyExchangeRateService _exchangeRateService;
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
: this(dbFactory, new CurrencyExchangeRateService(dbFactory))
{
}
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory, ICurrencyExchangeRateService exchangeRateService)
{
_dbFactory = dbFactory;
_exchangeRateService = exchangeRateService;
}
private static readonly List<ValueFieldDefinition> 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<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
@@ -65,11 +104,20 @@ public class ManagementCockpitService : IManagementCockpitService
.ToList();
}
public IReadOnlyList<ManagementCockpitValueFieldOption> GetValueFieldOptions()
=> ValueFieldDefinitions
.Select(ToValueFieldOption)
.ToList();
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
=> AnalyzeAsync(filePath, null);
public Task<ManagementCockpitResult> 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.");
@@ -92,14 +140,16 @@ public class ManagementCockpitService : IManagementCockpitService
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),
Findings = BuildFindings(rows),
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.SalesValueTotal),
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.SalesValueTotal),
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.SalesValueTotal),
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)
};
@@ -118,8 +168,13 @@ public class ManagementCockpitService : IManagementCockpitService
return years;
}
public async Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month)
public Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month)
=> AnalyzeCentralAsync(year, month, null);
public async Task<ManagementCockpitCentralResult> 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
@@ -129,6 +184,9 @@ public class ManagementCockpitService : IManagementCockpitService
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
})
@@ -137,16 +195,18 @@ public class ManagementCockpitService : IManagementCockpitService
if (baseRows.Count == 0)
throw new InvalidOperationException("Die zentrale Tabelle enthält noch keine Datensätze.");
var selectedRows = baseRows
var aggregatedRows = baseRows
.Select(row => BuildCentralAggregationRow(row, aggregation))
.ToList();
var selectedRows = aggregatedRows
.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 = baseRows
.Where(r => r.PeriodDate.Year == 2025 || r.PeriodDate.Year == 2026)
.ToList();
var yearlyRows = aggregatedRows;
var dailyBaseRows = selectedRows
.Where(r => month.HasValue)
@@ -157,7 +217,9 @@ public class ManagementCockpitService : IManagementCockpitService
Filter = new ManagementCockpitCentralFilter
{
Year = year,
Month = month
Month = month,
ValueField = aggregation.ValueField.Key,
TargetCurrency = aggregation.TargetCurrency
},
Summary = new ManagementCockpitCentralSummary
{
@@ -165,86 +227,63 @@ public class ManagementCockpitService : IManagementCockpitService
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.SalesCurrency).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)
},
Notices =
[
"Roh-Auswertung aus CentralSalesRecords.",
"Keine Intercompany-Bereinigung angewendet.",
"Keine CHF-Umrechnung angewendet. Umsatz bleibt in Sales Currency.",
"Kein Budget- und kein Spartemapping angewendet.",
"Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date."
],
AdditionalValueFields = aggregation.AdditionalValueFields
.Select(ToValueFieldOption)
.ToList(),
Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate)),
YearlyTotals = yearlyRows
.GroupBy(x => new { x.PeriodDate.Year, x.SalesCurrency })
.GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency })
.OrderBy(g => g.Key.Year)
.ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTimeValueRow
{
Label = g.Key.Year.ToString(),
Year = g.Key.Year,
Currency = g.Key.SalesCurrency,
SalesValue = g.Sum(x => x.SalesValue),
RowCount = g.Count()
})
.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.SalesCurrency })
.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.SalesCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTimeValueRow
{
Label = $"{g.Key.Year:D4}-{g.Key.Month:D2}",
Year = g.Key.Year,
Month = g.Key.Month,
Currency = g.Key.SalesCurrency,
SalesValue = g.Sum(x => x.SalesValue),
RowCount = g.Count()
})
.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.SalesCurrency })
.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.SalesCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTimeValueRow
{
Label = $"{g.Key.Year:D4}-{g.Key.Month:D2}-{g.Key.Day:D2}",
Year = g.Key.Year,
Month = g.Key.Month,
Day = g.Key.Day,
Currency = g.Key.SalesCurrency,
SalesValue = g.Sum(x => x.SalesValue),
RowCount = g.Count()
})
.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.SalesCurrency })
.GroupBy(x => new { x.SourceSystem, x.DisplayCurrency })
.OrderBy(g => g.Key.SourceSystem, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitDimensionValueRow
{
Label = g.Key.SourceSystem,
Currency = g.Key.SalesCurrency,
SalesValue = g.Sum(x => x.SalesValue),
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.SalesCurrency })
.OrderByDescending(g => g.Sum(x => x.SalesValue))
.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.SalesCurrency, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitDimensionValueRow
{
Label = g.Key.Land,
Currency = g.Key.SalesCurrency,
SalesValue = g.Sum(x => x.SalesValue),
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()
})
@@ -263,12 +302,253 @@ public class ManagementCockpitService : IManagementCockpitService
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<ValueFieldDefinition>()
.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<string, decimal?>(StringComparer.OrdinalIgnoreCase));
}
private void ApplyAggregation(List<CockpitRow> 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<string> 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<string> BuildCentralNotices(AggregationSelection aggregation, int missingExchangeRateCount)
{
var notices = new List<string>
{
"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."
};
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 ManagementCockpitTimeValueRow BuildTimeValueRow(
IEnumerable<CentralAggregationRow> 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<string, ManagementCockpitAggregatedFieldValue> BuildAdditionalValues(
IReadOnlyCollection<CentralAggregationRow> rows,
AggregationSelection aggregation)
{
var result = new Dictionary<string, ManagementCockpitAggregatedFieldValue>(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<string, int> headers)
{
var quantity = GetDecimal(row, headers, "quantity");
var standardCost = GetDecimal(row, headers, "standardcost");
var salesValue = GetDecimal(row, headers, "salespricevalue");
var estimatedCostTotal = quantity > 0 ? quantity * standardCost : standardCost;
var estimatedCostTotal = quantity != 0m ? quantity * standardCost : standardCost;
return new CockpitRow
{
@@ -288,7 +568,9 @@ public class ManagementCockpitService : IManagementCockpitService
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"),
@@ -299,8 +581,9 @@ public class ManagementCockpitService : IManagementCockpitService
};
}
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows)
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> 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);
@@ -317,7 +600,12 @@ public class ManagementCockpitService : IManagementCockpitService
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(),
SalesValueTotal = salesTotal,
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,
@@ -327,14 +615,14 @@ public class ManagementCockpitService : IManagementCockpitService
};
}
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows)
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows, AggregationSelection aggregation)
{
var findings = new List<ManagementCockpitFinding>();
var salesTotal = rows.Sum(x => x.SalesValueTotal);
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.SalesValueTotal) })
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.AggregatedValue) })
.OrderByDescending(x => x.Sales)
.FirstOrDefault();
@@ -349,6 +637,17 @@ public class ManagementCockpitService : IManagementCockpitService
});
}
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)
{
@@ -521,7 +820,9 @@ public class ManagementCockpitService : IManagementCockpitService
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; }
@@ -529,6 +830,9 @@ public class ManagementCockpitService : IManagementCockpitService
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
@@ -538,7 +842,46 @@ public class ManagementCockpitService : IManagementCockpitService
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<string, ConvertedValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
private sealed record AggregationSelection(
ValueFieldDefinition ValueField,
IReadOnlyList<ValueFieldDefinition> AdditionalValueFields,
string TargetCurrency,
Dictionary<string, decimal?> 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
}
}
@@ -26,7 +26,9 @@ public class TimerBackgroundService : BackgroundService
{
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
var settings = await db.ExportSettings
.OrderBy(x => x.Id)
.FirstOrDefaultAsync();
if (settings is null || !settings.TimerEnabled)
{
@@ -122,6 +122,111 @@ public class ManagementCockpitServiceTests : IDisposable
Assert.Equal(2, result.MonthlyTotals.Count);
}
[Fact]
public async Task AnalyzeCentralAsync_Can_Convert_Selected_Value_To_Eur()
{
await SeedRatesAsync(
CreateRate("EUR", "CHF", 2m),
CreateRate("EUR", "USD", 1.25m));
await SeedCentralRowsAsync(
CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10)),
CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 100m, new DateTime(2025, 1, 11)),
CreateRow("SAP", "Deutschland", "TRDE", "INV-3", "EUR", 100m, new DateTime(2025, 1, 12)));
var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions
{
ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue,
TargetCurrency = ManagementCockpitCurrencyOptions.Eur
});
Assert.Equal("EUR", result.Summary.DisplayCurrency);
Assert.Equal(230m, result.Summary.ValueTotal);
Assert.Equal(0, result.Summary.MissingExchangeRateCount);
Assert.All(result.CountryTotals, row => Assert.Equal("EUR", row.Currency));
Assert.Equal(50m, Assert.Single(result.CountryTotals, x => x.Label == "Schweiz").SalesValue);
Assert.Equal(80m, Assert.Single(result.CountryTotals, x => x.Label == "USA").SalesValue);
Assert.Equal(100m, Assert.Single(result.CountryTotals, x => x.Label == "Deutschland").SalesValue);
}
[Fact]
public async Task AnalyzeCentralAsync_Caches_Exchange_Rates_Per_Currency_Target_And_Date()
{
var exchangeRates = new CountingCurrencyExchangeRateService();
var service = new ManagementCockpitService(_dbFactory, exchangeRates);
await SeedCentralRowsAsync(
CreateRow("SAP", "USA", "TRUS", "INV-1", "USD", 100m, new DateTime(2025, 1, 10), quantity: 2m, standardCost: 10m),
CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 50m, new DateTime(2025, 1, 10), quantity: 3m, standardCost: 20m));
var result = await service.AnalyzeCentralAsync(2025, 1, new ManagementCockpitAnalysisOptions
{
ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue,
AdditionalValueFields = [ManagementCockpitValueFieldKeys.StandardCostTotal],
TargetCurrency = ManagementCockpitCurrencyOptions.Eur
});
Assert.Equal(300m, result.Summary.ValueTotal);
Assert.Equal(160m, Assert.Single(result.MonthlyTotals).AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value);
Assert.Equal(1, exchangeRates.ResolveRateCallCount);
}
[Fact]
public async Task AnalyzeCentralAsync_Can_Sum_Quantity_Without_Currency_Conversion()
{
await SeedCentralRowsAsync(
CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10), quantity: 2m),
CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 100m, new DateTime(2025, 1, 11), quantity: 3m));
var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions
{
ValueField = ManagementCockpitValueFieldKeys.Quantity,
TargetCurrency = ManagementCockpitCurrencyOptions.Eur
});
Assert.Equal(ManagementCockpitValueFieldKeys.Quantity, result.Summary.ValueFieldKey);
Assert.Equal("-", result.Summary.DisplayCurrency);
Assert.Equal(5m, result.Summary.ValueTotal);
Assert.Equal(0, result.Summary.MissingExchangeRateCount);
Assert.Equal(2m, Assert.Single(result.CountryTotals, x => x.Label == "Schweiz").SalesValue);
Assert.Equal(3m, Assert.Single(result.CountryTotals, x => x.Label == "USA").SalesValue);
}
[Fact]
public async Task AnalyzeCentralAsync_Adds_Selected_Additional_Value_Fields_To_Time_Rows()
{
await SeedCentralRowsAsync(
CreateRow("SAP", "Deutschland", "TRDE", "INV-1", "EUR", 100m, new DateTime(2025, 1, 10), quantity: 2m, standardCost: 5m),
CreateRow("SAP", "Deutschland", "TRDE", "INV-2", "EUR", 50m, new DateTime(2025, 2, 10), quantity: 3m, standardCost: 7m));
var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions
{
ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue,
AdditionalValueFields =
[
ManagementCockpitValueFieldKeys.Quantity,
ManagementCockpitValueFieldKeys.StandardCostTotal
],
TargetCurrency = ManagementCockpitCurrencyOptions.Eur
});
Assert.Equal(2, result.AdditionalValueFields.Count);
var yearly = Assert.Single(result.YearlyTotals);
Assert.Equal(150m, yearly.SalesValue);
Assert.Equal(5m, yearly.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Value);
Assert.Equal("-", yearly.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Currency);
Assert.Equal(31m, yearly.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value);
Assert.Equal("EUR", yearly.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Currency);
Assert.Contains(result.MonthlyTotals, row =>
row.Label == "2025-01" &&
row.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Value == 2m);
Assert.Contains(result.MonthlyTotals, row =>
row.Label == "2025-02" &&
row.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value == 21m);
}
[Fact]
public async Task AnalyzeCentralAsync_Throws_When_No_Rows_Exist_For_Selected_Period()
{
@@ -142,7 +247,36 @@ public class ManagementCockpitServiceTests : IDisposable
await db.SaveChangesAsync();
}
private static CentralSalesRecord CreateRow(string sourceSystem, string land, string tsc, string invoiceNumber, string currency, decimal salesValue, DateTime? invoiceDate, DateTime? extractionDate = null)
private async Task SeedRatesAsync(params CurrencyExchangeRate[] rates)
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.CurrencyExchangeRates.RemoveRange(db.CurrencyExchangeRates);
await db.SaveChangesAsync();
db.CurrencyExchangeRates.AddRange(rates);
await db.SaveChangesAsync();
}
private static CurrencyExchangeRate CreateRate(string fromCurrency, string toCurrency, decimal rate)
=> new()
{
FromCurrency = fromCurrency,
ToCurrency = toCurrency,
Rate = rate,
ValidFrom = new DateTime(2024, 1, 1),
IsActive = true
};
private static CentralSalesRecord CreateRow(
string sourceSystem,
string land,
string tsc,
string invoiceNumber,
string currency,
decimal salesValue,
DateTime? invoiceDate,
DateTime? extractionDate = null,
decimal quantity = 1m,
decimal standardCost = 1m)
{
return new CentralSalesRecord
{
@@ -156,7 +290,7 @@ public class ManagementCockpitServiceTests : IDisposable
Material = "MAT",
Name = "Article",
ProductGroup = "PG",
Quantity = 1m,
Quantity = quantity,
SupplierNumber = "SUP",
SupplierName = "Supplier",
SupplierCountry = "CH",
@@ -164,7 +298,7 @@ public class ManagementCockpitServiceTests : IDisposable
CustomerName = "Customer",
CustomerCountry = "CH",
CustomerIndustry = "Industry",
StandardCost = 1m,
StandardCost = standardCost,
StandardCostCurrency = currency,
PurchaseOrderNumber = "PO",
SalesPriceValue = salesValue,
@@ -192,4 +326,18 @@ public class ManagementCockpitServiceTests : IDisposable
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AppDbContext(_options));
}
private sealed class CountingCurrencyExchangeRateService : ICurrencyExchangeRateService
{
public int ResolveRateCallCount { get; private set; }
public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate)
{
ResolveRateCallCount++;
return 2m;
}
public string NormalizeCurrencyCode(string? currencyCode)
=> string.IsNullOrWhiteSpace(currencyCode) ? string.Empty : currencyCode.Trim().ToUpperInvariant();
}
}
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>