Load purchasing EKPO and EKET metrics

This commit is contained in:
2026-06-05 11:05:04 +02:00
parent e7e408fc20
commit 2fa410ec31
5 changed files with 239 additions and 30 deletions
@@ -360,9 +360,9 @@
private IReadOnlyList<PurchasingKpiCard> 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<PurchasingSectionKpi> 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<PurchasingSectionKpi> 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<PurchasingSectionChartRow> SpendChartRows
=> BuildPurchasingChartRows(x => x.Spend, FormatChf);
=> _liveState.EkpoLoaded && _liveState.SpendChartRows.Count > 0
? BuildLiveChartRows(_liveState.SpendChartRows, FormatChf)
: BuildPurchasingChartRows(x => x.Spend, FormatChf);
private IReadOnlyList<PurchasingSectionChartRow> OpenOrderChartRows
=> _liveState.EkkoLoaded
@@ -535,7 +537,9 @@
: BuildPurchasingChartRows(x => x.OpenValue, FormatChf);
private IReadOnlyList<PurchasingSectionChartRow> ContractChartRows
=> BuildPurchasingChartRows(x => x.ContractValue, FormatChf);
=> _liveState.EketLoaded && _liveState.ContractChartRows.Count > 0
? BuildLiveChartRows(_liveState.ContractChartRows, FormatChf)
: BuildPurchasingChartRows(x => x.ContractValue, FormatChf);
private IReadOnlyList<PurchasingSectionChartRow> 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<PurchasingSectionStatusRow> 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<PurchasingSectionStatusRow> SupplierStatusRows =>
@@ -570,7 +574,7 @@
private IReadOnlyList<PurchasingSectionDetailRow> 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<PurchasingSectionDetailRow> 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<PurchasingSectionChartRow> BuildLiveChartRows(IReadOnlyList<PurchasingLiveChartPoint> rows, Func<decimal, string> 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<PurchasingSectionChartRow> 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();
@@ -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<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = [];
public string Message { get; set; } = string.Empty;
}
public sealed record PurchasingLiveChartPoint(string Label, decimal Value);
@@ -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<Dictionary<string, object?>> ekkoRows,
List<Dictionary<string, object?>> 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<Dictionary<string, object?>> ekpoRows,
List<Dictionary<string, object?>> 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<string, object?> row, string key)
=> row.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty;
private static decimal GetDecimal(Dictionary<string, object?> 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<T>(IEnumerable<IGrouping<string, T>> groups, Func<T, decimal> 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))
@@ -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 <erste aktuelle EKKO-Bestellnummer>`.
- 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.
+1 -1
View File
@@ -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.