diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor index 5eac91a..6fe815e 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -360,9 +360,9 @@ private IReadOnlyList KpiCards => [ - new("Spend total", "Total spend", _liveState.EkpoLoaded ? "EKPO live" : T("wartet auf EKPO", "waiting for EKPO"), _liveState.EkpoLoaded ? "Positionsdaten verfuegbar" : "EKKO live, Positionswerte fehlen noch", _liveState.EkpoLoaded ? "Position data available" : "EKKO live, position values still missing", Icons.Material.Filled.Payments, Color.Primary), + new("Spend total", "Total spend", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : T("wartet auf EKPO", "waiting for EKPO"), _liveState.EkpoLoaded ? "EKPO-Live-Sample" : "EKKO live, Positionswerte fehlen noch", _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("Kontrakte", "Contracts", _liveState.EketLoaded ? _liveState.ScheduleSampleCount.ToString("N0") : T("wartet auf EKET", "waiting for EKET"), _liveState.EketLoaded ? "Termin-/Einteilungsprobe verfuegbar" : "EKKO live, Terminwerte fehlen noch", _liveState.EketLoaded ? "Schedule sample available" : "EKKO live, schedule values still missing", Icons.Material.Filled.Assignment, Color.Info), + new("Kontrakte", "Contracts", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : T("wartet auf EKET", "waiting for EKET"), _liveState.EketLoaded ? "Restwert aus EKET/EKPO-Sample" : "EKKO live, Terminwerte fehlen noch", _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) ]; @@ -432,7 +432,7 @@ private IReadOnlyList SpendKpis => [ - new("Spend CHF", "Spend CHF", _liveState.EkpoLoaded ? "EKPO live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.Spend)), _liveState.EkpoLoaded ? "aus SAP Positionen" : "Simulation bis EKPO liefert", _liveState.EkpoLoaded ? "from SAP item rows" : "simulation until EKPO delivers"), + new("Spend CHF", "Spend CHF", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : FormatChf(Purchasing3dBaseRows.Sum(x => x.Spend)), _liveState.EkpoLoaded ? "aus SAP Positionen" : "Simulation bis EKPO liefert", _liveState.EkpoLoaded ? "from SAP item rows" : "simulation until EKPO delivers"), new("Jahre", "Years", "2024-2026", "aus PBIX-Struktur", "from PBIX structure"), new("Dimensionen", "Dimensions", "4", "Jahr, Lieferant, Warengruppe, Artikel", "year, supplier, material group, article"), new("SAP Status", "SAP status", _liveState.EkpoLoaded ? "live" : "wartet", "EKPOSet fuer Spend notwendig", "EKPOSet required for spend") @@ -442,13 +442,13 @@ [ new("Bestellungen", "Orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"), new("Lieferanten", "Suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "aus EKKO-Liveprobe", "from EKKO live sample"), - new("Offener Wert", "Open value", _liveState.EkpoLoaded ? "EKPO live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.OpenValue)), _liveState.EkpoLoaded ? "aus SAP Positionen" : "Simulation bis EKPO liefert", _liveState.EkpoLoaded ? "from SAP item rows" : "simulation until EKPO delivers"), - new("Offene Menge", "Open quantity", _liveState.EkpoLoaded ? "EKPO live" : Purchasing3dBaseRows.Sum(x => x.OpenQuantity).ToString("N0"), _liveState.EkpoLoaded ? "aus SAP Positionen" : "Simulation bis EKPO liefert", _liveState.EkpoLoaded ? "from SAP item rows" : "simulation until EKPO delivers") + 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") ]; private IReadOnlyList ContractKpis => [ - new("Restwert", "Remaining value", _liveState.EketLoaded ? "EKET live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), _liveState.EketLoaded ? "aus SAP Einteilungen" : "Simulation bis EKET liefert", _liveState.EketLoaded ? "from SAP schedules" : "simulation until EKET delivers"), + new("Restwert", "Remaining value", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : FormatChf(Purchasing3dBaseRows.Sum(x => 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("Faelligkeit", "Due date", _liveState.LatestOrderDate?.ToString("yyyy-MM-dd") ?? "-", "letztes bekanntes EKKO-Datum", "latest known EKKO date") @@ -458,7 +458,7 @@ [ new("Aktive Lieferanten", "Active suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"), 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 ? "EKPO live" : "wartet", "Netwr CHF/Stk braucht EKPO", "Netwr CHF/unit needs EKPO"), + 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") ]; @@ -527,7 +527,9 @@ ]; private IReadOnlyList SpendChartRows - => BuildPurchasingChartRows(x => x.Spend, FormatChf); + => _liveState.EkpoLoaded && _liveState.SpendChartRows.Count > 0 + ? BuildLiveChartRows(_liveState.SpendChartRows, FormatChf) + : BuildPurchasingChartRows(x => x.Spend, FormatChf); private IReadOnlyList OpenOrderChartRows => _liveState.EkkoLoaded @@ -535,7 +537,9 @@ : BuildPurchasingChartRows(x => x.OpenValue, FormatChf); private IReadOnlyList ContractChartRows - => BuildPurchasingChartRows(x => x.ContractValue, FormatChf); + => _liveState.EketLoaded && _liveState.ContractChartRows.Count > 0 + ? BuildLiveChartRows(_liveState.ContractChartRows, FormatChf) + : BuildPurchasingChartRows(x => x.ContractValue, FormatChf); private IReadOnlyList SupplierChartRows => BuildPurchasingChartRows(x => x.SupplierScore, value => $"{value:N1}%"); @@ -544,13 +548,13 @@ [ BuildStatus("EKKO Bestellkoepfe", "EKKO purchase headers", _liveState.EkkoLoaded, _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-"), BuildStatus("EKPO Positionen", "EKPO item rows", _liveState.EkpoLoaded, _liveState.EkpoLoaded ? $"{_liveState.PositionSampleCount:N0}" : "0"), - BuildStatus("Spend CHF", "Spend CHF", _liveState.EkpoLoaded, _liveState.EkpoLoaded ? "live" : "Simulation") + BuildStatus("Spend CHF", "Spend CHF", _liveState.EkpoLoaded, _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : "Simulation") ]; private IReadOnlyList OpenOrderStatusRows => [ BuildStatus("Bestellkoepfe", "Purchase headers", _liveState.EkkoLoaded, _liveState.EkkoLoaded ? "live" : "-"), - BuildStatus("Offene Werte", "Open values", _liveState.EkpoLoaded, _liveState.EkpoLoaded ? "live" : "wartet auf EKPO"), + BuildStatus("Offene Werte", "Open values", _liveState.EketLoaded, _liveState.EketLoaded ? FormatChf(_liveState.OpenValueSample) : "wartet auf EKET"), BuildStatus("Faelligkeiten", "Due dates", _liveState.EketLoaded, _liveState.EketLoaded ? "live" : "wartet auf EKET") ]; @@ -558,7 +562,7 @@ [ BuildStatus("Kontraktkopf/-position", "Contract header/item", _liveState.EkpoLoaded, _liveState.EkpoLoaded ? "live" : "wartet auf EKPO"), BuildStatus("Einteilungen", "Schedules", _liveState.EketLoaded, _liveState.EketLoaded ? $"{_liveState.ScheduleSampleCount:N0}" : "0"), - BuildStatus("Restverpflichtung", "Remaining commitment", _liveState.EkpoLoaded && _liveState.EketLoaded, _liveState.EkpoLoaded && _liveState.EketLoaded ? "live" : "Simulation") + BuildStatus("Restverpflichtung", "Remaining commitment", _liveState.EkpoLoaded && _liveState.EketLoaded, _liveState.EkpoLoaded && _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : "Simulation") ]; private IReadOnlyList SupplierStatusRows => @@ -570,7 +574,7 @@ private IReadOnlyList SpendDetailRows => [ - new("Spend nach Jahr", "Spend by year", _liveState.EkpoLoaded ? "SAP live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.Spend)), "EKKOSet.Bedat Jahr", _liveState.EkpoLoaded ? "SAP live" : "Simulation"), + 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") @@ -580,13 +584,13 @@ [ 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("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.EkpoLoaded ? "SAP live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.OpenValue)), "EKPOSet.Netwr CHF", _liveState.EkpoLoaded ? "SAP live" : "Simulation"), + 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") ]; private IReadOnlyList ContractDetailRows => [ - new("Restverpflichtung", "Remaining commitment", _liveState.EketLoaded ? "SAP live" : FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), "EKPO/EKET", _liveState.EketLoaded ? "SAP live" : "Simulation"), + 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") @@ -614,9 +618,10 @@ private string T(string german, string english) => UiText.Text(german, english); private static string FormatChf(double value) => $"CHF {value:N0}"; - private string TopSpendLabel => BuildTopLabel(x => x.Spend, FormatChf); - private string TopMaterialGroupLabel => BuildTopLabel(x => x.Spend, FormatChf, "Warengruppe"); - private string TopArticleLabel => BuildTopLabel(x => x.Spend, FormatChf, "Artikel"); + private static string FormatChf(decimal value) => $"CHF {value:N0}"; + 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 PurchasingStatusText => _liveLoading ? T("SAP-Einkaufsdaten werden geladen...", "Loading SAP purchasing data...") @@ -654,9 +659,23 @@ .ToList(); } + private IReadOnlyList BuildLiveChartRows(IReadOnlyList rows, Func formatter) + { + var max = rows.Count == 0 ? 0m : rows.Max(row => row.Value); + return rows + .Select((row, index) => new PurchasingSectionChartRow( + row.Label, + formatter(row.Value), + max <= 0 ? 0 : (double)(row.Value / max * 100m), + PurchasingPalette[index % PurchasingPalette.Length])) + .ToList(); + } + private IReadOnlyList BuildOpenOrderLiveChartRows() { var simulatedRows = BuildPurchasingChartRows(x => x.OpenValue, FormatChf).ToList(); + if (_liveState.EketLoaded && _liveState.OpenValueChartRows.Count > 0) + simulatedRows = BuildLiveChartRows(_liveState.OpenValueChartRows, FormatChf).ToList(); simulatedRows.Insert(0, new PurchasingSectionChartRow(T("EKKO Bestellungen live", "EKKO orders live"), _liveState.PurchaseOrderCount.ToString("N0"), 100d, "#2e7d32")); simulatedRows.Insert(1, new PurchasingSectionChartRow(T("Lieferanten live", "Suppliers live"), _liveState.SupplierCount.ToString("N0"), _liveState.PurchaseOrderCount <= 0 ? 0 : Math.Min(100d, _liveState.SupplierCount / (double)_liveState.PurchaseOrderCount * 100d), "#1565c0")); return simulatedRows.Take(6).ToList(); diff --git a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs index dab4f68..c8c4e0e 100644 --- a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs @@ -16,5 +16,17 @@ public sealed class PurchasingDashboardLiveState public DateTime? LatestOrderDate { get; set; } public int PositionSampleCount { get; set; } public int ScheduleSampleCount { get; set; } + public decimal SpendChfSample { get; set; } + public decimal OpenQuantitySample { get; set; } + public decimal OpenValueSample { get; set; } + public decimal ContractValueSample { get; set; } + public string TopSupplierLabel { get; set; } = string.Empty; + public string TopMaterialGroupLabel { get; set; } = string.Empty; + public string TopArticleLabel { get; set; } = string.Empty; + public List SpendChartRows { get; set; } = []; + public List OpenValueChartRows { get; set; } = []; + public List ContractChartRows { get; set; } = []; public string Message { get; set; } = string.Empty; } + +public sealed record PurchasingLiveChartPoint(string Label, decimal Value); diff --git a/TrafagSalesExporter/Services/PurchasingDashboardService.cs b/TrafagSalesExporter/Services/PurchasingDashboardService.cs index 567d4b2..eda5cbf 100644 --- a/TrafagSalesExporter/Services/PurchasingDashboardService.cs +++ b/TrafagSalesExporter/Services/PurchasingDashboardService.cs @@ -74,22 +74,27 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService { var ekpoRows = await ReadRowsAsync( client, - $"{baseUrl}EKPOSet?$format=json&$top=50&$filter={Uri.EscapeDataString($"Ebeln eq '{firstEbeln}'")}", + $"{baseUrl}EKPOSet?$format=json&$top=1000&$filter={Uri.EscapeDataString($"Ebeln ge '{firstEbeln}'")}", cancellationToken); state.PositionSampleCount = ekpoRows.Count; state.EkpoLoaded = ekpoRows.Count > 0; var eketRows = await ReadRowsAsync( client, - $"{baseUrl}eketSet?$format=json&$top=50&$filter={Uri.EscapeDataString($"Ebeln eq '{firstEbeln}'")}", + $"{baseUrl}eketSet?$format=json&$top=1000&$filter={Uri.EscapeDataString($"Ebeln ge '{firstEbeln}'")}", cancellationToken); state.ScheduleSampleCount = eketRows.Count; state.EketLoaded = eketRows.Count > 0; + + ApplyEkpoMetrics(state, ekkoRows, ekpoRows); + ApplyEketMetrics(state, ekpoRows, eketRows); } - state.Message = state.EkpoLoaded - ? "SAP Einkaufsdaten geladen." - : "EKKO ist live geladen; EKPO/EKET liefern aktuell noch keine Positionsdaten."; + state.Message = state.EkpoLoaded && state.EketLoaded + ? "SAP Einkaufsdaten inkl. EKPO/EKET geladen." + : state.EkpoLoaded + ? "SAP Einkaufsdaten inkl. EKPO geladen; EKET liefert noch keine Termindaten." + : "EKKO ist live geladen; EKPO/EKET liefern aktuell noch keine Positionsdaten."; } catch (Exception ex) { @@ -99,6 +104,105 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService return state; } + private static void ApplyEkpoMetrics( + PurchasingDashboardLiveState state, + List> ekkoRows, + List> ekpoRows) + { + if (ekpoRows.Count == 0) + return; + + var supplierByEbeln = ekkoRows + .Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = 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 enriched = ekpoRows + .Select(row => + { + var ebeln = GetText(row, "Ebeln"); + supplierByEbeln.TryGetValue(ebeln, out var supplier); + var netwr = GetDecimal(row, "Netwr"); + var quantity = GetDecimal(row, "Menge"); + return new + { + Ebeln = ebeln, + Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier, + Material = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel"), + MaterialGroup = FirstNonEmpty(GetText(row, "Matkl"), "ohne Warengruppe"), + NetValue = netwr, + Quantity = quantity + }; + }) + .ToList(); + + 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.SpendChartRows = enriched + .GroupBy(row => row.Supplier) + .Select(group => new PurchasingLiveChartPoint($"Lief. {group.Key}", group.Sum(row => row.NetValue))) + .OrderByDescending(row => row.Value) + .Take(6) + .ToList(); + } + + private static void ApplyEketMetrics( + PurchasingDashboardLiveState state, + List> ekpoRows, + List> eketRows) + { + if (eketRows.Count == 0) + return; + + var netPriceByPosition = ekpoRows + .Select(row => + { + var ebeln = GetText(row, "Ebeln"); + var ebelp = GetText(row, "Ebelp"); + var key = $"{ebeln}|{ebelp}"; + var quantity = GetDecimal(row, "Menge"); + var netValue = GetDecimal(row, "Netwr"); + var netPrice = quantity == 0 ? 0 : netValue / quantity; + return new { key, netPrice }; + }) + .GroupBy(row => row.key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().netPrice, StringComparer.OrdinalIgnoreCase); + + var enriched = eketRows + .Select(row => + { + var ebeln = GetText(row, "Ebeln"); + var ebelp = GetText(row, "Ebelp"); + var key = $"{ebeln}|{ebelp}"; + netPriceByPosition.TryGetValue(key, out var netPrice); + var quantity = GetDecimal(row, "Menge"); + var received = GetDecimal(row, "Wemng"); + var openQuantity = Math.Max(0, quantity - received); + return new + { + Ebeln = ebeln, + DueDate = TryParseSapDate(GetText(row, "Eindt")), + OpenQuantity = openQuantity, + OpenValue = openQuantity * netPrice + }; + }) + .ToList(); + + state.OpenQuantitySample = enriched.Sum(row => row.OpenQuantity); + state.OpenValueSample = enriched.Sum(row => row.OpenValue); + state.ContractValueSample = state.OpenValueSample; + state.OpenValueChartRows = enriched + .GroupBy(row => row.DueDate?.ToString("yyyy-MM") ?? "ohne Termin") + .Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.OpenValue))) + .OrderBy(row => row.Label) + .Take(6) + .ToList(); + state.ContractChartRows = state.OpenValueChartRows.ToList(); + } + private static HttpClient CreateClient(string username, string password) { var client = new HttpClient { Timeout = TimeSpan.FromSeconds(45) }; @@ -151,6 +255,27 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService private static string GetText(Dictionary row, string key) => row.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty; + private static decimal GetDecimal(Dictionary row, string key) + { + var text = GetText(row, key); + return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) + || decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out value) + ? value + : 0m; + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + + private static string BuildTopLabel(IEnumerable> groups, Func selector, string fallback) + { + var top = groups + .Select(group => new { Label = group.Key, Value = group.Sum(selector) }) + .OrderByDescending(row => row.Value) + .FirstOrDefault(); + return top is null ? fallback : $"{top.Label}: CHF {top.Value:N0}"; + } + private static DateTime? TryParseSapDate(string value) { if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed)) diff --git a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md index a93e14f..62a2220 100644 --- a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md +++ b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md @@ -47,9 +47,11 @@ Das Dashboard wurde fachlich um diese Bereiche erweitert: - `Lieferanten` - `PBIX Vorlage` - `3D Simulation` + - `Ideen` - Unterpunkt `Einkauf > Datenquellen` fuer SAP/OData-Verbindung, Quellen, Join-Fluss und Zielmappings. - Die Seite ist als Cockpit-Struktur umgesetzt und zweisprachig ueber den vorhandenen UI-Sprachservice vorbereitet. -- Die Kennzahlen sind noch nicht live an SAP gebunden. +- EKKO, EKPO und EKET werden live ueber SAP/OData gelesen. +- Die Kennzahlen im Cockpit nutzen aktuell eine begrenzte Live-Probe, damit das Dashboard sofort echte Einkaufsdaten zeigt. ## SAP/OData-Konfiguration @@ -70,6 +72,44 @@ Vorbefuellte Joins: Die Seite verwendet dieselben Grundtabellen wie die Finance-/Standorte-Quellenpflege: `Sites`, `SapSourceDefinitions`, `SapJoinDefinitions`, `SapFieldMappings`. +## SAP/OData Live-Stand 2026-06-05 + +Der SAP-Test hat bestaetigt, dass die Einkaufstabellen Daten enthalten: + +- `EKKO` ab `01.01.2026`: 2'748 Koepfe. +- `EKPO` gesamt: 233'920 Positionen. +- `EKET` gesamt: 242'571 Einteilungen. +- Join `EKKO -> EKPO` ab `01.01.2026`: 3'464 Zeilen. +- Join `EKKO -> EKET` ab `01.01.2026`: 3'458 Zeilen. + +Nach Aktivierung der angepassten SAP-Methoden liefern die OData-Services: + +- `EKPOSet?$top=5`: HTTP 200 mit Daten. +- `eketSet?$top=5`: HTTP 200 mit Daten. +- `EKPOSet?$filter=Ebeln eq '45148366'`: 1 Zeile. +- `eketSet?$filter=Ebeln eq '45148366'`: 1 Zeile. + +Wichtig: Die OData-Property heisst `Ebeln`. Ein Filter mit `EBELN` liefert HTTP 400. + +## Live-Kennzahlen im Dashboard + +Die Seite `/einkauf` zeigt nun echte Werte aus SAP: + +- `Spend total`: Summe `EKPOSet.Netwr` aus der Live-Probe. +- `Offene Bestellungen`: Anzahl EKKO-Belege seit Jahresbeginn. +- `Kontrakte`: offener Restwert aus `EKET.Menge - EKET.Wemng` bewertet mit EKPO-Netto-Stueckwert. +- `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. +- Spend-, Offenwert- und Kontrakt-Diagramme verwenden Live-Gruppierungen, sofern EKPO/EKET Daten liefern. + +Aktuelle technische Begrenzung: + +- Das Dashboard laedt fuer EKPO/EKET eine begrenzte Probe mit `$top=1000`. +- Filter ist `Ebeln ge `. +- Damit sind die Werte echte SAP-Werte, aber noch keine vollstaendige Jahresaggregation. +- Fuer definitive Management-Summen braucht es als naechsten Schritt serverseitige OData-Filter/Aggregation oder einen eigenen Import-/Cache-Prozess analog Finance. + ## 3D Simulation Das Einkaufsdashboard hat eine eigene 3D-Simulation fuer wichtige Einkaufsindikatoren: @@ -84,12 +124,25 @@ Die Simulation nutzt feste Canvas-Groessen, sichtbare Achsen, waehlbare Diagramm ## Naechster Schritt fuer Live-Daten -Fuer echte Werte muessen die Einkaufsquellen sauber gemappt werden: +Fuer definitive Vollwerte muessen die Live-Quellen noch fachlich fertig aggregiert werden: -- Bestellkopf, z. B. `EKKOSet`. -- Bestellpositionen, z. B. `EKPOSet`. -- Offene Liefer-/Terminmengen, voraussichtlich Termin-/Schedule-Daten. -- Kontrakte und offene Verpflichtungen. +- Jahres-/Periodenfilter fuer `EKKOSet.Bedat`. +- Vollstaendige Aggregation von `EKPOSet.Netwr` nach Jahr, Lieferant, Warengruppe und Artikel. +- Vollstaendige offene Werte/Mengen aus `EKET` und `EKPO`. +- Kontrakte und offene Verpflichtungen, inkl. fachlicher Abgrenzung von normalen Bestellungen. - Lieferantenbewertung / Performance, falls im SAP-System als OData- oder HANA-Quelle verfuegbar. Danach koennen Filter, Aggregationen und Delta-/Refresh-Prozess analog zu Finance/Spain umgesetzt werden. + +## Geaenderte Programmstellen + +- `Components/Pages/PurchasingDashboard.razor` + - KPI-Karten, Detailtabellen und Diagramme lesen jetzt Live-Werte aus `PurchasingDashboardLiveState`. + - Fallback-Simulation bleibt sichtbar, falls SAP/OData nicht antwortet. +- `Services/IPurchasingDashboardService.cs` + - Live-State um Spend, offene Menge, offenen Wert, Kontraktwert und Live-Diagrammzeilen erweitert. +- `Services/PurchasingDashboardService.cs` + - Laedt EKKO, EKPO und EKET. + - Berechnet Spend aus EKPO. + - Berechnet offene Mengen/Werte aus EKET minus Wareneingangsmenge, bewertet mit EKPO-Netto-Stueckwert. + - Erstellt Top-Gruppierungen fuer Lieferant, Warengruppe und Artikel. diff --git a/TrafagSalesExporter/docs/rag/PROJECT.md b/TrafagSalesExporter/docs/rag/PROJECT.md index 62bbea2..ed73136 100644 --- a/TrafagSalesExporter/docs/rag/PROJECT.md +++ b/TrafagSalesExporter/docs/rag/PROJECT.md @@ -12,7 +12,7 @@ Stand: 2026-06-05 - Neu in der Navigation: Menuebaum wird aus `NavigationMenuItems` gerendert; Admins koennen bestehende Punkte unter `Admin > Menuestruktur` umhaengen, sortieren und aus-/einblenden. - Neu als Hauptbereich: `Einkauf` mit Einkaufswagen-Icon und erweitertem `Einkauf Dashboard`. - Einkauf: `x.pbix` wurde als Vorlage analysiert; `/einkauf` enthaelt jetzt Struktur fuer Spend, offene Bestellungen, Mengenkontrakte, Lieferantenperformance, PBIX-Reportseiten und 3D-Simulation. -- Einkauf: `Einkauf > Datenquellen` pflegt die SAP/OData-Konfiguration grafisch und ist mit `EKKOSet`, `EKPOSet`, `eketSet`, `Data`, `Data2`, Joins und Zielmappings vorbefuellt. Realer Kennzahlenimport ist noch offen. +- Einkauf: `Einkauf > Datenquellen` pflegt die SAP/OData-Konfiguration grafisch und ist mit `EKKOSet`, `EKPOSet`, `eketSet`, `Data`, `Data2`, Joins und Zielmappings vorbefuellt. `/einkauf` laedt EKKO/EKPO/EKET live und zeigt eine echte, begrenzte SAP-Probe fuer Spend, offene Werte/Mengen und Kontrakt-Restwerte. Vollstaendige Jahresaggregation und Lieferantenperformance sind noch offen. - Neu im Expertenbereich: `3D Datenanalyse` mit drehbarer 3D-Grafik, Achsen, Diagrammarten, Indikatorauswahl, Labelgroesse und Simulation per Schieberegler. - Spanien: `Run-SpainRangeExportAndUpload-AllInOne.ps1` exportiert Sage-Range direkt und laedt CSV/Summary via rclone nach SharePoint `trafag-bi:Import/Finance/Spanien`. - Spanien: Default-Range ist heute minus 7 Tage bis heute; `ToDate` ist exklusiv.