Load purchasing EKPO and EKET metrics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user