diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor index 5e8b899..197c1b5 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -139,10 +139,10 @@ case "kontrakte": x.ContractValue)), _liveState.EketLoaded ? "aus SAP Einteilungen" : "Simulation bis EKET liefert", _liveState.EketLoaded ? "from SAP schedules" : "simulation until EKET delivers"), new("Einteilungen", "Schedules", _liveState.EketLoaded ? _liveState.ScheduleSampleCount.ToString("N0") : "-", "EKET-Probe", "EKET sample"), - new("Abrufquote", "Consumption", "offen", "braucht Kontrakt- und Abrufdaten", "needs contract and call-off data"), + new("Top Verpflichtung", "Top commitment", TopCommitmentLabel, "Lieferant / Artikel / Monat", "supplier / article / month"), new("Faelligkeit", "Due date", _liveState.LatestOrderDate?.ToString("yyyy-MM-dd") ?? "-", "letztes bekanntes EKKO-Datum", "latest known EKKO date") ]; @@ -1396,7 +1396,7 @@ new("Spend nach Jahr", "Spend by year", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.Spend)), "EKKOSet.Bedat Jahr", _liveState.EkpoLoaded ? "SAP live" : "Simulation"), new("Spend nach Lieferant", "Spend by supplier", TopSpendLabel, "Data.Name / EKKOSet.Lifnr", _liveState.EkpoLoaded ? "SAP live" : "Simulation"), new("Spend nach Warengruppe", "Spend by material group", TopMaterialGroupLabel, "Data (2).Warengruppe", _liveState.EkpoLoaded ? "SAP live" : "Simulation"), - new("Spend nach Artikel", "Spend by article", TopArticleLabel, "EKPOSet.Matnr / Txz01", _liveState.EkpoLoaded ? "SAP live" : "Wartet auf SAP") + new("Spend nach Artikel", "Spend by article", TopArticleLabel, "Artikel / Lieferant / Monat", _liveState.EkpoLoaded ? "SAP live" : "Wartet auf SAP") ]; private IReadOnlyList OpenOrderDetailRows => @@ -1409,10 +1409,10 @@ private IReadOnlyList ContractDetailRows => [ - new("Restverpflichtung", "Remaining commitment", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), "EKPO/EKET", _liveState.EketLoaded ? "SAP live" : "Simulation"), - new("Mengenkontrakte", "Quantity contracts", _liveState.EkpoLoaded ? "SAP live" : "wartet auf EKPO", "EKPOSet.Menge", _liveState.EkpoLoaded ? "SAP live" : "Wartet auf SAP"), - new("Abrufquote", "Consumption rate", "offen", "Kontraktmenge / Abrufmenge", "Wartet auf SAP"), - new("Faellige Verpflichtungen", "Due commitments", _liveState.EketLoaded ? "SAP live" : "wartet auf EKET", "eketSet", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP") + new("Restverpflichtung", "Remaining commitment", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), "EKET offen * EKPO Stueckwert", _liveState.EketLoaded ? "SAP live" : "Simulation"), + new("Groesste Verpflichtung", "Largest commitment", TopCommitmentLabel, "Lieferant / Artikel / Faelligkeit", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP"), + new("Faelligkeitsverlauf", "Due-date trend", _liveState.OpenValueChartRows.Count > 0 ? $"{_liveState.OpenValueChartRows.Count:N0} Monate" : "-", "eketSet.Eindt", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP"), + new("Namensmapping", "Name mapping", _liveState.EketLoaded ? "Lifnr sichtbar, Name offen" : "wartet", "Data/LFA1 fehlt im OData", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP") ]; private IReadOnlyList SupplierDetailRows => @@ -1530,6 +1530,7 @@ 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"); + private string TopCommitmentLabel => _liveState.EketLoaded && !string.IsNullOrWhiteSpace(_liveState.TopCommitmentLabel) ? _liveState.TopCommitmentLabel : "-"; private string PurchasingStatusText => _liveLoading ? T("SAP-Einkaufsdaten werden geladen...", "Loading SAP purchasing data...") diff --git a/TrafagSalesExporter/Components/Pages/PurchasingSection.razor b/TrafagSalesExporter/Components/Pages/PurchasingSection.razor index 99041a3..f4ff3cb 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingSection.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingSection.razor @@ -202,6 +202,12 @@ text-align: right; } + .purchasing-bar-label { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.2; + } + @@media (max-width: 760px) { .purchasing-bar-row, .purchasing-status-row, diff --git a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs index 7719c1c..0349683 100644 --- a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs @@ -33,9 +33,11 @@ public sealed class PurchasingDashboardLiveState public string TopSupplierLabel { get; set; } = string.Empty; public string TopMaterialGroupLabel { get; set; } = string.Empty; public string TopArticleLabel { get; set; } = string.Empty; + public string TopCommitmentLabel { get; set; } = string.Empty; public List SpendChartRows { get; set; } = []; public List OpenValueChartRows { get; set; } = []; public List ContractChartRows { get; set; } = []; + public List CommitmentDetailChartRows { get; set; } = []; public List DeliveryRiskChartRows { get; set; } = []; public List PriceVarianceChartRows { get; set; } = []; public List SpendConcentrationChartRows { get; set; } = []; diff --git a/TrafagSalesExporter/Services/PurchasingDashboardService.cs b/TrafagSalesExporter/Services/PurchasingDashboardService.cs index ff33742..87fd4e1 100644 --- a/TrafagSalesExporter/Services/PurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/PurchasingDashboardService.cs @@ -23,6 +23,12 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today); } + private static string SupplierLabelSql(string lifnrExpression) + => $@"CASE + WHEN COALESCE(NULLIF({lifnrExpression}, ''), '') = '' THEN 'ohne Lieferant' + ELSE 'Lief. ' || {lifnrExpression} || ' (Name fehlt)' + END"; + public async Task LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default) { var state = new PurchasingDashboardLiveState(); @@ -100,7 +106,7 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService state.EketLoaded = eketRows.Count > 0; ApplyEkpoMetrics(state, ekkoRows, ekpoRows); - ApplyEketMetrics(state, ekpoRows, eketRows); + ApplyEketMetrics(state, ekkoRows, ekpoRows, eketRows); } state.Message = state.EkpoLoaded && state.EketLoaded @@ -164,7 +170,7 @@ LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp 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 +SELECT " + SupplierLabelSql("k.Lifnr") + @" 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 + @" @@ -180,15 +186,19 @@ 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 +SELECT + COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') || ' | ' || + " + SupplierLabelSql("k.Lifnr") + @" || ' | Monat ' || + COALESCE(substr(k.Bedat, 1, 7), 'ohne Datum') 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(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel') +GROUP BY COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel'), COALESCE(k.Lifnr, 'ohne Lieferant'), COALESCE(substr(k.Bedat, 1, 7), 'ohne Datum') ORDER BY Value DESC LIMIT 1;", "Artikel", cancellationToken); state.SpendChartRows = await ExecuteChartRowsAsync(conn, @" -SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value +SELECT " + SupplierLabelSql("k.Lifnr") + @" 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 + @" @@ -205,7 +215,26 @@ 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(); + state.CommitmentDetailChartRows = await ExecuteChartRowsAsync(conn, @" +SELECT + " + SupplierLabelSql("k.Lifnr") + @" || ' | ' || + COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') || ' | faellig ' || + COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label, + 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 +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'), COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') +ORDER BY Value DESC +LIMIT 6;", cancellationToken); + state.ContractChartRows = state.CommitmentDetailChartRows.Count > 0 + ? state.CommitmentDetailChartRows.ToList() + : state.OpenValueChartRows.ToList(); + state.TopCommitmentLabel = state.CommitmentDetailChartRows.Count > 0 + ? $"{state.CommitmentDetailChartRows[0].Label}: CHF {state.CommitmentDetailChartRows[0].Value:N0}" + : string.Empty; await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken); state.CacheStatus = latestStatus.Status; state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc; @@ -349,22 +378,29 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( return; var supplierByEbeln = ekkoRows - .Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = GetText(row, "Lifnr") }) + .Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = FormatSupplierLabel(GetText(row, "Lifnr")) }) .Where(row => !string.IsNullOrWhiteSpace(row.Ebeln)) .GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().Lifnr, StringComparer.OrdinalIgnoreCase); + var monthByEbeln = ekkoRows + .Select(row => new { Ebeln = GetText(row, "Ebeln"), Month = TryParseSapDate(GetText(row, "Bedat"))?.ToString("yyyy-MM") ?? "ohne Datum" }) + .Where(row => !string.IsNullOrWhiteSpace(row.Ebeln)) + .GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Month, StringComparer.OrdinalIgnoreCase); var enriched = ekpoRows .Select(row => { var ebeln = GetText(row, "Ebeln"); supplierByEbeln.TryGetValue(ebeln, out var supplier); + monthByEbeln.TryGetValue(ebeln, out var month); var netwr = GetDecimal(row, "Netwr"); var quantity = GetDecimal(row, "Menge"); return new { Ebeln = ebeln, Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier, + Month = string.IsNullOrWhiteSpace(month) ? "ohne Datum" : month, Material = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel"), MaterialGroup = FirstNonEmpty(GetText(row, "Matkl"), "ohne Warengruppe"), NetValue = netwr, @@ -376,10 +412,10 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( state.SpendChfSample = enriched.Sum(row => row.NetValue); state.TopSupplierLabel = BuildTopLabel(enriched.GroupBy(row => row.Supplier), row => row.NetValue, "Lieferant"); state.TopMaterialGroupLabel = BuildTopLabel(enriched.GroupBy(row => row.MaterialGroup), row => row.NetValue, "Warengruppe"); - state.TopArticleLabel = BuildTopLabel(enriched.GroupBy(row => row.Material), row => row.NetValue, "Artikel"); + state.TopArticleLabel = BuildTopLabel(enriched.GroupBy(row => $"{row.Material} | {row.Supplier} | Monat {row.Month}"), row => row.NetValue, "Artikel"); state.SpendChartRows = enriched .GroupBy(row => row.Supplier) - .Select(group => new PurchasingLiveChartPoint($"Lief. {group.Key}", group.Sum(row => row.NetValue))) + .Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.NetValue))) .OrderByDescending(row => row.Value) .Take(6) .ToList(); @@ -387,12 +423,31 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( private static void ApplyEketMetrics( PurchasingDashboardLiveState state, + List> ekkoRows, List> ekpoRows, List> eketRows) { if (eketRows.Count == 0) return; + var supplierByEbeln = ekkoRows + .Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = FormatSupplierLabel(GetText(row, "Lifnr")) }) + .Where(row => !string.IsNullOrWhiteSpace(row.Ebeln)) + .GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Lifnr, StringComparer.OrdinalIgnoreCase); + var itemByPosition = ekpoRows + .Select(row => + { + var ebeln = GetText(row, "Ebeln"); + var ebelp = GetText(row, "Ebelp"); + return new + { + key = $"{ebeln}|{ebelp}", + Article = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel") + }; + }) + .GroupBy(row => row.key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Article, StringComparer.OrdinalIgnoreCase); var netPriceByPosition = ekpoRows .Select(row => { @@ -414,12 +469,16 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( var ebelp = GetText(row, "Ebelp"); var key = $"{ebeln}|{ebelp}"; netPriceByPosition.TryGetValue(key, out var netPrice); + itemByPosition.TryGetValue(key, out var article); + supplierByEbeln.TryGetValue(ebeln, out var supplier); var quantity = GetDecimal(row, "Menge"); var received = GetDecimal(row, "Wemng"); var openQuantity = Math.Max(0, quantity - received); return new { Ebeln = ebeln, + Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier, + Article = string.IsNullOrWhiteSpace(article) ? "ohne Artikel" : article, DueDate = TryParseSapDate(GetText(row, "Eindt")), OpenQuantity = openQuantity, OpenValue = openQuantity * netPrice @@ -436,7 +495,19 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( .OrderBy(row => row.Label) .Take(6) .ToList(); - state.ContractChartRows = state.OpenValueChartRows.ToList(); + state.CommitmentDetailChartRows = enriched + .Where(row => row.OpenValue > 0) + .GroupBy(row => $"{row.Supplier} | {row.Article} | faellig {row.DueDate?.ToString("yyyy-MM") ?? "ohne Termin"}") + .Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.OpenValue))) + .OrderByDescending(row => row.Value) + .Take(6) + .ToList(); + state.ContractChartRows = state.CommitmentDetailChartRows.Count > 0 + ? state.CommitmentDetailChartRows.ToList() + : state.OpenValueChartRows.ToList(); + state.TopCommitmentLabel = state.CommitmentDetailChartRows.Count > 0 + ? $"{state.CommitmentDetailChartRows[0].Label}: CHF {state.CommitmentDetailChartRows[0].Value:N0}" + : string.Empty; } private static HttpClient CreateClient(string username, string password) @@ -597,6 +668,11 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT( private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + private static string FormatSupplierLabel(string supplierNumber) + => string.IsNullOrWhiteSpace(supplierNumber) + ? "ohne Lieferant" + : $"Lief. {supplierNumber} (Name fehlt)"; + private static string BuildTopLabel(IEnumerable> groups, Func selector, string fallback) { var top = groups diff --git a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md index 3e8ba35..88042a1 100644 --- a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md +++ b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md @@ -153,6 +153,8 @@ Die Seite `/einkauf` zeigt nun echte Werte aus dem SAP-Cache: - `Offener Bestellwert`: berechnet aus EKET-Offenmenge und EKPO-Netto-Stueckwert. - `Offene Menge`: Summe offener EKET-Mengen. - Top-Lieferant, Top-Warengruppe und Top-Artikel werden aus EKPO gruppiert. +- Top-Artikel zeigt nun Artikel, Lieferant und Bestellmonat, damit ein Wert wie `C42698: CHF 1` fachlich nachvollziehbar ist. +- Die Verpflichtungs-/Kontraktseite zeigt Top-Restverpflichtungen nach Lieferant, Artikel und Faelligkeitsmonat, nicht nur den Monatsverlauf. - Spend-, Offenwert- und Kontrakt-Diagramme verwenden Cache-Gruppierungen, sofern der Cache gefuellt ist. - Ist der Cache leer oder nicht erreichbar, faellt das Dashboard auf eine begrenzte SAP-Live-Probe zurueck. - Der Standardzeitraum ist rollierend auf die letzten drei Kalenderjahre bis heute gesetzt. Die Datumsabgrenzung erfolgt im Dashboard ueber `Von Monat` und `Bis Monat`. @@ -180,7 +182,7 @@ Noch nicht final 1:1 ist die Namensauflösung: - PowerBI nutzt fuer Lieferanten- und Warengruppennamen `Data.Name`, `Data.Lieferant`, `Data (2).Warengruppe` und `Data (2).WG komplett`. - Der aktuelle SAP-OData-Service liefert produktiv `EKKOSet`, `EKPOSet` und `eketSet`. - Tests auf `Data`, `Data2`, `DataSet` und `Data2Set` liefern aktuell `404 Resource not found`. -- Bis diese Mapping-Quelle angebunden ist, zeigt das Dashboard Lieferantennummern und Warengruppen-Codes statt vollstaendiger Namen. +- Bis diese Mapping-Quelle angebunden ist, zeigt das Dashboard Lieferanten als `Lief. (Name fehlt)` und Warengruppen-Codes statt vollstaendiger Namen. ## Ideen und Kennzahlen-Katalog