Show purchasing commitments with supplier context

This commit is contained in:
2026-06-05 14:28:01 +02:00
parent a41ef0a564
commit 9b43d483fc
5 changed files with 109 additions and 22 deletions
@@ -139,10 +139,10 @@
case "kontrakte": case "kontrakte":
<PurchasingSection TitleDe="Offene Verpflichtungen" <PurchasingSection TitleDe="Offene Verpflichtungen"
TitleEn="Open commitments" TitleEn="Open commitments"
DescriptionDe="Kontrakte und Restverpflichtungen werden auf EKPO/EKET aufgebaut. Der Bereich zeigt bereits die Zielkennzahlen und den aktuellen Ladezustand." DescriptionDe="Restverpflichtungen werden aus EKET-Offenmenge und EKPO-Stueckwert berechnet und nach Lieferant, Artikel und Faelligkeitsmonat gezeigt."
DescriptionEn="Contracts and remaining commitments are built on EKPO/EKET. This area already shows the target KPIs and current load status." DescriptionEn="Remaining commitments are calculated from EKET open quantity and EKPO unit value and shown by supplier, article and due month."
ChartTitleDe="Kontrakt- und Verpflichtungsuebersicht" ChartTitleDe="Top Verpflichtungen nach Lieferant, Artikel und Faelligkeit"
ChartTitleEn="Contract and commitment overview" ChartTitleEn="Top commitments by supplier, article and due date"
Kpis="@ContractKpis" Kpis="@ContractKpis"
ChartRows="@ContractChartRows" ChartRows="@ContractChartRows"
StatusRows="@ContractStatusRows" StatusRows="@ContractStatusRows"
@@ -686,7 +686,7 @@
[ [
new("Spend total", "Total spend", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : T("wartet auf EKPO", "waiting for EKPO"), _liveState.UsesCache ? "Einkauf Cache Vollwerte" : _liveState.EkpoLoaded ? "EKPO-Live-Sample" : "EKKO live, Positionswerte fehlen noch", _liveState.UsesCache ? "purchasing cache full values" : _liveState.EkpoLoaded ? "EKPO live sample" : "EKKO live, position values still missing", Icons.Material.Filled.Payments, Color.Primary), new("Spend total", "Total spend", _liveState.EkpoLoaded ? FormatChf(_liveState.SpendChfSample) : T("wartet auf EKPO", "waiting for EKPO"), _liveState.UsesCache ? "Einkauf Cache Vollwerte" : _liveState.EkpoLoaded ? "EKPO-Live-Sample" : "EKKO live, Positionswerte fehlen noch", _liveState.UsesCache ? "purchasing cache full values" : _liveState.EkpoLoaded ? "EKPO live sample" : "EKKO live, position values still missing", Icons.Material.Filled.Payments, Color.Primary),
new("Offene Bestellungen", "Open orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", _liveState.EkkoLoaded ? $"EKKO-Belege im Zeitraum {FilterLabel}" : "Noch nicht geladen", _liveState.EkkoLoaded ? $"EKKO orders in period {FilterLabel}" : "Not loaded yet", Icons.Material.Filled.PendingActions, Color.Warning), new("Offene Bestellungen", "Open orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", _liveState.EkkoLoaded ? $"EKKO-Belege im Zeitraum {FilterLabel}" : "Noch nicht geladen", _liveState.EkkoLoaded ? $"EKKO orders in period {FilterLabel}" : "Not loaded yet", Icons.Material.Filled.PendingActions, Color.Warning),
new("Kontrakte", "Contracts", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : T("wartet auf EKET", "waiting for EKET"), _liveState.UsesCache ? "Restwert aus Einkauf Cache" : _liveState.EketLoaded ? "Restwert aus EKET/EKPO-Sample" : "EKKO live, Terminwerte fehlen noch", _liveState.UsesCache ? "Remaining value from purchasing cache" : _liveState.EketLoaded ? "Remaining value from EKET/EKPO sample" : "EKKO live, schedule values still missing", Icons.Material.Filled.Assignment, Color.Info), new("Verpflichtungen", "Commitments", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : T("wartet auf EKET", "waiting for EKET"), _liveState.UsesCache ? "nach Lieferant / Artikel / Faelligkeit" : _liveState.EketLoaded ? "Restwert aus EKET/EKPO-Sample" : "EKKO live, Terminwerte fehlen noch", _liveState.UsesCache ? "by supplier / article / due date" : _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) 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)
]; ];
@@ -774,7 +774,7 @@
[ [
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("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("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") 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 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 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 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<PurchasingSectionDetailRow> OpenOrderDetailRows => private IReadOnlyList<PurchasingSectionDetailRow> OpenOrderDetailRows =>
@@ -1409,10 +1409,10 @@
private IReadOnlyList<PurchasingSectionDetailRow> ContractDetailRows => private IReadOnlyList<PurchasingSectionDetailRow> ContractDetailRows =>
[ [
new("Restverpflichtung", "Remaining commitment", _liveState.EketLoaded ? FormatChf(_liveState.ContractValueSample) : 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)), "EKET offen * EKPO Stueckwert", _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("Groesste Verpflichtung", "Largest commitment", TopCommitmentLabel, "Lieferant / Artikel / Faelligkeit", _liveState.EketLoaded ? "SAP live" : "Wartet auf SAP"),
new("Abrufquote", "Consumption rate", "offen", "Kontraktmenge / Abrufmenge", "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("Faellige Verpflichtungen", "Due commitments", _liveState.EketLoaded ? "SAP live" : "wartet auf EKET", "eketSet", _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<PurchasingSectionDetailRow> SupplierDetailRows => private IReadOnlyList<PurchasingSectionDetailRow> SupplierDetailRows =>
@@ -1530,6 +1530,7 @@
private string TopSpendLabel => _liveState.EkpoLoaded && !string.IsNullOrWhiteSpace(_liveState.TopSupplierLabel) ? _liveState.TopSupplierLabel : BuildTopLabel(x => x.Spend, FormatChf); 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 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 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 private string PurchasingStatusText
=> _liveLoading => _liveLoading
? T("SAP-Einkaufsdaten werden geladen...", "Loading SAP purchasing data...") ? T("SAP-Einkaufsdaten werden geladen...", "Loading SAP purchasing data...")
@@ -202,6 +202,12 @@
text-align: right; text-align: right;
} }
.purchasing-bar-label {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.2;
}
@@media (max-width: 760px) { @@media (max-width: 760px) {
.purchasing-bar-row, .purchasing-bar-row,
.purchasing-status-row, .purchasing-status-row,
@@ -33,9 +33,11 @@ public sealed class PurchasingDashboardLiveState
public string TopSupplierLabel { get; set; } = string.Empty; public string TopSupplierLabel { get; set; } = string.Empty;
public string TopMaterialGroupLabel { get; set; } = string.Empty; public string TopMaterialGroupLabel { get; set; } = string.Empty;
public string TopArticleLabel { get; set; } = string.Empty; public string TopArticleLabel { get; set; } = string.Empty;
public string TopCommitmentLabel { get; set; } = string.Empty;
public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> CommitmentDetailChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> DeliveryRiskChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> DeliveryRiskChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> PriceVarianceChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> PriceVarianceChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> SpendConcentrationChartRows { get; set; } = []; public List<PurchasingLiveChartPoint> SpendConcentrationChartRows { get; set; } = [];
@@ -23,6 +23,12 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today); 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<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default) public async Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default)
{ {
var state = new PurchasingDashboardLiveState(); var state = new PurchasingDashboardLiveState();
@@ -100,7 +106,7 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
state.EketLoaded = eketRows.Count > 0; state.EketLoaded = eketRows.Count > 0;
ApplyEkpoMetrics(state, ekkoRows, ekpoRows); ApplyEkpoMetrics(state, ekkoRows, ekpoRows);
ApplyEketMetrics(state, ekpoRows, eketRows); ApplyEketMetrics(state, ekkoRows, ekpoRows, eketRows);
} }
state.Message = state.EkpoLoaded && state.EketLoaded 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); WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + ";", cancellationToken);
state.ContractValueSample = state.OpenValueSample; state.ContractValueSample = state.OpenValueSample;
state.TopSupplierLabel = await ExecuteTopLabelAsync(conn, @" 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 FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @" WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
@@ -180,15 +186,19 @@ GROUP BY COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe')
ORDER BY Value DESC ORDER BY Value DESC
LIMIT 1;", "Warengruppe", cancellationToken); LIMIT 1;", "Warengruppe", cancellationToken);
state.TopArticleLabel = await ExecuteTopLabelAsync(conn, @" 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 FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @" 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 ORDER BY Value DESC
LIMIT 1;", "Artikel", cancellationToken); LIMIT 1;", "Artikel", cancellationToken);
state.SpendChartRows = await ExecuteChartRowsAsync(conn, @" 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 FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @" 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') GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
ORDER BY Label ORDER BY Label
LIMIT 6;", cancellationToken); 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); await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken);
state.CacheStatus = latestStatus.Status; state.CacheStatus = latestStatus.Status;
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc; state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
@@ -349,22 +378,29 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(
return; return;
var supplierByEbeln = ekkoRows 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)) .Where(row => !string.IsNullOrWhiteSpace(row.Ebeln))
.GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase) .GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Lifnr, 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 var enriched = ekpoRows
.Select(row => .Select(row =>
{ {
var ebeln = GetText(row, "Ebeln"); var ebeln = GetText(row, "Ebeln");
supplierByEbeln.TryGetValue(ebeln, out var supplier); supplierByEbeln.TryGetValue(ebeln, out var supplier);
monthByEbeln.TryGetValue(ebeln, out var month);
var netwr = GetDecimal(row, "Netwr"); var netwr = GetDecimal(row, "Netwr");
var quantity = GetDecimal(row, "Menge"); var quantity = GetDecimal(row, "Menge");
return new return new
{ {
Ebeln = ebeln, Ebeln = ebeln,
Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier, Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier,
Month = string.IsNullOrWhiteSpace(month) ? "ohne Datum" : month,
Material = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel"), Material = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel"),
MaterialGroup = FirstNonEmpty(GetText(row, "Matkl"), "ohne Warengruppe"), MaterialGroup = FirstNonEmpty(GetText(row, "Matkl"), "ohne Warengruppe"),
NetValue = netwr, NetValue = netwr,
@@ -376,10 +412,10 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(
state.SpendChfSample = enriched.Sum(row => row.NetValue); state.SpendChfSample = enriched.Sum(row => row.NetValue);
state.TopSupplierLabel = BuildTopLabel(enriched.GroupBy(row => row.Supplier), row => row.NetValue, "Lieferant"); 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.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 state.SpendChartRows = enriched
.GroupBy(row => row.Supplier) .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) .OrderByDescending(row => row.Value)
.Take(6) .Take(6)
.ToList(); .ToList();
@@ -387,12 +423,31 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(
private static void ApplyEketMetrics( private static void ApplyEketMetrics(
PurchasingDashboardLiveState state, PurchasingDashboardLiveState state,
List<Dictionary<string, object?>> ekkoRows,
List<Dictionary<string, object?>> ekpoRows, List<Dictionary<string, object?>> ekpoRows,
List<Dictionary<string, object?>> eketRows) List<Dictionary<string, object?>> eketRows)
{ {
if (eketRows.Count == 0) if (eketRows.Count == 0)
return; 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 var netPriceByPosition = ekpoRows
.Select(row => .Select(row =>
{ {
@@ -414,12 +469,16 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(
var ebelp = GetText(row, "Ebelp"); var ebelp = GetText(row, "Ebelp");
var key = $"{ebeln}|{ebelp}"; var key = $"{ebeln}|{ebelp}";
netPriceByPosition.TryGetValue(key, out var netPrice); netPriceByPosition.TryGetValue(key, out var netPrice);
itemByPosition.TryGetValue(key, out var article);
supplierByEbeln.TryGetValue(ebeln, out var supplier);
var quantity = GetDecimal(row, "Menge"); var quantity = GetDecimal(row, "Menge");
var received = GetDecimal(row, "Wemng"); var received = GetDecimal(row, "Wemng");
var openQuantity = Math.Max(0, quantity - received); var openQuantity = Math.Max(0, quantity - received);
return new return new
{ {
Ebeln = ebeln, Ebeln = ebeln,
Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier,
Article = string.IsNullOrWhiteSpace(article) ? "ohne Artikel" : article,
DueDate = TryParseSapDate(GetText(row, "Eindt")), DueDate = TryParseSapDate(GetText(row, "Eindt")),
OpenQuantity = openQuantity, OpenQuantity = openQuantity,
OpenValue = openQuantity * netPrice OpenValue = openQuantity * netPrice
@@ -436,7 +495,19 @@ SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(
.OrderBy(row => row.Label) .OrderBy(row => row.Label)
.Take(6) .Take(6)
.ToList(); .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) 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) private static string FirstNonEmpty(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; => 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<T>(IEnumerable<IGrouping<string, T>> groups, Func<T, decimal> selector, string fallback) private static string BuildTopLabel<T>(IEnumerable<IGrouping<string, T>> groups, Func<T, decimal> selector, string fallback)
{ {
var top = groups var top = groups
@@ -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. - `Offener Bestellwert`: berechnet aus EKET-Offenmenge und EKPO-Netto-Stueckwert.
- `Offene Menge`: Summe offener EKET-Mengen. - `Offene Menge`: Summe offener EKET-Mengen.
- Top-Lieferant, Top-Warengruppe und Top-Artikel werden aus EKPO gruppiert. - 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. - 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. - 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`. - 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`. - 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`. - Der aktuelle SAP-OData-Service liefert produktiv `EKKOSet`, `EKPOSet` und `eketSet`.
- Tests auf `Data`, `Data2`, `DataSet` und `Data2Set` liefern aktuell `404 Resource not found`. - 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. <Nummer> (Name fehlt)` und Warengruppen-Codes statt vollstaendiger Namen.
## Ideen und Kennzahlen-Katalog ## Ideen und Kennzahlen-Katalog