Add purchasing period analytics

This commit is contained in:
2026-06-05 13:54:36 +02:00
parent 43250a4abc
commit aa6d0d0804
4 changed files with 598 additions and 44 deletions
@@ -61,6 +61,35 @@
</div>
</MudPaper>
<MudPaper Class="pa-3 mb-4 purchasing-filter-panel" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="2">
<div>
<MudText Typo="Typo.subtitle1">@T("Zeitraum", "Period")</MudText>
<MudText Typo="Typo.body2" Class="purchasing-muted">
@T("Alle Einkauf-Kennzahlen, Top-Listen und 3D-Daten werden auf diesen Zeitraum abgegrenzt.",
"All purchasing KPIs, top lists and 3D data are limited to this period.")
</MudText>
</div>
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@FilterLabel</MudChip>
</MudStack>
<div class="purchasing-filter-grid">
<label>
<span>@T("Von Monat", "From month")</span>
<input type="month" value="@_filterFromMonth" @onchange="SetFilterFromMonth" />
</label>
<label>
<span>@T("Bis Monat", "To month")</span>
<input type="month" value="@_filterToMonth" @onchange="SetFilterToMonth" />
</label>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.FilterAlt" OnClick="ApplyPurchasingFilterAsync" Disabled="_liveLoading">
@T("Anwenden", "Apply")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="SetLastThreeYearsAsync" Disabled="_liveLoading">
@T("Letzte 3 Jahre", "Last 3 years")
</MudButton>
</div>
</MudPaper>
<MudGrid Class="mb-4" Spacing="2">
@foreach (var card in KpiCards)
{
@@ -390,6 +419,53 @@
</MudPaper>
</MudItem>
}
else
{
<MudItem xs="12">
<PurchasingSection TitleDe="@SelectedIdeaAnalysisTitleDe"
TitleEn="@SelectedIdeaAnalysisTitleEn"
DescriptionDe="@SelectedIdeaAnalysisDescriptionDe"
DescriptionEn="@SelectedIdeaAnalysisDescriptionEn"
ChartTitleDe="@SelectedIdeaChartTitleDe"
ChartTitleEn="@SelectedIdeaChartTitleEn"
Kpis="@SelectedIdeaKpis"
ChartRows="@SelectedIdeaChartRows"
StatusRows="@SelectedIdeaStatusRows"
DetailRows="@SelectedIdeaDetailRows" />
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-3 purchasing-overview-panel" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Detail-Hotlist", "Detail hotlist")</MudText>
<MudText Typo="Typo.body2" Class="purchasing-muted">
@T("Direkt aus dem Einkauf-Cache berechnet, keine Simulation.",
"Calculated directly from the purchasing cache, no simulation.")
</MudText>
</div>
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">@SelectedIdeaRows.Count @T("Zeilen", "rows")</MudChip>
</MudStack>
<MudTable Items="@SelectedIdeaRows" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Objekt", "Object")</MudTh>
<MudTh>@T("Wert", "Value")</MudTh>
<MudTh>@T("Detail", "Detail")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd><strong>@context.Value</strong></MudTd>
<MudTd>@context.Detail</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@ResolveAnalysisSeverityColor(context.Severity)" Variant="Variant.Outlined">
@context.Severity
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
}
</MudGrid>
break;
case "kennzahlen":
@@ -583,6 +659,10 @@
private double _purchasing3dFactor = 1d;
private double _purchasing3dLabelScale = 1.5d;
private string _lastRendered3dUri = string.Empty;
private string _filterFromMonth = BuildDefaultFromMonth();
private string _filterToMonth = BuildDefaultToMonth();
private string FilterLabel => $"{_filterFromMonth} - {_filterToMonth}";
private string CurrentPurchasingPage
{
@@ -605,7 +685,7 @@
private IReadOnlyList<PurchasingKpiCard> KpiCards =>
[
new("Spend total", "Total spend", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : T("wartet auf EKPO", "waiting for EKPO"), _liveState.UsesCache ? "Einkauf Cache Vollwerte" : _liveState.EkpoLoaded ? "EKPO-Live-Sample" : "EKKO live, Positionswerte fehlen noch", _liveState.UsesCache ? "purchasing cache full values" : _liveState.EkpoLoaded ? "EKPO live sample" : "EKKO live, position values still missing", Icons.Material.Filled.Payments, Color.Primary),
new("Offene Bestellungen", "Open orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", _liveState.EkkoLoaded ? "EKKO-Belege seit Jahresbeginn" : "Noch nicht geladen", _liveState.EkkoLoaded ? "EKKO orders since start of year" : "Not loaded yet", Icons.Material.Filled.PendingActions, Color.Warning),
new("Offene Bestellungen", "Open orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", _liveState.EkkoLoaded ? $"EKKO-Belege im Zeitraum {FilterLabel}" : "Noch nicht geladen", _liveState.EkkoLoaded ? $"EKKO orders in period {FilterLabel}" : "Not loaded yet", Icons.Material.Filled.PendingActions, Color.Warning),
new("Kontrakte", "Contracts", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : T("wartet auf EKET", "waiting for EKET"), _liveState.UsesCache ? "Restwert aus Einkauf Cache" : _liveState.EketLoaded ? "Restwert aus EKET/EKPO-Sample" : "EKKO live, Terminwerte fehlen noch", _liveState.UsesCache ? "Remaining value from purchasing cache" : _liveState.EketLoaded ? "Remaining value from EKET/EKPO sample" : "EKKO live, schedule values still missing", Icons.Material.Filled.Assignment, Color.Info),
new("Lieferantenperformance", "Supplier performance", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", _liveState.EkkoLoaded ? "Lieferanten in EKKO-Liveprobe" : "Noch nicht geladen", _liveState.EkkoLoaded ? "Suppliers in EKKO live sample" : "Not loaded yet", Icons.Material.Filled.Verified, Color.Success)
];
@@ -654,7 +734,7 @@
private IReadOnlyList<PurchasingPipelineStep> PipelineRows =>
[
new("EKKO Bestellkopf", "EKKO purchase header", _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-", _liveState.EkkoLoaded ? "Bestellungen seit Jahresbeginn sind live verfuegbar" : "Bestellkopf wartet auf SAP", _liveState.EkkoLoaded ? "Orders since start of year are available live" : "Purchase header is waiting for SAP", Icons.Material.Filled.ReceiptLong, _liveState.EkkoLoaded, _liveState.EkkoLoaded ? Color.Success : Color.Warning),
new("EKKO Bestellkopf", "EKKO purchase header", _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-", _liveState.EkkoLoaded ? $"Bestellungen im Zeitraum {FilterLabel}" : "Bestellkopf wartet auf SAP", _liveState.EkkoLoaded ? $"Orders in period {FilterLabel}" : "Purchase header is waiting for SAP", Icons.Material.Filled.ReceiptLong, _liveState.EkkoLoaded, _liveState.EkkoLoaded ? Color.Success : Color.Warning),
new("EKPO Positionen", "EKPO item rows", _liveState.EkpoLoaded ? $"{_liveState.PositionSampleCount:N0}" : "0", _liveState.EkpoLoaded ? "Spend, Artikel und Warengruppen koennen echt berechnet werden" : "Spend und offene Werte bleiben Simulation", _liveState.EkpoLoaded ? "Spend, articles and material groups can be calculated real" : "Spend and open values remain simulation", Icons.Material.Filled.Inventory2, _liveState.EkpoLoaded, _liveState.EkpoLoaded ? Color.Success : Color.Warning),
new("EKET Termine", "EKET schedules", _liveState.EketLoaded ? $"{_liveState.ScheduleSampleCount:N0}" : "0", _liveState.EketLoaded ? "Faelligkeiten und Terminstatus koennen echt berechnet werden" : "Faelligkeiten und Kontrakte warten auf SAP", _liveState.EketLoaded ? "Due dates and schedule status can be calculated real" : "Due dates and contracts wait for SAP", Icons.Material.Filled.EventAvailable, _liveState.EketLoaded, _liveState.EketLoaded ? Color.Success : Color.Warning),
new("Dashboard Layer", "Dashboard layer", _liveState.EkkoLoaded ? "aktiv" : "bereit", "Livewerte, Simulation und 3D-Analyse werden getrennt ausgewiesen", "Live values, simulation and 3D analysis are shown separately", Icons.Material.Filled.DashboardCustomize, true, Color.Info)
@@ -663,7 +743,7 @@
private IReadOnlyList<string> ManagementInsights =>
[
_liveState.EkkoLoaded
? T($"{_liveState.PurchaseOrderCount:N0} Einkaufsbelege sind fuer 2026 live im Cockpit.", $"{_liveState.PurchaseOrderCount:N0} purchase orders are live in the cockpit for 2026.")
? T($"{_liveState.PurchaseOrderCount:N0} Einkaufsbelege sind im Zeitraum {FilterLabel} im Cockpit.", $"{_liveState.PurchaseOrderCount:N0} purchase orders are in the cockpit for {FilterLabel}.")
: T("EKKO wird geladen; danach erscheinen Bestellungen und Lieferanten live.", "EKKO is loading; orders and suppliers appear live afterwards."),
_liveState.EkpoLoaded
? T("Spend, Artikel und Warengruppen koennen nun aus SAP-Positionen kommen.", "Spend, articles and material groups can now come from SAP item rows.")
@@ -684,7 +764,7 @@
private IReadOnlyList<PurchasingSectionKpi> OpenOrderKpis =>
[
new("Bestellungen", "Orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"),
new("Bestellungen", "Orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", $"EKKO im Zeitraum {FilterLabel}", $"EKKO in period {FilterLabel}"),
new("Lieferanten", "Suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "aus EKKO-Liveprobe", "from EKKO live sample"),
new("Offener Wert", "Open value", _liveState.EketLoaded ? FormatChf(_liveState.OpenValueSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.OpenValue)), _liveState.EketLoaded ? "aus SAP Terminen/Positionen" : "Simulation bis EKET liefert", _liveState.EketLoaded ? "from SAP schedules/items" : "simulation until EKET delivers"),
new("Offene Menge", "Open quantity", _liveState.EketLoaded ? _liveState.OpenQuantitySample.ToString("N0") : Purchasing3dBaseRows.Sum(x => x.OpenQuantity).ToString("N0"), _liveState.EketLoaded ? "aus SAP Terminen" : "Simulation bis EKET liefert", _liveState.EketLoaded ? "from SAP schedules" : "simulation until EKET delivers")
@@ -700,7 +780,7 @@
private IReadOnlyList<PurchasingSectionKpi> SupplierKpis =>
[
new("Aktive Lieferanten", "Active suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"),
new("Aktive Lieferanten", "Active suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", $"EKKO im Zeitraum {FilterLabel}", $"EKKO in period {FilterLabel}"),
new("Performance Score", "Performance score", $"{Purchasing3dBaseRows.Average(x => x.SupplierScore):N1}%", "Simulation bis Bewertungsdaten kommen", "simulation until rating data arrives"),
new("Preisindikator", "Price indicator", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : "wartet", "Netwr CHF/Stk braucht EKPO", "Netwr CHF/unit needs EKPO"),
new("Qualitaet", "Quality", "offen", "Reklamationsquelle noch nicht angebunden", "claim source not connected yet")
@@ -1096,6 +1176,10 @@
new("openValue", "Offener Bestellwert", "Open order value", "CHF"),
new("openQuantity", "Offene Menge", "Open quantity", "Qty"),
new("contractValue", "Kontrakt-Restwert", "Contract remaining value", "CHF"),
new("deliveryRisk", "Liefertermin-Risiko", "Delivery due-date risk", "CHF"),
new("priceVariance", "Preisabweichung", "Price variance", "CHF"),
new("spendConcentration", "Spend-Konzentration", "Spend concentration", "CHF"),
new("dataQuality", "Datenqualitaet", "Data quality", "Issues"),
new("supplierScore", "Lieferantenperformance", "Supplier performance", "%")
];
@@ -1133,6 +1217,152 @@
private IReadOnlyList<PurchasingSectionChartRow> SupplierChartRows
=> BuildPurchasingChartRows(x => x.SupplierScore, value => $"{value:N1}%");
private IReadOnlyList<PurchasingIdeaAnalysisRow> SelectedIdeaRows => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => _liveState.DeliveryRiskRows,
"ideen/preisabweichung" => _liveState.PriceVarianceRows,
"ideen/spend-konzentration" => _liveState.SpendConcentrationRows,
"ideen/datenqualitaet" => _liveState.DataQualityRows,
_ => []
};
private IReadOnlyList<PurchasingSectionChartRow> SelectedIdeaChartRows => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => BuildLiveChartRows(_liveState.DeliveryRiskChartRows, FormatChf),
"ideen/preisabweichung" => BuildLiveChartRows(_liveState.PriceVarianceChartRows, FormatChf),
"ideen/spend-konzentration" => BuildLiveChartRows(_liveState.SpendConcentrationChartRows, FormatChf),
"ideen/datenqualitaet" => BuildLiveChartRows(_liveState.DataQualityChartRows, value => value.ToString("N0")),
_ => []
};
private IReadOnlyList<PurchasingSectionKpi> SelectedIdeaKpis => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" =>
[
new("Ueberfaellig", "Overdue", FormatChartValue("Ueberfaellig", _liveState.DeliveryRiskChartRows, FormatChf), "offener Wert", "open value"),
new("0-7 Tage", "0-7 days", FormatChartValue("0-7 Tage", _liveState.DeliveryRiskChartRows, FormatChf), "kurzfristig faellig", "due short-term"),
new("Hotlist", "Hotlist", _liveState.DeliveryRiskRows.Count.ToString("N0"), "Lieferant / Artikel", "supplier / article"),
new("Datenbasis", "Data basis", _liveState.EketLoaded ? "EKET Cache" : "-", "offene Mengen", "open quantities")
],
"ideen/preisabweichung" =>
[
new("Preissteigerungen", "Price increases", _liveState.PriceVarianceRows.Count.ToString("N0"), "2026 gegen 2025", "2026 vs 2025"),
new("Top Wirkung", "Top impact", _liveState.PriceVarianceChartRows.Count > 0 ? FormatChf(_liveState.PriceVarianceChartRows.Max(x => x.Value)) : "-", "CHF Effekt", "CHF effect"),
new("Lieferanten", "Suppliers", _liveState.PriceVarianceChartRows.Count.ToString("N0"), "mit Abweichung", "with variance"),
new("Datenbasis", "Data basis", _liveState.EkpoLoaded ? "EKPO + EKKO" : "-", "Stueckpreis", "unit price")
],
"ideen/spend-konzentration" =>
[
new("Top 10 Spend", "Top 10 spend", FormatChf(_liveState.SpendConcentrationChartRows.Sum(x => x.Value)), "Lieferanten-Pareto", "supplier pareto"),
new("Top Anteil", "Top share", FormatTopShare(), "vom Gesamtspend", "of total spend"),
new("Lieferanten", "Suppliers", _liveState.SupplierCount.ToString("N0"), "EKKO Cache", "EKKO cache"),
new("Datenbasis", "Data basis", _liveState.EkpoLoaded ? "EKPO + EKKO" : "-", "Spend netto", "net spend")
],
"ideen/datenqualitaet" =>
[
new("Pruefungen", "Checks", _liveState.DataQualityRows.Count.ToString("N0"), "Pflichtfelder", "required fields"),
new("Fehler gesamt", "Total issues", _liveState.DataQualityChartRows.Sum(x => x.Value).ToString("N0"), "Cache-Zeilen", "cache rows"),
new("EKPO", "EKPO", _liveState.PositionSampleCount.ToString("N0"), "Positionen", "items"),
new("EKKO", "EKKO", _liveState.PurchaseOrderCount.ToString("N0"), "Bestellkoepfe", "headers")
],
_ => []
};
private IReadOnlyList<PurchasingSectionStatusRow> SelectedIdeaStatusRows =>
[
BuildStatus("Cache", "Cache", _liveState.UsesCache, _liveState.UsesCache ? _liveState.CacheStatus : "leer"),
BuildStatus("EKKO", "EKKO", _liveState.EkkoLoaded, _liveState.PurchaseOrderCount.ToString("N0")),
BuildStatus("EKPO", "EKPO", _liveState.EkpoLoaded, _liveState.PositionSampleCount.ToString("N0")),
BuildStatus("EKET", "EKET", _liveState.EketLoaded, _liveState.ScheduleSampleCount.ToString("N0"))
];
private IReadOnlyList<PurchasingSectionDetailRow> SelectedIdeaDetailRows => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" =>
[
new("Faelligkeits-Buckets", "Due-date buckets", $"{_liveState.DeliveryRiskChartRows.Count:N0}", "EKET.Eindt", "SAP live"),
new("Offener Wert", "Open value", FormatChf(_liveState.OpenValueSample), "EKET/EKPO", "SAP live"),
new("Hotlist", "Hotlist", $"{_liveState.DeliveryRiskRows.Count:N0}", "Lieferant / Artikel", "SAP live"),
new("Berechnung", "Calculation", "Menge - WEMNG", "EKET", "SAP live")
],
"ideen/preisabweichung" =>
[
new("Vergleich", "Comparison", "2026 vs 2025", "EKKO.Bedat", "SAP live"),
new("Preis", "Price", "NETWR / MENGE", "EKPO", "SAP live"),
new("CHF Wirkung", "CHF impact", FormatChf(_liveState.PriceVarianceChartRows.Sum(x => x.Value)), "Preisdelta * Menge", "SAP live"),
new("Hotlist", "Hotlist", $"{_liveState.PriceVarianceRows.Count:N0}", "Lieferant / Artikel", "SAP live")
],
"ideen/spend-konzentration" =>
[
new("Gesamtspend", "Total spend", FormatChf(_liveState.SpendChfSample), "EKPO.Netwr", "SAP live"),
new("Top Lieferanten", "Top suppliers", $"{_liveState.SpendConcentrationRows.Count:N0}", "EKKO.Lifnr", "SAP live"),
new("Top Anteil", "Top share", FormatTopShare(), "Pareto", "SAP live"),
new("Warengruppen", "Material groups", TopMaterialGroupLabel, "EKPO.Matkl", "SAP live")
],
"ideen/datenqualitaet" =>
[
new("Fehlender Lieferant", "Missing supplier", FormatChartValue("fehlender Lieferant", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKKO.Lifnr", "SAP live"),
new("Fehlende Warengruppe", "Missing material group", FormatChartValue("fehlende Warengruppe", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Matkl", "SAP live"),
new("Nullmenge", "Zero quantity", FormatChartValue("Nullmenge", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Menge", "SAP live"),
new("Nullwert", "Zero value", FormatChartValue("Nullwert", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Netwr", "SAP live")
],
_ => []
};
private string SelectedIdeaAnalysisTitleDe => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Liefertermin-Risiko produktiv",
"ideen/preisabweichung" => "Preisabweichung produktiv",
"ideen/spend-konzentration" => "Spend-Konzentration produktiv",
"ideen/datenqualitaet" => "Datenqualitaet produktiv",
_ => "Einkauf Analyse"
};
private string SelectedIdeaAnalysisTitleEn => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Delivery due-date risk productive",
"ideen/preisabweichung" => "Price variance productive",
"ideen/spend-konzentration" => "Spend concentration productive",
"ideen/datenqualitaet" => "Data quality productive",
_ => "Purchasing analysis"
};
private string SelectedIdeaAnalysisDescriptionDe => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Offene EKET-Mengen werden bewertet und nach Faelligkeit, Lieferant und Artikel priorisiert.",
"ideen/preisabweichung" => "Netto-Stueckpreise werden 2026 gegen 2025 verglichen und nach CHF-Wirkung sortiert.",
"ideen/spend-konzentration" => "Lieferantenspend wird als Pareto ausgewertet, um Abhaengigkeit und Buendelungspotenzial zu zeigen.",
"ideen/datenqualitaet" => "Pflichtfelder und Nullwerte im Einkauf-Cache werden als Qualitaetsampel gezaehlt.",
_ => "Echte Analyse aus dem Einkauf-Cache."
};
private string SelectedIdeaAnalysisDescriptionEn => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Open EKET quantities are valued and prioritised by due date, supplier and article.",
"ideen/preisabweichung" => "Net unit prices are compared 2026 against 2025 and sorted by CHF impact.",
"ideen/spend-konzentration" => "Supplier spend is evaluated as pareto to show dependency and bundling potential.",
"ideen/datenqualitaet" => "Required fields and zero values in the purchasing cache are counted as quality indicators.",
_ => "Real analysis from the purchasing cache."
};
private string SelectedIdeaChartTitleDe => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Offener Wert nach Faelligkeit",
"ideen/preisabweichung" => "Preissteigerungswirkung nach Lieferant",
"ideen/spend-konzentration" => "Top Lieferanten Spend",
"ideen/datenqualitaet" => "Datenqualitaetsfehler",
_ => "Analyse"
};
private string SelectedIdeaChartTitleEn => CurrentPurchasingPage switch
{
"ideen/liefertermin-risiko" => "Open value by due date",
"ideen/preisabweichung" => "Price increase impact by supplier",
"ideen/spend-konzentration" => "Top supplier spend",
"ideen/datenqualitaet" => "Data quality issues",
_ => "Analysis"
};
private IReadOnlyList<PurchasingSectionStatusRow> SpendStatusRows =>
[
BuildStatus("EKKO Bestellkoepfe", "EKKO purchase headers", _liveState.EkkoLoaded, _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-"),
@@ -1171,7 +1401,7 @@
private IReadOnlyList<PurchasingSectionDetailRow> OpenOrderDetailRows =>
[
new("Bestellungen seit Jahresbeginn", "Orders since start of year", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", "EKKOSet.Bedat", _liveState.EkkoLoaded ? "SAP live" : "Wartet auf SAP"),
new("Bestellungen im Zeitraum", "Orders in period", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", "EKKOSet.Bedat", _liveState.EkkoLoaded ? "SAP live" : "Wartet auf SAP"),
new("Lieferanten mit Bestellung", "Suppliers with order", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "EKKOSet.Lifnr", _liveState.EkkoLoaded ? "SAP live" : "Wartet auf SAP"),
new("Offener Bestellwert", "Open order value", _liveState.EketLoaded ? FormatChf(_liveState.OpenValueSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.OpenValue)), "EKPO/EKET", _liveState.EketLoaded ? "SAP live" : "Simulation"),
new("Faelligkeiten", "Due dates", _liveState.EketLoaded ? "SAP live" : "wartet auf EKET", "eketSet.Eindt", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP")
@@ -1196,7 +1426,7 @@
protected override async Task OnInitializedAsync()
{
Navigation.LocationChanged += HandleLocationChanged;
_liveState = await PurchasingDashboardService.LoadAsync();
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
_refreshStatus = await PurchasingDataRefreshService.GetStatusAsync();
_liveLoading = false;
}
@@ -1226,6 +1456,77 @@
private string T(string german, string english) => UiText.Text(german, english);
private static string FormatChf(double value) => $"CHF {value:N0}";
private static string FormatChf(decimal value) => $"CHF {value:N0}";
private static string BuildDefaultFromMonth()
{
var today = DateTime.Today;
return $"{today.Year - 2}-01";
}
private static string BuildDefaultToMonth()
=> DateTime.Today.ToString("yyyy-MM", CultureInfo.InvariantCulture);
private PurchasingDashboardFilter BuildCurrentFilter()
{
var from = ParseMonth(_filterFromMonth, new DateTime(DateTime.Today.Year - 2, 1, 1));
var toMonth = ParseMonth(_filterToMonth, DateTime.Today);
var to = new DateTime(toMonth.Year, toMonth.Month, DateTime.DaysInMonth(toMonth.Year, toMonth.Month));
if (to < from)
from = new DateTime(to.Year, 1, 1);
if (to > DateTime.Today)
to = DateTime.Today;
return new PurchasingDashboardFilter(from, to);
}
private static DateTime ParseMonth(string value, DateTime fallback)
=> DateTime.TryParseExact(value, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
? new DateTime(parsed.Year, parsed.Month, 1)
: fallback;
private void SetFilterFromMonth(ChangeEventArgs args)
=> _filterFromMonth = Convert.ToString(args.Value, CultureInfo.InvariantCulture) ?? _filterFromMonth;
private void SetFilterToMonth(ChangeEventArgs args)
=> _filterToMonth = Convert.ToString(args.Value, CultureInfo.InvariantCulture) ?? _filterToMonth;
private async Task ApplyPurchasingFilterAsync()
{
_liveLoading = true;
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
_liveLoading = false;
if (CurrentPurchasingPage == "3d")
await RenderPurchasing3dAsync();
}
private async Task SetLastThreeYearsAsync()
{
_filterFromMonth = BuildDefaultFromMonth();
_filterToMonth = BuildDefaultToMonth();
await ApplyPurchasingFilterAsync();
}
private static string FormatChartValue(string label, IReadOnlyList<PurchasingLiveChartPoint> rows, Func<decimal, string> formatter)
=> formatter(rows.FirstOrDefault(row => row.Label.Equals(label, StringComparison.OrdinalIgnoreCase))?.Value ?? 0m);
private string FormatTopShare()
{
if (_liveState.SpendChfSample <= 0)
return "-";
var share = _liveState.SpendConcentrationChartRows.Sum(row => row.Value) / _liveState.SpendChfSample * 100m;
return $"{share:N1}%";
}
private static Color ResolveAnalysisSeverityColor(string severity)
=> severity switch
{
"High" => Color.Error,
"Medium" => Color.Warning,
"Low" => Color.Success,
_ => Color.Info
};
private string TopSpendLabel => _liveState.EkpoLoaded && !string.IsNullOrWhiteSpace(_liveState.TopSupplierLabel) ? _liveState.TopSupplierLabel : BuildTopLabel(x => x.Spend, FormatChf);
private string TopMaterialGroupLabel => _liveState.EkpoLoaded && !string.IsNullOrWhiteSpace(_liveState.TopMaterialGroupLabel) ? _liveState.TopMaterialGroupLabel : BuildTopLabel(x => x.Spend, FormatChf, "Warengruppe");
private string TopArticleLabel => _liveState.EkpoLoaded && !string.IsNullOrWhiteSpace(_liveState.TopArticleLabel) ? _liveState.TopArticleLabel : BuildTopLabel(x => x.Spend, FormatChf, "Artikel");
@@ -1257,7 +1558,7 @@
try
{
_refreshStatus = await PurchasingDataRefreshService.RunFullLoadAsync();
_liveState = await PurchasingDashboardService.LoadAsync();
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
}
finally
{
@@ -1271,7 +1572,7 @@
try
{
_refreshStatus = await PurchasingDataRefreshService.RunDeltaAsync();
_liveState = await PurchasingDashboardService.LoadAsync();
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
}
finally
{
@@ -1396,7 +1697,23 @@
}
private IReadOnlyList<object> BuildPurchasing3dRows()
=> Purchasing3dBaseRows
{
var liveRows = ResolvePurchasing3dLiveRows();
if (liveRows.Count > 0)
{
var year = _liveState.PeriodTo?.Year ?? DateTime.Today.Year;
return liveRows
.Select(row => new
{
country = row.Label,
year,
value = (double)row.Value
})
.Cast<object>()
.ToList();
}
return Purchasing3dBaseRows
.Select(row => new
{
country = row.Axis,
@@ -1405,6 +1722,19 @@
})
.Cast<object>()
.ToList();
}
private IReadOnlyList<PurchasingLiveChartPoint> ResolvePurchasing3dLiveRows() => _purchasing3dIndicator switch
{
"spend" => _liveState.SpendChartRows,
"openValue" => _liveState.OpenValueChartRows,
"contractValue" => _liveState.ContractChartRows,
"deliveryRisk" => _liveState.DeliveryRiskChartRows,
"priceVariance" => _liveState.PriceVarianceChartRows,
"spendConcentration" => _liveState.SpendConcentrationChartRows,
"dataQuality" => _liveState.DataQualityChartRows,
_ => []
};
private double ResolvePurchasing3dValue(Purchasing3dBaseRow row) => _purchasing3dIndicator switch
{
@@ -1415,7 +1745,7 @@
_ => row.Spend
};
private bool ScenarioAffectsPurchasingValue => _purchasing3dIndicator is "spend" or "openValue" or "contractValue";
private bool ScenarioAffectsPurchasingValue => _purchasing3dIndicator is "spend" or "openValue" or "contractValue" or "deliveryRisk" or "priceVariance" or "spendConcentration";
private string ResolvePurchasing3dIndicatorLabel()
=> T(
@@ -1427,7 +1757,10 @@
if (!ScenarioAffectsPurchasingValue)
return T("nicht auf diesen Indikator angewendet", "not applied to this indicator");
var baseTotal = Purchasing3dBaseRows.Sum(ResolvePurchasing3dValue);
var liveRows = ResolvePurchasing3dLiveRows();
var baseTotal = liveRows.Count > 0
? (double)liveRows.Sum(row => row.Value)
: Purchasing3dBaseRows.Sum(ResolvePurchasing3dValue);
var delta = baseTotal * _purchasing3dFactor - baseTotal;
return $"{delta:N0} {Purchasing3dIndicators.First(x => x.Key == _purchasing3dIndicator).Unit}";
}
@@ -304,13 +304,20 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
cmd.ExecuteNonQuery();
}
using var ekpoIndex = conn.CreateCommand();
ekpoIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);";
ekpoIndex.ExecuteNonQuery();
using var eketDateIndex = conn.CreateCommand();
eketDateIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);";
eketDateIndex.ExecuteNonQuery();
foreach (var indexSql in new[]
{
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Bedat ON PurchasingEkkoCache (Bedat);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Lifnr ON PurchasingEkkoCache (Lifnr);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Ebeln ON PurchasingEkpoCache (Ebeln);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_EbelnEbelp ON PurchasingEketCache (Ebeln, Ebelp);"
})
{
using var indexCommand = conn.CreateCommand();
indexCommand.CommandText = indexSql;
indexCommand.ExecuteNonQuery();
}
}
private static void EnsureSapSourceTable(AppDbContext db)
@@ -2,7 +2,12 @@ namespace TrafagSalesExporter.Services;
public interface IPurchasingDashboardService
{
Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default);
Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default);
}
public sealed record PurchasingDashboardFilter(DateTime FromDate, DateTime ToDate)
{
public string Label => $"{FromDate:yyyy-MM-dd} bis {ToDate:yyyy-MM-dd}";
}
public sealed class PurchasingDashboardLiveState
@@ -19,6 +24,8 @@ public sealed class PurchasingDashboardLiveState
public bool UsesCache { get; set; }
public string CacheStatus { get; set; } = string.Empty;
public DateTime? CacheCompletedAtUtc { get; set; }
public DateTime? PeriodFrom { get; set; }
public DateTime? PeriodTo { get; set; }
public decimal SpendChfSample { get; set; }
public decimal OpenQuantitySample { get; set; }
public decimal OpenValueSample { get; set; }
@@ -29,7 +36,16 @@ public sealed class PurchasingDashboardLiveState
public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> DeliveryRiskChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> PriceVarianceChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> SpendConcentrationChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> DataQualityChartRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> DeliveryRiskRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> PriceVarianceRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> SpendConcentrationRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> DataQualityRows { get; set; } = [];
public string Message { get; set; } = string.Empty;
}
public sealed record PurchasingLiveChartPoint(string Label, decimal Value);
public sealed record PurchasingIdeaAnalysisRow(string Label, string Value, string Detail, string Severity);
@@ -17,14 +17,23 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
_dbFactory = dbFactory;
}
public async Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default)
private static PurchasingDashboardFilter BuildDefaultFilter()
{
var today = DateTime.Today;
return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today);
}
public async Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default)
{
var state = new PurchasingDashboardLiveState();
filter ??= BuildDefaultFilter();
state.PeriodFrom = filter.FromDate;
state.PeriodTo = filter.ToDate;
try
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
if (await TryLoadCacheStateAsync(db, state, cancellationToken))
if (await TryLoadCacheStateAsync(db, state, filter, cancellationToken))
return state;
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken);
@@ -108,16 +117,22 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
return state;
}
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, CancellationToken cancellationToken)
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, PurchasingDashboardFilter filter, CancellationToken cancellationToken)
{
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
var ekkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
var ekpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
var eketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
if (ekkoRows <= 0 || ekpoRows <= 0 || eketRows <= 0)
var from = filter.FromDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var to = filter.ToDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var ekkoPeriod = $"Bedat >= '{from}' AND Bedat <= '{to}'";
var joinedEkkoPeriod = $"k.Bedat >= '{from}' AND k.Bedat <= '{to}'";
var eketPeriod = $"e.Eindt >= '{from}' AND e.Eindt <= '{to}'";
var cacheEkkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
var cacheEkpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
var cacheEketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
if (cacheEkkoRows <= 0 || cacheEkpoRows <= 0 || cacheEketRows <= 0)
return false;
var latestStatus = await ReadCacheStatusAsync(conn, cancellationToken);
@@ -126,47 +141,57 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
state.EkkoLoaded = true;
state.EkpoLoaded = true;
state.EketLoaded = true;
state.PurchaseOrderCount = ekkoRows;
state.PositionSampleCount = ekpoRows;
state.ScheduleSampleCount = eketRows;
state.SupplierCount = await ExecuteScalarIntAsync(conn, "SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '';", cancellationToken);
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, "SELECT MAX(Bedat) FROM PurchasingEkkoCache;", cancellationToken);
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, "SELECT COALESCE(SUM(CAST(Netwr AS REAL)), 0) FROM PurchasingEkpoCache WHERE Loekz = '';", cancellationToken);
state.OpenQuantitySample = await ExecuteScalarDecimalAsync(conn, "SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0)), 0) FROM PurchasingEketCache e;", cancellationToken);
state.PurchaseOrderCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
state.PositionSampleCount = await ExecuteScalarIntAsync(conn, $@"
SELECT COUNT(1)
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE {joinedEkkoPeriod};", cancellationToken);
state.ScheduleSampleCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
state.SupplierCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '' AND {ekkoPeriod};", cancellationToken);
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, $"SELECT MAX(Bedat) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, $@"
SELECT COALESCE(SUM(CAST(p.Netwr AS REAL)), 0)
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND {joinedEkkoPeriod};", cancellationToken);
state.OpenQuantitySample = await ExecuteScalarDecimalAsync(conn, $"SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0)), 0) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
state.OpenValueSample = await ExecuteScalarDecimalAsync(conn, @"
SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END), 0)
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = '';", cancellationToken);
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + ";", cancellationToken);
state.ContractValueSample = state.OpenValueSample;
state.TopSupplierLabel = await ExecuteTopLabelAsync(conn, @"
SELECT COALESCE(k.Lifnr, 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE p.Loekz = ''
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(k.Lifnr, 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 1;", "Lieferant", cancellationToken);
state.TopMaterialGroupLabel = await ExecuteTopLabelAsync(conn, @"
SELECT COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache
WHERE Loekz = ''
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe')
ORDER BY Value DESC
LIMIT 1;", "Warengruppe", cancellationToken);
state.TopArticleLabel = await ExecuteTopLabelAsync(conn, @"
SELECT COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache
WHERE Loekz = ''
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel')
ORDER BY Value DESC
LIMIT 1;", "Artikel", cancellationToken);
state.SpendChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'Lief. ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE p.Loekz = ''
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
@@ -176,17 +201,164 @@ SELECT COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label,
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) AS Value
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = ''
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @"
GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
ORDER BY Label
LIMIT 6;", cancellationToken);
state.ContractChartRows = state.OpenValueChartRows.ToList();
await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken);
state.CacheStatus = latestStatus.Status;
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
state.Message = $"Einkauf Cache geladen: EKKO={ekkoRows:N0}, EKPO={ekpoRows:N0}, EKET={eketRows:N0}. {latestStatus.Message}";
state.Message = $"Einkauf Cache geladen fuer {filter.Label}: EKKO={state.PurchaseOrderCount:N0}, EKPO={state.PositionSampleCount:N0}, EKET={state.ScheduleSampleCount:N0}. {latestStatus.Message}";
return true;
}
private static async Task ApplyIdeaAnalyticsAsync(SqliteConnection conn, PurchasingDashboardLiveState state, string joinedEkkoPeriod, string eketPeriod, CancellationToken cancellationToken)
{
state.DeliveryRiskChartRows = await ExecuteChartRowsAsync(conn, @"
WITH open_rows AS (
SELECT
CASE
WHEN date(e.Eindt) < date('now', 'localtime') THEN 'Ueberfaellig'
WHEN date(e.Eindt) <= date('now', 'localtime', '+7 day') THEN '0-7 Tage'
WHEN date(e.Eindt) <= date('now', 'localtime', '+30 day') THEN '8-30 Tage'
ELSE 'Spaeter'
END AS Label,
MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END AS OpenValue
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
)
SELECT Label, SUM(OpenValue) AS Value
FROM open_rows
GROUP BY Label
ORDER BY CASE Label WHEN 'Ueberfaellig' THEN 1 WHEN '0-7 Tage' THEN 2 WHEN '8-30 Tage' THEN 3 ELSE 4 END;", cancellationToken);
state.DeliveryRiskRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') || ' / ' || COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Label,
'CHF ' || printf('%,.0f', SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END)) AS Value,
'Faellig ' || COALESCE(MIN(e.Eindt), 'ohne Termin') AS Detail,
CASE WHEN MIN(date(e.Eindt)) < date('now', 'localtime') THEN 'High' ELSE 'Medium' END AS Severity
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = e.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant'), COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel')
ORDER BY SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) DESC
LIMIT 10;", cancellationToken);
state.PriceVarianceRows = await ExecuteAnalysisRowsAsync(conn, @"
WITH priced AS (
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Supplier,
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
substr(k.Bedat, 1, 4) AS Year,
SUM(CAST(p.Netwr AS REAL)) AS Value,
SUM(CAST(p.Menge AS REAL)) AS Quantity
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
GROUP BY Supplier, Article, Year
),
current_year AS (
SELECT Supplier, Article, Value, Quantity, Value / Quantity AS UnitPrice
FROM priced
WHERE Year = '2026'
),
previous_year AS (
SELECT Supplier, Article, Value / Quantity AS UnitPrice
FROM priced
WHERE Year = '2025' AND Quantity > 0
)
SELECT
c.Supplier || ' / ' || c.Article AS Label,
printf('%.1f%%', ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) * 100.0) AS Value,
'Wirkung CHF ' || printf('%,.0f', (c.UnitPrice - p.UnitPrice) * c.Quantity) AS Detail,
CASE WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.10 THEN 'High'
WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.03 THEN 'Medium'
ELSE 'Low' END AS Severity
FROM current_year c
JOIN previous_year p ON p.Supplier = c.Supplier AND p.Article = c.Article
WHERE p.UnitPrice > 0 AND c.UnitPrice > p.UnitPrice
ORDER BY (c.UnitPrice - p.UnitPrice) * c.Quantity DESC
LIMIT 10;", cancellationToken);
state.PriceVarianceChartRows = await ExecuteChartRowsAsync(conn, @"
WITH priced AS (
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Supplier,
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
substr(k.Bedat, 1, 4) AS Year,
SUM(CAST(p.Netwr AS REAL)) AS Value,
SUM(CAST(p.Menge AS REAL)) AS Quantity
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
GROUP BY Supplier, Article, Year
),
delta AS (
SELECT c.Supplier, (c.Value / c.Quantity - p.Value / p.Quantity) * c.Quantity AS Impact
FROM priced c
JOIN priced p ON p.Supplier = c.Supplier AND p.Article = c.Article
WHERE c.Year = '2026' AND p.Year = '2025' AND p.Quantity > 0 AND c.Quantity > 0 AND c.Value / c.Quantity > p.Value / p.Quantity
)
SELECT Supplier, SUM(Impact) AS Value
FROM delta
GROUP BY Supplier
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
state.SpendConcentrationChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 10;", cancellationToken);
var totalSpend = state.SpendChfSample <= 0 ? 1 : state.SpendChfSample;
var concentrationRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label,
'CHF ' || printf('%,.0f', SUM(CAST(p.Netwr AS REAL))) AS Value,
COUNT(DISTINCT COALESCE(NULLIF(p.Matkl, ''), 'ohne Warengruppe')) || ' Warengruppen' AS Detail,
CASE WHEN SUM(CAST(p.Netwr AS REAL)) > 1000000 THEN 'High'
WHEN SUM(CAST(p.Netwr AS REAL)) > 250000 THEN 'Medium'
ELSE 'Low' END AS Severity
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY SUM(CAST(p.Netwr AS REAL)) DESC
LIMIT 10;", cancellationToken);
state.SpendConcentrationRows = concentrationRows
.Select((row, index) => row with { Detail = $"{row.Detail} | Rang {index + 1} | Anteil {CalculateSupplierShare(state.SpendConcentrationChartRows, row.Label, totalSpend):N1}%" })
.ToList();
state.DataQualityChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'fehlender Lieferant' AS Label, COUNT(*) AS Value FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
UNION ALL
SELECT 'fehlende Warengruppe', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
UNION ALL
SELECT 'fehlender Artikel/Text', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
UNION ALL
SELECT 'Nullmenge', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
UNION ALL
SELECT 'Nullwert', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
state.DataQualityRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT 'Fehlender Lieferant' AS Label, COUNT(*) || ' Belege' AS Value, 'EKKO.Lifnr leer' AS Detail, CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END AS Severity FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
UNION ALL
SELECT 'Fehlende Warengruppe', COUNT(*) || ' Positionen', 'EKPO.Matkl leer', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
UNION ALL
SELECT 'Fehlender Artikel/Text', COUNT(*) || ' Positionen', 'EKPO.Matnr und Txz01 leer', CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
UNION ALL
SELECT 'Nullmenge', COUNT(*) || ' Positionen', 'EKPO.Menge = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
UNION ALL
SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
}
private static void ApplyEkpoMetrics(
PurchasingDashboardLiveState state,
List<Dictionary<string, object?>> ekkoRows,
@@ -378,6 +550,32 @@ LIMIT 6;", cancellationToken);
return rows;
}
private static async Task<List<PurchasingIdeaAnalysisRow>> ExecuteAnalysisRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
var rows = new List<PurchasingIdeaAnalysisRow>();
await using var command = conn.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
rows.Add(new PurchasingIdeaAnalysisRow(
reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(3) ? string.Empty : reader.GetString(3)));
}
return rows;
}
private static decimal CalculateSupplierShare(IReadOnlyList<PurchasingLiveChartPoint> rows, string supplier, decimal totalSpend)
{
var value = rows
.Where(row => row.Label.Equals(supplier, StringComparison.OrdinalIgnoreCase) || row.Label.Equals($"Lieferant {supplier}", StringComparison.OrdinalIgnoreCase))
.Sum(row => row.Value);
return totalSpend <= 0 ? 0 : value / totalSpend * 100m;
}
private static async Task<(string Status, DateTime? CompletedAtUtc, string Message)> ReadCacheStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();