diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor index 434b8a4..4b9268d 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -61,6 +61,35 @@ + + +
+ @T("Zeitraum", "Period") + + @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.") + +
+ @FilterLabel +
+
+ + + + @T("Anwenden", "Apply") + + + @T("Letzte 3 Jahre", "Last 3 years") + +
+
+ @foreach (var card in KpiCards) { @@ -390,6 +419,53 @@ } + else + { + + + + + + +
+ @T("Detail-Hotlist", "Detail hotlist") + + @T("Direkt aus dem Einkauf-Cache berechnet, keine Simulation.", + "Calculated directly from the purchasing cache, no simulation.") + +
+ @SelectedIdeaRows.Count @T("Zeilen", "rows") +
+ + + @T("Objekt", "Object") + @T("Wert", "Value") + @T("Detail", "Detail") + @T("Ampel", "Status") + + + @context.Label + @context.Value + @context.Detail + + + @context.Severity + + + + +
+
+ }
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 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 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 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 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 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 SupplierChartRows => BuildPurchasingChartRows(x => x.SupplierScore, value => $"{value:N1}%"); + private IReadOnlyList SelectedIdeaRows => CurrentPurchasingPage switch + { + "ideen/liefertermin-risiko" => _liveState.DeliveryRiskRows, + "ideen/preisabweichung" => _liveState.PriceVarianceRows, + "ideen/spend-konzentration" => _liveState.SpendConcentrationRows, + "ideen/datenqualitaet" => _liveState.DataQualityRows, + _ => [] + }; + + private IReadOnlyList 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 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 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 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 SpendStatusRows => [ BuildStatus("EKKO Bestellkoepfe", "EKKO purchase headers", _liveState.EkkoLoaded, _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-"), @@ -1171,7 +1401,7 @@ private IReadOnlyList 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 rows, Func 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 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() + .ToList(); + } + + return Purchasing3dBaseRows .Select(row => new { country = row.Axis, @@ -1405,6 +1722,19 @@ }) .Cast() .ToList(); + } + + private IReadOnlyList 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}"; } diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index cfebdcb..010ced0 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -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) diff --git a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs index c4f043b..32f4d4e 100644 --- a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs @@ -2,7 +2,12 @@ namespace TrafagSalesExporter.Services; public interface IPurchasingDashboardService { - Task LoadAsync(CancellationToken cancellationToken = default); + Task 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 SpendChartRows { get; set; } = []; public List OpenValueChartRows { get; set; } = []; public List ContractChartRows { get; set; } = []; + public List DeliveryRiskChartRows { get; set; } = []; + public List PriceVarianceChartRows { get; set; } = []; + public List SpendConcentrationChartRows { get; set; } = []; + public List DataQualityChartRows { get; set; } = []; + public List DeliveryRiskRows { get; set; } = []; + public List PriceVarianceRows { get; set; } = []; + public List SpendConcentrationRows { get; set; } = []; + public List 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); diff --git a/TrafagSalesExporter/Services/PurchasingDashboardService.cs b/TrafagSalesExporter/Services/PurchasingDashboardService.cs index b218b2b..2b7cacd 100644 --- a/TrafagSalesExporter/Services/PurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/PurchasingDashboardService.cs @@ -17,14 +17,23 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService _dbFactory = dbFactory; } - public async Task 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 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 TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, CancellationToken cancellationToken) + private static async Task 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> ekkoRows, @@ -378,6 +550,32 @@ LIMIT 6;", cancellationToken); return rows; } + private static async Task> ExecuteAnalysisRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken) + { + var rows = new List(); + 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 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();