Add purchasing period analytics
This commit is contained in:
@@ -61,6 +61,35 @@
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-3 mb-4 purchasing-filter-panel" Outlined="true">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="2">
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle1">@T("Zeitraum", "Period")</MudText>
|
||||
<MudText Typo="Typo.body2" Class="purchasing-muted">
|
||||
@T("Alle Einkauf-Kennzahlen, Top-Listen und 3D-Daten werden auf diesen Zeitraum abgegrenzt.",
|
||||
"All purchasing KPIs, top lists and 3D data are limited to this period.")
|
||||
</MudText>
|
||||
</div>
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@FilterLabel</MudChip>
|
||||
</MudStack>
|
||||
<div class="purchasing-filter-grid">
|
||||
<label>
|
||||
<span>@T("Von Monat", "From month")</span>
|
||||
<input type="month" value="@_filterFromMonth" @onchange="SetFilterFromMonth" />
|
||||
</label>
|
||||
<label>
|
||||
<span>@T("Bis Monat", "To month")</span>
|
||||
<input type="month" value="@_filterToMonth" @onchange="SetFilterToMonth" />
|
||||
</label>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.FilterAlt" OnClick="ApplyPurchasingFilterAsync" Disabled="_liveLoading">
|
||||
@T("Anwenden", "Apply")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="SetLastThreeYearsAsync" Disabled="_liveLoading">
|
||||
@T("Letzte 3 Jahre", "Last 3 years")
|
||||
</MudButton>
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
<MudGrid Class="mb-4" Spacing="2">
|
||||
@foreach (var card in KpiCards)
|
||||
{
|
||||
@@ -390,6 +419,53 @@
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<PurchasingSection TitleDe="@SelectedIdeaAnalysisTitleDe"
|
||||
TitleEn="@SelectedIdeaAnalysisTitleEn"
|
||||
DescriptionDe="@SelectedIdeaAnalysisDescriptionDe"
|
||||
DescriptionEn="@SelectedIdeaAnalysisDescriptionEn"
|
||||
ChartTitleDe="@SelectedIdeaChartTitleDe"
|
||||
ChartTitleEn="@SelectedIdeaChartTitleEn"
|
||||
Kpis="@SelectedIdeaKpis"
|
||||
ChartRows="@SelectedIdeaChartRows"
|
||||
StatusRows="@SelectedIdeaStatusRows"
|
||||
DetailRows="@SelectedIdeaDetailRows" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3 purchasing-overview-panel" Outlined="true">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">@T("Detail-Hotlist", "Detail hotlist")</MudText>
|
||||
<MudText Typo="Typo.body2" Class="purchasing-muted">
|
||||
@T("Direkt aus dem Einkauf-Cache berechnet, keine Simulation.",
|
||||
"Calculated directly from the purchasing cache, no simulation.")
|
||||
</MudText>
|
||||
</div>
|
||||
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">@SelectedIdeaRows.Count @T("Zeilen", "rows")</MudChip>
|
||||
</MudStack>
|
||||
<MudTable Items="@SelectedIdeaRows" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>@T("Objekt", "Object")</MudTh>
|
||||
<MudTh>@T("Wert", "Value")</MudTh>
|
||||
<MudTh>@T("Detail", "Detail")</MudTh>
|
||||
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
<MudTd><strong>@context.Value</strong></MudTd>
|
||||
<MudTd>@context.Detail</MudTd>
|
||||
<MudTd>
|
||||
<MudChip T="string" Size="Size.Small" Color="@ResolveAnalysisSeverityColor(context.Severity)" Variant="Variant.Outlined">
|
||||
@context.Severity
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
break;
|
||||
case "kennzahlen":
|
||||
@@ -583,6 +659,10 @@
|
||||
private double _purchasing3dFactor = 1d;
|
||||
private double _purchasing3dLabelScale = 1.5d;
|
||||
private string _lastRendered3dUri = string.Empty;
|
||||
private string _filterFromMonth = BuildDefaultFromMonth();
|
||||
private string _filterToMonth = BuildDefaultToMonth();
|
||||
|
||||
private string FilterLabel => $"{_filterFromMonth} - {_filterToMonth}";
|
||||
|
||||
private string CurrentPurchasingPage
|
||||
{
|
||||
@@ -605,7 +685,7 @@
|
||||
private IReadOnlyList<PurchasingKpiCard> KpiCards =>
|
||||
[
|
||||
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 seit Jahresbeginn" : "Noch nicht geladen", _liveState.EkkoLoaded ? "EKKO orders since start of year" : "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("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)
|
||||
];
|
||||
@@ -654,7 +734,7 @@
|
||||
|
||||
private IReadOnlyList<PurchasingPipelineStep> PipelineRows =>
|
||||
[
|
||||
new("EKKO Bestellkopf", "EKKO purchase header", _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-", _liveState.EkkoLoaded ? "Bestellungen seit Jahresbeginn sind live verfuegbar" : "Bestellkopf wartet auf SAP", _liveState.EkkoLoaded ? "Orders since start of year are available live" : "Purchase header is waiting for SAP", Icons.Material.Filled.ReceiptLong, _liveState.EkkoLoaded, _liveState.EkkoLoaded ? Color.Success : Color.Warning),
|
||||
new("EKKO Bestellkopf", "EKKO purchase header", _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-", _liveState.EkkoLoaded ? $"Bestellungen im Zeitraum {FilterLabel}" : "Bestellkopf wartet auf SAP", _liveState.EkkoLoaded ? $"Orders in period {FilterLabel}" : "Purchase header is waiting for SAP", Icons.Material.Filled.ReceiptLong, _liveState.EkkoLoaded, _liveState.EkkoLoaded ? Color.Success : Color.Warning),
|
||||
new("EKPO Positionen", "EKPO item rows", _liveState.EkpoLoaded ? $"{_liveState.PositionSampleCount:N0}" : "0", _liveState.EkpoLoaded ? "Spend, Artikel und Warengruppen koennen echt berechnet werden" : "Spend und offene Werte bleiben Simulation", _liveState.EkpoLoaded ? "Spend, articles and material groups can be calculated real" : "Spend and open values remain simulation", Icons.Material.Filled.Inventory2, _liveState.EkpoLoaded, _liveState.EkpoLoaded ? Color.Success : Color.Warning),
|
||||
new("EKET Termine", "EKET schedules", _liveState.EketLoaded ? $"{_liveState.ScheduleSampleCount:N0}" : "0", _liveState.EketLoaded ? "Faelligkeiten und Terminstatus koennen echt berechnet werden" : "Faelligkeiten und Kontrakte warten auf SAP", _liveState.EketLoaded ? "Due dates and schedule status can be calculated real" : "Due dates and contracts wait for SAP", Icons.Material.Filled.EventAvailable, _liveState.EketLoaded, _liveState.EketLoaded ? Color.Success : Color.Warning),
|
||||
new("Dashboard Layer", "Dashboard layer", _liveState.EkkoLoaded ? "aktiv" : "bereit", "Livewerte, Simulation und 3D-Analyse werden getrennt ausgewiesen", "Live values, simulation and 3D analysis are shown separately", Icons.Material.Filled.DashboardCustomize, true, Color.Info)
|
||||
@@ -663,7 +743,7 @@
|
||||
private IReadOnlyList<string> ManagementInsights =>
|
||||
[
|
||||
_liveState.EkkoLoaded
|
||||
? T($"{_liveState.PurchaseOrderCount:N0} Einkaufsbelege sind fuer 2026 live im Cockpit.", $"{_liveState.PurchaseOrderCount:N0} purchase orders are live in the cockpit for 2026.")
|
||||
? T($"{_liveState.PurchaseOrderCount:N0} Einkaufsbelege sind im Zeitraum {FilterLabel} im Cockpit.", $"{_liveState.PurchaseOrderCount:N0} purchase orders are in the cockpit for {FilterLabel}.")
|
||||
: T("EKKO wird geladen; danach erscheinen Bestellungen und Lieferanten live.", "EKKO is loading; orders and suppliers appear live afterwards."),
|
||||
_liveState.EkpoLoaded
|
||||
? T("Spend, Artikel und Warengruppen koennen nun aus SAP-Positionen kommen.", "Spend, articles and material groups can now come from SAP item rows.")
|
||||
@@ -684,7 +764,7 @@
|
||||
|
||||
private IReadOnlyList<PurchasingSectionKpi> OpenOrderKpis =>
|
||||
[
|
||||
new("Bestellungen", "Orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"),
|
||||
new("Bestellungen", "Orders", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", $"EKKO im Zeitraum {FilterLabel}", $"EKKO in period {FilterLabel}"),
|
||||
new("Lieferanten", "Suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "aus EKKO-Liveprobe", "from EKKO live sample"),
|
||||
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")
|
||||
@@ -700,7 +780,7 @@
|
||||
|
||||
private IReadOnlyList<PurchasingSectionKpi> SupplierKpis =>
|
||||
[
|
||||
new("Aktive Lieferanten", "Active suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", "EKKO live seit Jahresbeginn", "EKKO live since start of year"),
|
||||
new("Aktive Lieferanten", "Active suppliers", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", $"EKKO im Zeitraum {FilterLabel}", $"EKKO in period {FilterLabel}"),
|
||||
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 ? 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")
|
||||
@@ -1096,6 +1176,10 @@
|
||||
new("openValue", "Offener Bestellwert", "Open order value", "CHF"),
|
||||
new("openQuantity", "Offene Menge", "Open quantity", "Qty"),
|
||||
new("contractValue", "Kontrakt-Restwert", "Contract remaining value", "CHF"),
|
||||
new("deliveryRisk", "Liefertermin-Risiko", "Delivery due-date risk", "CHF"),
|
||||
new("priceVariance", "Preisabweichung", "Price variance", "CHF"),
|
||||
new("spendConcentration", "Spend-Konzentration", "Spend concentration", "CHF"),
|
||||
new("dataQuality", "Datenqualitaet", "Data quality", "Issues"),
|
||||
new("supplierScore", "Lieferantenperformance", "Supplier performance", "%")
|
||||
];
|
||||
|
||||
@@ -1133,6 +1217,152 @@
|
||||
private IReadOnlyList<PurchasingSectionChartRow> SupplierChartRows
|
||||
=> BuildPurchasingChartRows(x => x.SupplierScore, value => $"{value:N1}%");
|
||||
|
||||
private IReadOnlyList<PurchasingIdeaAnalysisRow> SelectedIdeaRows => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => _liveState.DeliveryRiskRows,
|
||||
"ideen/preisabweichung" => _liveState.PriceVarianceRows,
|
||||
"ideen/spend-konzentration" => _liveState.SpendConcentrationRows,
|
||||
"ideen/datenqualitaet" => _liveState.DataQualityRows,
|
||||
_ => []
|
||||
};
|
||||
|
||||
private IReadOnlyList<PurchasingSectionChartRow> SelectedIdeaChartRows => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => BuildLiveChartRows(_liveState.DeliveryRiskChartRows, FormatChf),
|
||||
"ideen/preisabweichung" => BuildLiveChartRows(_liveState.PriceVarianceChartRows, FormatChf),
|
||||
"ideen/spend-konzentration" => BuildLiveChartRows(_liveState.SpendConcentrationChartRows, FormatChf),
|
||||
"ideen/datenqualitaet" => BuildLiveChartRows(_liveState.DataQualityChartRows, value => value.ToString("N0")),
|
||||
_ => []
|
||||
};
|
||||
|
||||
private IReadOnlyList<PurchasingSectionKpi> SelectedIdeaKpis => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" =>
|
||||
[
|
||||
new("Ueberfaellig", "Overdue", FormatChartValue("Ueberfaellig", _liveState.DeliveryRiskChartRows, FormatChf), "offener Wert", "open value"),
|
||||
new("0-7 Tage", "0-7 days", FormatChartValue("0-7 Tage", _liveState.DeliveryRiskChartRows, FormatChf), "kurzfristig faellig", "due short-term"),
|
||||
new("Hotlist", "Hotlist", _liveState.DeliveryRiskRows.Count.ToString("N0"), "Lieferant / Artikel", "supplier / article"),
|
||||
new("Datenbasis", "Data basis", _liveState.EketLoaded ? "EKET Cache" : "-", "offene Mengen", "open quantities")
|
||||
],
|
||||
"ideen/preisabweichung" =>
|
||||
[
|
||||
new("Preissteigerungen", "Price increases", _liveState.PriceVarianceRows.Count.ToString("N0"), "2026 gegen 2025", "2026 vs 2025"),
|
||||
new("Top Wirkung", "Top impact", _liveState.PriceVarianceChartRows.Count > 0 ? FormatChf(_liveState.PriceVarianceChartRows.Max(x => x.Value)) : "-", "CHF Effekt", "CHF effect"),
|
||||
new("Lieferanten", "Suppliers", _liveState.PriceVarianceChartRows.Count.ToString("N0"), "mit Abweichung", "with variance"),
|
||||
new("Datenbasis", "Data basis", _liveState.EkpoLoaded ? "EKPO + EKKO" : "-", "Stueckpreis", "unit price")
|
||||
],
|
||||
"ideen/spend-konzentration" =>
|
||||
[
|
||||
new("Top 10 Spend", "Top 10 spend", FormatChf(_liveState.SpendConcentrationChartRows.Sum(x => x.Value)), "Lieferanten-Pareto", "supplier pareto"),
|
||||
new("Top Anteil", "Top share", FormatTopShare(), "vom Gesamtspend", "of total spend"),
|
||||
new("Lieferanten", "Suppliers", _liveState.SupplierCount.ToString("N0"), "EKKO Cache", "EKKO cache"),
|
||||
new("Datenbasis", "Data basis", _liveState.EkpoLoaded ? "EKPO + EKKO" : "-", "Spend netto", "net spend")
|
||||
],
|
||||
"ideen/datenqualitaet" =>
|
||||
[
|
||||
new("Pruefungen", "Checks", _liveState.DataQualityRows.Count.ToString("N0"), "Pflichtfelder", "required fields"),
|
||||
new("Fehler gesamt", "Total issues", _liveState.DataQualityChartRows.Sum(x => x.Value).ToString("N0"), "Cache-Zeilen", "cache rows"),
|
||||
new("EKPO", "EKPO", _liveState.PositionSampleCount.ToString("N0"), "Positionen", "items"),
|
||||
new("EKKO", "EKKO", _liveState.PurchaseOrderCount.ToString("N0"), "Bestellkoepfe", "headers")
|
||||
],
|
||||
_ => []
|
||||
};
|
||||
|
||||
private IReadOnlyList<PurchasingSectionStatusRow> SelectedIdeaStatusRows =>
|
||||
[
|
||||
BuildStatus("Cache", "Cache", _liveState.UsesCache, _liveState.UsesCache ? _liveState.CacheStatus : "leer"),
|
||||
BuildStatus("EKKO", "EKKO", _liveState.EkkoLoaded, _liveState.PurchaseOrderCount.ToString("N0")),
|
||||
BuildStatus("EKPO", "EKPO", _liveState.EkpoLoaded, _liveState.PositionSampleCount.ToString("N0")),
|
||||
BuildStatus("EKET", "EKET", _liveState.EketLoaded, _liveState.ScheduleSampleCount.ToString("N0"))
|
||||
];
|
||||
|
||||
private IReadOnlyList<PurchasingSectionDetailRow> SelectedIdeaDetailRows => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" =>
|
||||
[
|
||||
new("Faelligkeits-Buckets", "Due-date buckets", $"{_liveState.DeliveryRiskChartRows.Count:N0}", "EKET.Eindt", "SAP live"),
|
||||
new("Offener Wert", "Open value", FormatChf(_liveState.OpenValueSample), "EKET/EKPO", "SAP live"),
|
||||
new("Hotlist", "Hotlist", $"{_liveState.DeliveryRiskRows.Count:N0}", "Lieferant / Artikel", "SAP live"),
|
||||
new("Berechnung", "Calculation", "Menge - WEMNG", "EKET", "SAP live")
|
||||
],
|
||||
"ideen/preisabweichung" =>
|
||||
[
|
||||
new("Vergleich", "Comparison", "2026 vs 2025", "EKKO.Bedat", "SAP live"),
|
||||
new("Preis", "Price", "NETWR / MENGE", "EKPO", "SAP live"),
|
||||
new("CHF Wirkung", "CHF impact", FormatChf(_liveState.PriceVarianceChartRows.Sum(x => x.Value)), "Preisdelta * Menge", "SAP live"),
|
||||
new("Hotlist", "Hotlist", $"{_liveState.PriceVarianceRows.Count:N0}", "Lieferant / Artikel", "SAP live")
|
||||
],
|
||||
"ideen/spend-konzentration" =>
|
||||
[
|
||||
new("Gesamtspend", "Total spend", FormatChf(_liveState.SpendChfSample), "EKPO.Netwr", "SAP live"),
|
||||
new("Top Lieferanten", "Top suppliers", $"{_liveState.SpendConcentrationRows.Count:N0}", "EKKO.Lifnr", "SAP live"),
|
||||
new("Top Anteil", "Top share", FormatTopShare(), "Pareto", "SAP live"),
|
||||
new("Warengruppen", "Material groups", TopMaterialGroupLabel, "EKPO.Matkl", "SAP live")
|
||||
],
|
||||
"ideen/datenqualitaet" =>
|
||||
[
|
||||
new("Fehlender Lieferant", "Missing supplier", FormatChartValue("fehlender Lieferant", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKKO.Lifnr", "SAP live"),
|
||||
new("Fehlende Warengruppe", "Missing material group", FormatChartValue("fehlende Warengruppe", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Matkl", "SAP live"),
|
||||
new("Nullmenge", "Zero quantity", FormatChartValue("Nullmenge", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Menge", "SAP live"),
|
||||
new("Nullwert", "Zero value", FormatChartValue("Nullwert", _liveState.DataQualityChartRows, value => value.ToString("N0")), "EKPO.Netwr", "SAP live")
|
||||
],
|
||||
_ => []
|
||||
};
|
||||
|
||||
private string SelectedIdeaAnalysisTitleDe => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Liefertermin-Risiko produktiv",
|
||||
"ideen/preisabweichung" => "Preisabweichung produktiv",
|
||||
"ideen/spend-konzentration" => "Spend-Konzentration produktiv",
|
||||
"ideen/datenqualitaet" => "Datenqualitaet produktiv",
|
||||
_ => "Einkauf Analyse"
|
||||
};
|
||||
|
||||
private string SelectedIdeaAnalysisTitleEn => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Delivery due-date risk productive",
|
||||
"ideen/preisabweichung" => "Price variance productive",
|
||||
"ideen/spend-konzentration" => "Spend concentration productive",
|
||||
"ideen/datenqualitaet" => "Data quality productive",
|
||||
_ => "Purchasing analysis"
|
||||
};
|
||||
|
||||
private string SelectedIdeaAnalysisDescriptionDe => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Offene EKET-Mengen werden bewertet und nach Faelligkeit, Lieferant und Artikel priorisiert.",
|
||||
"ideen/preisabweichung" => "Netto-Stueckpreise werden 2026 gegen 2025 verglichen und nach CHF-Wirkung sortiert.",
|
||||
"ideen/spend-konzentration" => "Lieferantenspend wird als Pareto ausgewertet, um Abhaengigkeit und Buendelungspotenzial zu zeigen.",
|
||||
"ideen/datenqualitaet" => "Pflichtfelder und Nullwerte im Einkauf-Cache werden als Qualitaetsampel gezaehlt.",
|
||||
_ => "Echte Analyse aus dem Einkauf-Cache."
|
||||
};
|
||||
|
||||
private string SelectedIdeaAnalysisDescriptionEn => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Open EKET quantities are valued and prioritised by due date, supplier and article.",
|
||||
"ideen/preisabweichung" => "Net unit prices are compared 2026 against 2025 and sorted by CHF impact.",
|
||||
"ideen/spend-konzentration" => "Supplier spend is evaluated as pareto to show dependency and bundling potential.",
|
||||
"ideen/datenqualitaet" => "Required fields and zero values in the purchasing cache are counted as quality indicators.",
|
||||
_ => "Real analysis from the purchasing cache."
|
||||
};
|
||||
|
||||
private string SelectedIdeaChartTitleDe => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Offener Wert nach Faelligkeit",
|
||||
"ideen/preisabweichung" => "Preissteigerungswirkung nach Lieferant",
|
||||
"ideen/spend-konzentration" => "Top Lieferanten Spend",
|
||||
"ideen/datenqualitaet" => "Datenqualitaetsfehler",
|
||||
_ => "Analyse"
|
||||
};
|
||||
|
||||
private string SelectedIdeaChartTitleEn => CurrentPurchasingPage switch
|
||||
{
|
||||
"ideen/liefertermin-risiko" => "Open value by due date",
|
||||
"ideen/preisabweichung" => "Price increase impact by supplier",
|
||||
"ideen/spend-konzentration" => "Top supplier spend",
|
||||
"ideen/datenqualitaet" => "Data quality issues",
|
||||
_ => "Analysis"
|
||||
};
|
||||
|
||||
private IReadOnlyList<PurchasingSectionStatusRow> SpendStatusRows =>
|
||||
[
|
||||
BuildStatus("EKKO Bestellkoepfe", "EKKO purchase headers", _liveState.EkkoLoaded, _liveState.EkkoLoaded ? $"{_liveState.PurchaseOrderCount:N0}" : "-"),
|
||||
@@ -1171,7 +1401,7 @@
|
||||
|
||||
private IReadOnlyList<PurchasingSectionDetailRow> OpenOrderDetailRows =>
|
||||
[
|
||||
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("Bestellungen im Zeitraum", "Orders in period", _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.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")
|
||||
@@ -1196,7 +1426,7 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Navigation.LocationChanged += HandleLocationChanged;
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
|
||||
_refreshStatus = await PurchasingDataRefreshService.GetStatusAsync();
|
||||
_liveLoading = false;
|
||||
}
|
||||
@@ -1226,6 +1456,77 @@
|
||||
private string T(string german, string english) => UiText.Text(german, english);
|
||||
private static string FormatChf(double value) => $"CHF {value:N0}";
|
||||
private static string FormatChf(decimal value) => $"CHF {value:N0}";
|
||||
private static string BuildDefaultFromMonth()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
return $"{today.Year - 2}-01";
|
||||
}
|
||||
|
||||
private static string BuildDefaultToMonth()
|
||||
=> DateTime.Today.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
|
||||
private PurchasingDashboardFilter BuildCurrentFilter()
|
||||
{
|
||||
var from = ParseMonth(_filterFromMonth, new DateTime(DateTime.Today.Year - 2, 1, 1));
|
||||
var toMonth = ParseMonth(_filterToMonth, DateTime.Today);
|
||||
var to = new DateTime(toMonth.Year, toMonth.Month, DateTime.DaysInMonth(toMonth.Year, toMonth.Month));
|
||||
if (to < from)
|
||||
from = new DateTime(to.Year, 1, 1);
|
||||
|
||||
if (to > DateTime.Today)
|
||||
to = DateTime.Today;
|
||||
|
||||
return new PurchasingDashboardFilter(from, to);
|
||||
}
|
||||
|
||||
private static DateTime ParseMonth(string value, DateTime fallback)
|
||||
=> DateTime.TryParseExact(value, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? new DateTime(parsed.Year, parsed.Month, 1)
|
||||
: fallback;
|
||||
|
||||
private void SetFilterFromMonth(ChangeEventArgs args)
|
||||
=> _filterFromMonth = Convert.ToString(args.Value, CultureInfo.InvariantCulture) ?? _filterFromMonth;
|
||||
|
||||
private void SetFilterToMonth(ChangeEventArgs args)
|
||||
=> _filterToMonth = Convert.ToString(args.Value, CultureInfo.InvariantCulture) ?? _filterToMonth;
|
||||
|
||||
private async Task ApplyPurchasingFilterAsync()
|
||||
{
|
||||
_liveLoading = true;
|
||||
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
|
||||
_liveLoading = false;
|
||||
if (CurrentPurchasingPage == "3d")
|
||||
await RenderPurchasing3dAsync();
|
||||
}
|
||||
|
||||
private async Task SetLastThreeYearsAsync()
|
||||
{
|
||||
_filterFromMonth = BuildDefaultFromMonth();
|
||||
_filterToMonth = BuildDefaultToMonth();
|
||||
await ApplyPurchasingFilterAsync();
|
||||
}
|
||||
|
||||
private static string FormatChartValue(string label, IReadOnlyList<PurchasingLiveChartPoint> rows, Func<decimal, string> formatter)
|
||||
=> formatter(rows.FirstOrDefault(row => row.Label.Equals(label, StringComparison.OrdinalIgnoreCase))?.Value ?? 0m);
|
||||
|
||||
private string FormatTopShare()
|
||||
{
|
||||
if (_liveState.SpendChfSample <= 0)
|
||||
return "-";
|
||||
|
||||
var share = _liveState.SpendConcentrationChartRows.Sum(row => row.Value) / _liveState.SpendChfSample * 100m;
|
||||
return $"{share:N1}%";
|
||||
}
|
||||
|
||||
private static Color ResolveAnalysisSeverityColor(string severity)
|
||||
=> severity switch
|
||||
{
|
||||
"High" => Color.Error,
|
||||
"Medium" => Color.Warning,
|
||||
"Low" => Color.Success,
|
||||
_ => Color.Info
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -1257,7 +1558,7 @@
|
||||
try
|
||||
{
|
||||
_refreshStatus = await PurchasingDataRefreshService.RunFullLoadAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1271,7 +1572,7 @@
|
||||
try
|
||||
{
|
||||
_refreshStatus = await PurchasingDataRefreshService.RunDeltaAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync(BuildCurrentFilter());
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1396,7 +1697,23 @@
|
||||
}
|
||||
|
||||
private IReadOnlyList<object> BuildPurchasing3dRows()
|
||||
=> Purchasing3dBaseRows
|
||||
{
|
||||
var liveRows = ResolvePurchasing3dLiveRows();
|
||||
if (liveRows.Count > 0)
|
||||
{
|
||||
var year = _liveState.PeriodTo?.Year ?? DateTime.Today.Year;
|
||||
return liveRows
|
||||
.Select(row => new
|
||||
{
|
||||
country = row.Label,
|
||||
year,
|
||||
value = (double)row.Value
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Purchasing3dBaseRows
|
||||
.Select(row => new
|
||||
{
|
||||
country = row.Axis,
|
||||
@@ -1405,6 +1722,19 @@
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IReadOnlyList<PurchasingLiveChartPoint> ResolvePurchasing3dLiveRows() => _purchasing3dIndicator switch
|
||||
{
|
||||
"spend" => _liveState.SpendChartRows,
|
||||
"openValue" => _liveState.OpenValueChartRows,
|
||||
"contractValue" => _liveState.ContractChartRows,
|
||||
"deliveryRisk" => _liveState.DeliveryRiskChartRows,
|
||||
"priceVariance" => _liveState.PriceVarianceChartRows,
|
||||
"spendConcentration" => _liveState.SpendConcentrationChartRows,
|
||||
"dataQuality" => _liveState.DataQualityChartRows,
|
||||
_ => []
|
||||
};
|
||||
|
||||
private double ResolvePurchasing3dValue(Purchasing3dBaseRow row) => _purchasing3dIndicator switch
|
||||
{
|
||||
@@ -1415,7 +1745,7 @@
|
||||
_ => row.Spend
|
||||
};
|
||||
|
||||
private bool ScenarioAffectsPurchasingValue => _purchasing3dIndicator is "spend" or "openValue" or "contractValue";
|
||||
private bool ScenarioAffectsPurchasingValue => _purchasing3dIndicator is "spend" or "openValue" or "contractValue" or "deliveryRisk" or "priceVariance" or "spendConcentration";
|
||||
|
||||
private string ResolvePurchasing3dIndicatorLabel()
|
||||
=> T(
|
||||
@@ -1427,7 +1757,10 @@
|
||||
if (!ScenarioAffectsPurchasingValue)
|
||||
return T("nicht auf diesen Indikator angewendet", "not applied to this indicator");
|
||||
|
||||
var baseTotal = Purchasing3dBaseRows.Sum(ResolvePurchasing3dValue);
|
||||
var liveRows = ResolvePurchasing3dLiveRows();
|
||||
var baseTotal = liveRows.Count > 0
|
||||
? (double)liveRows.Sum(row => row.Value)
|
||||
: Purchasing3dBaseRows.Sum(ResolvePurchasing3dValue);
|
||||
var delta = baseTotal * _purchasing3dFactor - baseTotal;
|
||||
return $"{delta:N0} {Purchasing3dIndicators.First(x => x.Key == _purchasing3dIndicator).Unit}";
|
||||
}
|
||||
|
||||
@@ -304,13 +304,20 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var ekpoIndex = conn.CreateCommand();
|
||||
ekpoIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);";
|
||||
ekpoIndex.ExecuteNonQuery();
|
||||
|
||||
using var eketDateIndex = conn.CreateCommand();
|
||||
eketDateIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);";
|
||||
eketDateIndex.ExecuteNonQuery();
|
||||
foreach (var indexSql in new[]
|
||||
{
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Bedat ON PurchasingEkkoCache (Bedat);",
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Lifnr ON PurchasingEkkoCache (Lifnr);",
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Ebeln ON PurchasingEkpoCache (Ebeln);",
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);",
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);",
|
||||
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_EbelnEbelp ON PurchasingEketCache (Ebeln, Ebelp);"
|
||||
})
|
||||
{
|
||||
using var indexCommand = conn.CreateCommand();
|
||||
indexCommand.CommandText = indexSql;
|
||||
indexCommand.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSapSourceTable(AppDbContext db)
|
||||
|
||||
@@ -2,7 +2,12 @@ namespace TrafagSalesExporter.Services;
|
||||
|
||||
public interface IPurchasingDashboardService
|
||||
{
|
||||
Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default);
|
||||
Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PurchasingDashboardFilter(DateTime FromDate, DateTime ToDate)
|
||||
{
|
||||
public string Label => $"{FromDate:yyyy-MM-dd} bis {ToDate:yyyy-MM-dd}";
|
||||
}
|
||||
|
||||
public sealed class PurchasingDashboardLiveState
|
||||
@@ -19,6 +24,8 @@ public sealed class PurchasingDashboardLiveState
|
||||
public bool UsesCache { get; set; }
|
||||
public string CacheStatus { get; set; } = string.Empty;
|
||||
public DateTime? CacheCompletedAtUtc { get; set; }
|
||||
public DateTime? PeriodFrom { get; set; }
|
||||
public DateTime? PeriodTo { get; set; }
|
||||
public decimal SpendChfSample { get; set; }
|
||||
public decimal OpenQuantitySample { get; set; }
|
||||
public decimal OpenValueSample { get; set; }
|
||||
@@ -29,7 +36,16 @@ public sealed class PurchasingDashboardLiveState
|
||||
public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> DeliveryRiskChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> PriceVarianceChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> SpendConcentrationChartRows { get; set; } = [];
|
||||
public List<PurchasingLiveChartPoint> DataQualityChartRows { get; set; } = [];
|
||||
public List<PurchasingIdeaAnalysisRow> DeliveryRiskRows { get; set; } = [];
|
||||
public List<PurchasingIdeaAnalysisRow> PriceVarianceRows { get; set; } = [];
|
||||
public List<PurchasingIdeaAnalysisRow> SpendConcentrationRows { get; set; } = [];
|
||||
public List<PurchasingIdeaAnalysisRow> DataQualityRows { get; set; } = [];
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record PurchasingLiveChartPoint(string Label, decimal Value);
|
||||
public sealed record PurchasingIdeaAnalysisRow(string Label, string Value, string Detail, string Severity);
|
||||
|
||||
@@ -17,14 +17,23 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
public async Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default)
|
||||
private static PurchasingDashboardFilter BuildDefaultFilter()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today);
|
||||
}
|
||||
|
||||
public async Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = new PurchasingDashboardLiveState();
|
||||
filter ??= BuildDefaultFilter();
|
||||
state.PeriodFrom = filter.FromDate;
|
||||
state.PeriodTo = filter.ToDate;
|
||||
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
if (await TryLoadCacheStateAsync(db, state, cancellationToken))
|
||||
if (await TryLoadCacheStateAsync(db, state, filter, cancellationToken))
|
||||
return state;
|
||||
|
||||
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken);
|
||||
@@ -108,16 +117,22 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
return state;
|
||||
}
|
||||
|
||||
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, CancellationToken cancellationToken)
|
||||
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, PurchasingDashboardFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
var conn = (SqliteConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
var ekkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
|
||||
var ekpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
|
||||
var eketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
|
||||
if (ekkoRows <= 0 || ekpoRows <= 0 || eketRows <= 0)
|
||||
var from = filter.FromDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
var to = filter.ToDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
var ekkoPeriod = $"Bedat >= '{from}' AND Bedat <= '{to}'";
|
||||
var joinedEkkoPeriod = $"k.Bedat >= '{from}' AND k.Bedat <= '{to}'";
|
||||
var eketPeriod = $"e.Eindt >= '{from}' AND e.Eindt <= '{to}'";
|
||||
|
||||
var cacheEkkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
|
||||
var cacheEkpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
|
||||
var cacheEketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
|
||||
if (cacheEkkoRows <= 0 || cacheEkpoRows <= 0 || cacheEketRows <= 0)
|
||||
return false;
|
||||
|
||||
var latestStatus = await ReadCacheStatusAsync(conn, cancellationToken);
|
||||
@@ -126,47 +141,57 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
state.EkkoLoaded = true;
|
||||
state.EkpoLoaded = true;
|
||||
state.EketLoaded = true;
|
||||
state.PurchaseOrderCount = ekkoRows;
|
||||
state.PositionSampleCount = ekpoRows;
|
||||
state.ScheduleSampleCount = eketRows;
|
||||
state.SupplierCount = await ExecuteScalarIntAsync(conn, "SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '';", cancellationToken);
|
||||
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, "SELECT MAX(Bedat) FROM PurchasingEkkoCache;", cancellationToken);
|
||||
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, "SELECT COALESCE(SUM(CAST(Netwr AS REAL)), 0) FROM PurchasingEkpoCache WHERE Loekz = '';", cancellationToken);
|
||||
state.OpenQuantitySample = await ExecuteScalarDecimalAsync(conn, "SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0)), 0) FROM PurchasingEketCache e;", cancellationToken);
|
||||
state.PurchaseOrderCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
|
||||
state.PositionSampleCount = await ExecuteScalarIntAsync(conn, $@"
|
||||
SELECT COUNT(1)
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE {joinedEkkoPeriod};", cancellationToken);
|
||||
state.ScheduleSampleCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
|
||||
state.SupplierCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '' AND {ekkoPeriod};", cancellationToken);
|
||||
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, $"SELECT MAX(Bedat) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
|
||||
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, $@"
|
||||
SELECT COALESCE(SUM(CAST(p.Netwr AS REAL)), 0)
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND {joinedEkkoPeriod};", cancellationToken);
|
||||
state.OpenQuantitySample = await ExecuteScalarDecimalAsync(conn, $"SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0)), 0) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
|
||||
state.OpenValueSample = await ExecuteScalarDecimalAsync(conn, @"
|
||||
SELECT COALESCE(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), 0)
|
||||
FROM PurchasingEketCache e
|
||||
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
|
||||
WHERE COALESCE(p.Loekz, '') = '';", cancellationToken);
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + ";", cancellationToken);
|
||||
state.ContractValueSample = state.OpenValueSample;
|
||||
state.TopSupplierLabel = await ExecuteTopLabelAsync(conn, @"
|
||||
SELECT COALESCE(k.Lifnr, 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE p.Loekz = ''
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(k.Lifnr, 'ohne Lieferant')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 1;", "Lieferant", cancellationToken);
|
||||
state.TopMaterialGroupLabel = await ExecuteTopLabelAsync(conn, @"
|
||||
SELECT COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
|
||||
FROM PurchasingEkpoCache
|
||||
WHERE Loekz = ''
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 1;", "Warengruppe", cancellationToken);
|
||||
state.TopArticleLabel = await ExecuteTopLabelAsync(conn, @"
|
||||
SELECT COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
|
||||
FROM PurchasingEkpoCache
|
||||
WHERE Loekz = ''
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 1;", "Artikel", cancellationToken);
|
||||
state.SpendChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
SELECT 'Lief. ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
|
||||
SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE p.Loekz = ''
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 6;", cancellationToken);
|
||||
@@ -176,17 +201,164 @@ SELECT COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label,
|
||||
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
|
||||
WHERE COALESCE(p.Loekz, '') = ''
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @"
|
||||
GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
|
||||
ORDER BY Label
|
||||
LIMIT 6;", cancellationToken);
|
||||
state.ContractChartRows = state.OpenValueChartRows.ToList();
|
||||
await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken);
|
||||
state.CacheStatus = latestStatus.Status;
|
||||
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
|
||||
state.Message = $"Einkauf Cache geladen: EKKO={ekkoRows:N0}, EKPO={ekpoRows:N0}, EKET={eketRows:N0}. {latestStatus.Message}";
|
||||
state.Message = $"Einkauf Cache geladen fuer {filter.Label}: EKKO={state.PurchaseOrderCount:N0}, EKPO={state.PositionSampleCount:N0}, EKET={state.ScheduleSampleCount:N0}. {latestStatus.Message}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task ApplyIdeaAnalyticsAsync(SqliteConnection conn, PurchasingDashboardLiveState state, string joinedEkkoPeriod, string eketPeriod, CancellationToken cancellationToken)
|
||||
{
|
||||
state.DeliveryRiskChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
WITH open_rows AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN date(e.Eindt) < date('now', 'localtime') THEN 'Ueberfaellig'
|
||||
WHEN date(e.Eindt) <= date('now', 'localtime', '+7 day') THEN '0-7 Tage'
|
||||
WHEN date(e.Eindt) <= date('now', 'localtime', '+30 day') THEN '8-30 Tage'
|
||||
ELSE 'Spaeter'
|
||||
END AS Label,
|
||||
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 OpenValue
|
||||
FROM PurchasingEketCache e
|
||||
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
|
||||
)
|
||||
SELECT Label, SUM(OpenValue) AS Value
|
||||
FROM open_rows
|
||||
GROUP BY Label
|
||||
ORDER BY CASE Label WHEN 'Ueberfaellig' THEN 1 WHEN '0-7 Tage' THEN 2 WHEN '8-30 Tage' THEN 3 ELSE 4 END;", cancellationToken);
|
||||
state.DeliveryRiskRows = await ExecuteAnalysisRowsAsync(conn, @"
|
||||
SELECT
|
||||
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') || ' / ' || COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Label,
|
||||
'CHF ' || printf('%,.0f', 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,
|
||||
'Faellig ' || COALESCE(MIN(e.Eindt), 'ohne Termin') AS Detail,
|
||||
CASE WHEN MIN(date(e.Eindt)) < date('now', 'localtime') THEN 'High' ELSE 'Medium' END AS Severity
|
||||
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')
|
||||
ORDER BY 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) DESC
|
||||
LIMIT 10;", cancellationToken);
|
||||
|
||||
state.PriceVarianceRows = await ExecuteAnalysisRowsAsync(conn, @"
|
||||
WITH priced AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Supplier,
|
||||
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
|
||||
substr(k.Bedat, 1, 4) AS Year,
|
||||
SUM(CAST(p.Netwr AS REAL)) AS Value,
|
||||
SUM(CAST(p.Menge AS REAL)) AS Quantity
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY Supplier, Article, Year
|
||||
),
|
||||
current_year AS (
|
||||
SELECT Supplier, Article, Value, Quantity, Value / Quantity AS UnitPrice
|
||||
FROM priced
|
||||
WHERE Year = '2026'
|
||||
),
|
||||
previous_year AS (
|
||||
SELECT Supplier, Article, Value / Quantity AS UnitPrice
|
||||
FROM priced
|
||||
WHERE Year = '2025' AND Quantity > 0
|
||||
)
|
||||
SELECT
|
||||
c.Supplier || ' / ' || c.Article AS Label,
|
||||
printf('%.1f%%', ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) * 100.0) AS Value,
|
||||
'Wirkung CHF ' || printf('%,.0f', (c.UnitPrice - p.UnitPrice) * c.Quantity) AS Detail,
|
||||
CASE WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.10 THEN 'High'
|
||||
WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.03 THEN 'Medium'
|
||||
ELSE 'Low' END AS Severity
|
||||
FROM current_year c
|
||||
JOIN previous_year p ON p.Supplier = c.Supplier AND p.Article = c.Article
|
||||
WHERE p.UnitPrice > 0 AND c.UnitPrice > p.UnitPrice
|
||||
ORDER BY (c.UnitPrice - p.UnitPrice) * c.Quantity DESC
|
||||
LIMIT 10;", cancellationToken);
|
||||
state.PriceVarianceChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
WITH priced AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Supplier,
|
||||
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
|
||||
substr(k.Bedat, 1, 4) AS Year,
|
||||
SUM(CAST(p.Netwr AS REAL)) AS Value,
|
||||
SUM(CAST(p.Menge AS REAL)) AS Quantity
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY Supplier, Article, Year
|
||||
),
|
||||
delta AS (
|
||||
SELECT c.Supplier, (c.Value / c.Quantity - p.Value / p.Quantity) * c.Quantity AS Impact
|
||||
FROM priced c
|
||||
JOIN priced p ON p.Supplier = c.Supplier AND p.Article = c.Article
|
||||
WHERE c.Year = '2026' AND p.Year = '2025' AND p.Quantity > 0 AND c.Quantity > 0 AND c.Value / c.Quantity > p.Value / p.Quantity
|
||||
)
|
||||
SELECT Supplier, SUM(Impact) AS Value
|
||||
FROM delta
|
||||
GROUP BY Supplier
|
||||
ORDER BY Value DESC
|
||||
LIMIT 6;", cancellationToken);
|
||||
|
||||
state.SpendConcentrationChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 10;", cancellationToken);
|
||||
var totalSpend = state.SpendChfSample <= 0 ? 1 : state.SpendChfSample;
|
||||
var concentrationRows = await ExecuteAnalysisRowsAsync(conn, @"
|
||||
SELECT
|
||||
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label,
|
||||
'CHF ' || printf('%,.0f', SUM(CAST(p.Netwr AS REAL))) AS Value,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(p.Matkl, ''), 'ohne Warengruppe')) || ' Warengruppen' AS Detail,
|
||||
CASE WHEN SUM(CAST(p.Netwr AS REAL)) > 1000000 THEN 'High'
|
||||
WHEN SUM(CAST(p.Netwr AS REAL)) > 250000 THEN 'Medium'
|
||||
ELSE 'Low' END AS Severity
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
|
||||
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
|
||||
ORDER BY SUM(CAST(p.Netwr AS REAL)) DESC
|
||||
LIMIT 10;", cancellationToken);
|
||||
state.SpendConcentrationRows = concentrationRows
|
||||
.Select((row, index) => row with { Detail = $"{row.Detail} | Rang {index + 1} | Anteil {CalculateSupplierShare(state.SpendConcentrationChartRows, row.Label, totalSpend):N1}%" })
|
||||
.ToList();
|
||||
|
||||
state.DataQualityChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
SELECT 'fehlender Lieferant' AS Label, COUNT(*) AS Value FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'fehlende Warengruppe', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'fehlender Artikel/Text', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'Nullmenge', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
|
||||
UNION ALL
|
||||
SELECT 'Nullwert', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
|
||||
state.DataQualityRows = await ExecuteAnalysisRowsAsync(conn, @"
|
||||
SELECT 'Fehlender Lieferant' AS Label, COUNT(*) || ' Belege' AS Value, 'EKKO.Lifnr leer' AS Detail, CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END AS Severity FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'Fehlende Warengruppe', COUNT(*) || ' Positionen', 'EKPO.Matkl leer', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'Fehlender Artikel/Text', COUNT(*) || ' Positionen', 'EKPO.Matnr und Txz01 leer', CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
|
||||
UNION ALL
|
||||
SELECT 'Nullmenge', COUNT(*) || ' Positionen', 'EKPO.Menge = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
|
||||
UNION ALL
|
||||
SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
|
||||
}
|
||||
|
||||
private static void ApplyEkpoMetrics(
|
||||
PurchasingDashboardLiveState state,
|
||||
List<Dictionary<string, object?>> ekkoRows,
|
||||
@@ -378,6 +550,32 @@ LIMIT 6;", cancellationToken);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task<List<PurchasingIdeaAnalysisRow>> ExecuteAnalysisRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<PurchasingIdeaAnalysisRow>();
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
rows.Add(new PurchasingIdeaAnalysisRow(
|
||||
reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
|
||||
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
||||
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
reader.IsDBNull(3) ? string.Empty : reader.GetString(3)));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static decimal CalculateSupplierShare(IReadOnlyList<PurchasingLiveChartPoint> rows, string supplier, decimal totalSpend)
|
||||
{
|
||||
var value = rows
|
||||
.Where(row => row.Label.Equals(supplier, StringComparison.OrdinalIgnoreCase) || row.Label.Equals($"Lieferant {supplier}", StringComparison.OrdinalIgnoreCase))
|
||||
.Sum(row => row.Value);
|
||||
return totalSpend <= 0 ? 0 : value / totalSpend * 100m;
|
||||
}
|
||||
|
||||
private static async Task<(string Status, DateTime? CompletedAtUtc, string Message)> ReadCacheStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
|
||||
Reference in New Issue
Block a user