Add purchasing full load cache
This commit is contained in:
@@ -4,6 +4,11 @@
|
||||
@page "/einkauf/kontrakte"
|
||||
@page "/einkauf/lieferanten"
|
||||
@page "/einkauf/ideen"
|
||||
@page "/einkauf/ideen/datenservice"
|
||||
@page "/einkauf/ideen/liefertermin-risiko"
|
||||
@page "/einkauf/ideen/preisabweichung"
|
||||
@page "/einkauf/ideen/spend-konzentration"
|
||||
@page "/einkauf/ideen/datenqualitaet"
|
||||
@page "/einkauf/kennzahlen"
|
||||
@page "/einkauf/pbix"
|
||||
@page "/einkauf/3d"
|
||||
@@ -16,6 +21,7 @@
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject NavigationManager Navigation
|
||||
@inject IPurchasingDashboardService PurchasingDashboardService
|
||||
@inject IPurchasingDataRefreshService PurchasingDataRefreshService
|
||||
|
||||
<PageTitle>@T("Einkauf", "Purchasing")</PageTitle>
|
||||
|
||||
@@ -254,6 +260,138 @@
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
break;
|
||||
case "ideen/datenservice":
|
||||
case "ideen/liefertermin-risiko":
|
||||
case "ideen/preisabweichung":
|
||||
case "ideen/spend-konzentration":
|
||||
case "ideen/datenqualitaet":
|
||||
var implementation = SelectedImplementationPackage;
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" lg="4">
|
||||
<MudPaper Class="pa-3 purchasing-overview-panel" Outlined="true">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-3">
|
||||
<div class="purchasing-idea-icon">
|
||||
<MudIcon Icon="@implementation.Icon" Color="@implementation.Color" />
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">@T(implementation.TitleDe, implementation.TitleEn)</MudText>
|
||||
<MudText Typo="Typo.body2" Class="purchasing-muted">@T(implementation.SubtitleDe, implementation.SubtitleEn)</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudChip T="string" Color="@implementation.Color" Variant="Variant.Outlined" Class="mb-3">@T(implementation.StatusDe, implementation.StatusEn)</MudChip>
|
||||
<div class="purchasing-priority-stack">
|
||||
@foreach (var step in implementation.Steps)
|
||||
{
|
||||
<div class="purchasing-priority-row">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
<div>
|
||||
<strong>@T(step.TitleDe, step.TitleEn)</strong>
|
||||
<span>@T(step.DetailDe, step.DetailEn)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="8">
|
||||
<MudPaper Class="pa-3 purchasing-overview-panel" Outlined="true">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsetzung", "Implementation")</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="purchasing-idea-detail-block">
|
||||
<MudText Typo="Typo.subtitle2">@T("Zielbild", "Target state")</MudText>
|
||||
<span>@T(implementation.TargetDe, implementation.TargetEn)</span>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="purchasing-idea-detail-block">
|
||||
<MudText Typo="Typo.subtitle2">@T("Datenbasis", "Data basis")</MudText>
|
||||
<code>@implementation.DataBasis</code>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="purchasing-idea-detail-block">
|
||||
<MudText Typo="Typo.subtitle2">@T("Kennzahlen", "KPIs")</MudText>
|
||||
<span>@T(implementation.KpisDe, implementation.KpisEn)</span>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="purchasing-idea-detail-block">
|
||||
<MudText Typo="Typo.subtitle2">@T("Technische Umsetzung", "Technical implementation")</MudText>
|
||||
<span>@T(implementation.TechnicalDe, implementation.TechnicalEn)</span>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<div class="purchasing-idea-next-step">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlaylistAddCheck" Color="Color.Success" />
|
||||
<span>@T(implementation.NextStepDe, implementation.NextStepEn)</span>
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@if (implementation.Key == "ideen/datenservice")
|
||||
{
|
||||
<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("Refresh Steuerung", "Refresh control")</MudText>
|
||||
<MudText Typo="Typo.body2" Class="purchasing-muted">
|
||||
@T("Full Load baut die korrekte Basis auf. Delta aktualisiert danach nur geaenderte Einkaufsbelege.",
|
||||
"Full load builds the correct base. Delta then updates only changed purchase documents.")
|
||||
</MudText>
|
||||
</div>
|
||||
<MudChip T="string" Color="@ResolveRefreshStatusColor()" Variant="Variant.Outlined">@_refreshStatus.Status</MudChip>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="2" Class="mb-3">
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
|
||||
<MudText Typo="Typo.caption" Class="purchasing-muted">EKKO</MudText>
|
||||
<MudText Typo="Typo.h6">@_refreshStatus.EkkoRows.ToString("N0")</MudText>
|
||||
<MudText Typo="Typo.caption">@T("Bestellkoepfe im Cache", "purchase headers in cache")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
|
||||
<MudText Typo="Typo.caption" Class="purchasing-muted">EKPO</MudText>
|
||||
<MudText Typo="Typo.h6">@_refreshStatus.EkpoRows.ToString("N0")</MudText>
|
||||
<MudText Typo="Typo.caption">@T("Positionen im Cache", "item rows in cache")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
|
||||
<MudText Typo="Typo.caption" Class="purchasing-muted">EKET</MudText>
|
||||
<MudText Typo="Typo.h6">@_refreshStatus.EketRows.ToString("N0")</MudText>
|
||||
<MudText Typo="Typo.caption">@T("Einteilungen im Cache", "schedules in cache")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
|
||||
<MudText Typo="Typo.caption" Class="purchasing-muted">@T("Letzter Stand", "Latest state")</MudText>
|
||||
<MudText Typo="Typo.h6">@FormatRefreshDate(_refreshStatus.CompletedAtUtc)</MudText>
|
||||
<MudText Typo="Typo.caption">@_refreshStatus.Mode</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<MudStack Row="true" Spacing="2" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" Disabled="_refreshBusy" OnClick="RunPurchasingFullLoadAsync">
|
||||
@T("Full Load starten", "Start full load")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshBusy" OnClick="RunPurchasingDeltaAsync">
|
||||
@T("Delta aktualisieren", "Refresh delta")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
@if (_refreshBusy)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
|
||||
}
|
||||
<MudText Typo="Typo.body2" Class="purchasing-muted">@_refreshStatus.Message</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
break;
|
||||
case "kennzahlen":
|
||||
<MudPaper Class="pa-3 purchasing-overview-panel" Outlined="true">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||
@@ -437,7 +575,9 @@
|
||||
@code {
|
||||
private ElementReference _purchasing3dCanvas;
|
||||
private PurchasingDashboardLiveState _liveState = new();
|
||||
private PurchasingDataRefreshStatus _refreshStatus = new();
|
||||
private bool _liveLoading = true;
|
||||
private bool _refreshBusy;
|
||||
private string _purchasing3dIndicator = "spend";
|
||||
private string _purchasing3dChartType = "bar";
|
||||
private double _purchasing3dFactor = 1d;
|
||||
@@ -453,15 +593,20 @@
|
||||
return string.Empty;
|
||||
|
||||
var parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length <= 1 ? string.Empty : parts[1].ToLowerInvariant();
|
||||
if (parts.Length <= 1)
|
||||
return string.Empty;
|
||||
|
||||
return parts.Length >= 3 && parts[1].Equals("ideen", StringComparison.OrdinalIgnoreCase)
|
||||
? $"{parts[1]}/{parts[2]}".ToLowerInvariant()
|
||||
: parts[1].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PurchasingKpiCard> KpiCards =>
|
||||
[
|
||||
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("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("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("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)
|
||||
];
|
||||
|
||||
@@ -595,6 +740,134 @@
|
||||
new("5. Contract Cockpit ausbauen", "5. Extend contract cockpit", "Mengenkontrakte und Restverpflichtungen brauchen klare fachliche Abgrenzung.", "Quantity contracts and remaining commitments need clear functional separation.", Icons.Material.Filled.Assignment, Color.Info)
|
||||
];
|
||||
|
||||
private PurchasingImplementationPackage SelectedImplementationPackage
|
||||
=> PurchasingImplementationPackages.FirstOrDefault(x => string.Equals(x.Key, CurrentPurchasingPage, StringComparison.OrdinalIgnoreCase))
|
||||
?? PurchasingImplementationPackages[0];
|
||||
|
||||
private IReadOnlyList<PurchasingImplementationPackage> PurchasingImplementationPackages =>
|
||||
[
|
||||
new(
|
||||
"ideen/datenservice",
|
||||
"Echter Einkauf-Datenservice",
|
||||
"Real purchasing data service",
|
||||
"Grundlage fuer alle echten Einkaufskennzahlen.",
|
||||
"Foundation for all real purchasing KPIs.",
|
||||
"Die aktuelle Live-Probe wird zu einer belastbaren Aggregationsschicht ausgebaut, damit alle weiteren Ideen nicht auf `$top=1000`, sondern auf vollstaendigen Periodenwerten laufen.",
|
||||
"The current live sample is expanded into a reliable aggregation layer so all later ideas run on full period values instead of `$top=1000`.",
|
||||
"EKKOSet, EKPOSet, eketSet, Zeitraumfilter, Belegart, Lieferant, Warengruppe, Artikel",
|
||||
"Spend CHF, offene Menge, offener Wert, Kontraktwert, Beleganzahl, Lieferantenanzahl, letzter Refresh.",
|
||||
"spend CHF, open quantity, open value, contract value, order count, supplier count, latest refresh.",
|
||||
"Service/Cache fuer periodische Aggregation bauen; Filter fuer Jahr/Von-Bis/Lieferant/Warengruppe; Belegarten fuer Bestellung vs. Kontrakt trennen.",
|
||||
"Build service/cache for periodic aggregation; filters for year/from-to/supplier/material group; separate document types for order vs. contract.",
|
||||
"Naechster Schritt: Datenmodell fuer Aggregation definieren und EKPO/EKET Vollabzug gegen SAP testen.",
|
||||
"Next step: define aggregation data model and test full EKPO/EKET extraction against SAP.",
|
||||
"Startpunkt",
|
||||
"starting point",
|
||||
Icons.Material.Filled.Storage,
|
||||
Color.Success,
|
||||
[
|
||||
new("Aggregationsmodell", "Aggregation model", "Zieltabelle oder Cache fuer Spend, offene Werte und offene Mengen definieren.", "define target table or cache for spend, open values and open quantities."),
|
||||
new("Filterlogik", "Filter logic", "Jahr, Von/Bis, Lieferant, Warengruppe und Artikel als Standardfilter vorbereiten.", "prepare year, from/to, supplier, material group and article as standard filters."),
|
||||
new("Performance", "Performance", "Dashboard liest Aggregatwerte, nicht bei jedem Seitenaufruf alle SAP-Zeilen.", "dashboard reads aggregate values instead of all SAP rows on every page request.")
|
||||
]),
|
||||
new(
|
||||
"ideen/liefertermin-risiko",
|
||||
"Liefertermin-Risiko",
|
||||
"Delivery due-date risk",
|
||||
"Sofortnutzen aus EKET.",
|
||||
"Immediate value from EKET.",
|
||||
"Offene Mengen und Werte werden nach Faelligkeit klassiert, damit Rueckstaende und kurzfristige Risiken pro Lieferant/Artikel sichtbar werden.",
|
||||
"Open quantities and values are classified by due date so delays and short-term risks by supplier/article become visible.",
|
||||
"EKET.Eindt, EKET.Menge, EKET.Wemng, EKPO.Netwr, EKPO.Menge, EKKO.Lifnr",
|
||||
"Ueberfaelliger offener Wert, offene Menge 7/30 Tage, Terminampel, Rueckstandsquote.",
|
||||
"overdue open value, open quantity 7/30 days, due-date traffic light, overdue rate.",
|
||||
"Offenmenge = EKET.Menge - EKET.Wemng; Bewertung mit EKPO-Stueckwert; EINDT gegen Heute in Klassen einteilen.",
|
||||
"open quantity = EKET.Menge - EKET.Wemng; value with EKPO unit price; classify EINDT against today.",
|
||||
"Naechster Schritt: eigene Seite mit Faelligkeitskalender, Rueckstands-Hotlist und Lieferant/Artikel-Drilldown bauen.",
|
||||
"Next step: build own page with due-date calendar, overdue hotlist and supplier/article drilldown.",
|
||||
_liveState.EketLoaded ? "bereit" : "wartet auf EKET",
|
||||
_liveState.EketLoaded ? "ready" : "waiting for EKET",
|
||||
Icons.Material.Filled.PendingActions,
|
||||
_liveState.EketLoaded ? Color.Success : Color.Warning,
|
||||
[
|
||||
new("Datumsklassen", "Date buckets", "Ueberfaellig, 0-7 Tage, 8-30 Tage und spaeter als Standardklassen.", "overdue, 0-7 days, 8-30 days and later as standard classes."),
|
||||
new("Bewertung", "Valuation", "Offene EKET-Mengen mit EKPO-Netto-Stueckwert bewerten.", "value open EKET quantities with EKPO net unit price."),
|
||||
new("Drilldown", "Drilldown", "Von Lieferant zu Artikel und Bestellbeleg navigieren.", "navigate from supplier to article and purchase order.")
|
||||
]),
|
||||
new(
|
||||
"ideen/preisabweichung",
|
||||
"Preisabweichung",
|
||||
"Price variance",
|
||||
"Preisveraenderungen pro Artikel/Lieferant.",
|
||||
"Price changes by article/supplier.",
|
||||
"Preissteigerungen und Ausreisser werden gegen Vorjahr, letzte Bestellung oder Budget-/Referenzpreis sichtbar gemacht.",
|
||||
"Price increases and outliers are shown against prior year, last order or budget/reference price.",
|
||||
"EKPO.Netwr, EKPO.Menge, EKPO.Matnr, EKKO.Bedat, EKKO.Lifnr, FX/Budgetkurse",
|
||||
"Preisdelta %, Preisdelta CHF, letzter Preis, Referenzpreis, potentieller Mehrpreis.",
|
||||
"price delta %, price delta CHF, last price, reference price, potential extra cost.",
|
||||
"Netto-Stueckpreis je Artikel/Lieferant/Periode bilden und gegen Referenzperiode vergleichen; Mengenwirkung separat ausweisen.",
|
||||
"calculate net unit price by article/supplier/period and compare against reference period; show quantity effect separately.",
|
||||
"Naechster Schritt: Referenzlogik festlegen: Vorjahr, letzter Preis oder Budgetpreis.",
|
||||
"Next step: define reference logic: prior year, last price or budget price.",
|
||||
_liveState.EkpoLoaded ? "bereit" : "wartet auf EKPO",
|
||||
_liveState.EkpoLoaded ? "ready" : "waiting for EKPO",
|
||||
Icons.Material.Filled.TrendingUp,
|
||||
_liveState.EkpoLoaded ? Color.Success : Color.Warning,
|
||||
[
|
||||
new("Preisnormalisierung", "Price normalisation", "Preis pro Einheit stabil aus NETWR/MENGE bilden.", "derive stable unit price from NETWR/MENGE."),
|
||||
new("Referenzperiode", "Reference period", "Vergleich gegen Vorjahr oder letzten Einkaufspreis waehlen.", "choose comparison against prior year or last purchase price."),
|
||||
new("Ausreisser", "Outliers", "Top Preissteigerungen nach CHF-Wirkung und Prozent anzeigen.", "show top price increases by CHF impact and percent.")
|
||||
]),
|
||||
new(
|
||||
"ideen/spend-konzentration",
|
||||
"Spend-Konzentration",
|
||||
"Spend concentration",
|
||||
"Lieferantenabhaengigkeit und Buendelungspotenzial.",
|
||||
"Supplier dependency and bundling potential.",
|
||||
"Spend wird nach Lieferant und Warengruppe verdichtet, um Top-Lieferantenanteile, Single-Source-Risiken und Fragmentierung sichtbar zu machen.",
|
||||
"Spend is aggregated by supplier and material group to show top supplier shares, single-source risks and fragmentation.",
|
||||
"EKPO.Netwr, EKPO.Matkl, EKKO.Lifnr, Lieferantenname, Warengruppentext",
|
||||
"Top-10-Anteil, Lieferantenanzahl je Warengruppe, Single-Source-Wert, Spend-Pareto.",
|
||||
"top-10 share, supplier count by material group, single-source value, spend Pareto.",
|
||||
"Spend je Warengruppe sortieren, kumulierten Anteil berechnen und Lieferantenkonzentration klassieren.",
|
||||
"sort spend by material group, calculate cumulative share and classify supplier concentration.",
|
||||
"Naechster Schritt: Pareto-Ansicht und Treemap fuer Lieferant/Warengruppe bauen.",
|
||||
"Next step: build Pareto view and treemap for supplier/material group.",
|
||||
_liveState.EkpoLoaded ? "bereit" : "wartet auf EKPO",
|
||||
_liveState.EkpoLoaded ? "ready" : "waiting for EKPO",
|
||||
Icons.Material.Filled.PieChart,
|
||||
_liveState.EkpoLoaded ? Color.Success : Color.Warning,
|
||||
[
|
||||
new("Pareto", "Pareto", "Top-N Lieferanten und kumulierten Spend-Anteil berechnen.", "calculate top-N suppliers and cumulative spend share."),
|
||||
new("Fragmentierung", "Fragmentation", "Viele kleine Lieferanten pro Warengruppe markieren.", "mark many small suppliers per material group."),
|
||||
new("Buendelung", "Bundling", "Warengruppen mit hohem Buendelungspotenzial hervorheben.", "highlight material groups with high bundling potential.")
|
||||
]),
|
||||
new(
|
||||
"ideen/datenqualitaet",
|
||||
"Datenqualitaet Einkauf",
|
||||
"Purchasing data quality",
|
||||
"Vertrauen in die Einkaufskennzahlen sichern.",
|
||||
"Secure trust in purchasing KPIs.",
|
||||
"Fehlende Stammdaten und Mapping-Luecken werden als eigene Qualitaetskennzahlen angezeigt, damit falsche Analysen frueh auffallen.",
|
||||
"Missing master data and mapping gaps are shown as dedicated quality KPIs so wrong analyses are detected early.",
|
||||
"EKKO, EKPO, EKET, Lieferantenmapping, Warengruppenmapping, Artikeltext",
|
||||
"Mapping-Abdeckung %, fehlende Warengruppe, fehlender Artikeltext, fehlender Lieferant, Nullpreis.",
|
||||
"mapping coverage %, missing material group, missing article text, missing supplier, zero price.",
|
||||
"Pflichtfelder zaehlen, Leerwerte klassieren, Mappingtreffer berechnen und Fehler nach Auswirkung priorisieren.",
|
||||
"count required fields, classify empty values, calculate mapping hits and prioritise errors by impact.",
|
||||
"Naechster Schritt: Pflichtfeldkatalog fuer Einkauf definieren und Datenqualitaetsampel bauen.",
|
||||
"Next step: define purchasing required-field catalogue and build data quality traffic light.",
|
||||
_liveState.EkpoLoaded ? "bereit" : "wartet auf EKPO",
|
||||
_liveState.EkpoLoaded ? "ready" : "waiting for EKPO",
|
||||
Icons.Material.Filled.FactCheck,
|
||||
_liveState.EkpoLoaded ? Color.Success : Color.Warning,
|
||||
[
|
||||
new("Pflichtfelder", "Required fields", "Ebeln, Ebelp, Matnr, Matkl, Menge, Netwr, Lifnr pruefen.", "check Ebeln, Ebelp, Matnr, Matkl, Menge, Netwr, Lifnr."),
|
||||
new("Mappingquote", "Mapping rate", "Warengruppen- und Lieferantenmapping pro Quelle messen.", "measure material group and supplier mapping per source."),
|
||||
new("Fehlerwirkung", "Error impact", "Fehler nach betroffenem Spend oder offenem Wert priorisieren.", "prioritise errors by affected spend or open value.")
|
||||
])
|
||||
];
|
||||
|
||||
private IReadOnlyList<PurchasingIdeaWorkPackage> PurchasingIdeaWorkPackages =>
|
||||
[
|
||||
new(
|
||||
@@ -924,6 +1197,7 @@
|
||||
{
|
||||
Navigation.LocationChanged += HandleLocationChanged;
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
_refreshStatus = await PurchasingDataRefreshService.GetStatusAsync();
|
||||
_liveLoading = false;
|
||||
}
|
||||
|
||||
@@ -965,6 +1239,46 @@
|
||||
? $"{T("Letztes EKKO-Datum", "Latest EKKO date")}: {_liveState.LatestOrderDate.Value:yyyy-MM-dd}."
|
||||
: string.Empty;
|
||||
|
||||
private static string FormatRefreshDate(DateTime? value)
|
||||
=> value.HasValue ? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "-";
|
||||
|
||||
private Color ResolveRefreshStatusColor()
|
||||
=> _refreshStatus.Status switch
|
||||
{
|
||||
"Success" => Color.Success,
|
||||
"Running" => Color.Info,
|
||||
"Error" => Color.Error,
|
||||
_ => Color.Warning
|
||||
};
|
||||
|
||||
private async Task RunPurchasingFullLoadAsync()
|
||||
{
|
||||
_refreshBusy = true;
|
||||
try
|
||||
{
|
||||
_refreshStatus = await PurchasingDataRefreshService.RunFullLoadAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunPurchasingDeltaAsync()
|
||||
{
|
||||
_refreshBusy = true;
|
||||
try
|
||||
{
|
||||
_refreshStatus = await PurchasingDataRefreshService.RunDeltaAsync();
|
||||
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildReadinessDonutStyle()
|
||||
{
|
||||
var live = DataReadinessPercent;
|
||||
@@ -1125,6 +1439,8 @@
|
||||
private sealed record PowerBiPageInfo(string Page, string Visuals, string Measure, string Dimensions);
|
||||
private sealed record PurchasingIdea(string TitleDe, string TitleEn, string DescriptionDe, string DescriptionEn, string RequiredData, string ValueDe, string ValueEn, string StatusDe, string StatusEn, string Icon, Color Color);
|
||||
private sealed record PurchasingIdeaPriority(string TitleDe, string TitleEn, string DetailDe, string DetailEn, string Icon, Color Color);
|
||||
private sealed record PurchasingImplementationStep(string TitleDe, string TitleEn, string DetailDe, string DetailEn);
|
||||
private sealed record PurchasingImplementationPackage(string Key, string TitleDe, string TitleEn, string SubtitleDe, string SubtitleEn, string TargetDe, string TargetEn, string DataBasis, string KpisDe, string KpisEn, string TechnicalDe, string TechnicalEn, string NextStepDe, string NextStepEn, string StatusDe, string StatusEn, string Icon, Color Color, IReadOnlyList<PurchasingImplementationStep> Steps);
|
||||
private sealed record PurchasingIdeaWorkPackage(string TitleDe, string TitleEn, string ShortDe, string ShortEn, string GoalDe, string GoalEn, string DataBasis, string KpisDe, string KpisEn, string LogicDe, string LogicEn, string VisualDe, string VisualEn, string NextStepDe, string NextStepEn, string StatusDe, string StatusEn, string Icon, Color Color);
|
||||
private sealed record PurchasingIdeaKpi(string AnalysisDe, string AnalysisEn, string KpiDe, string KpiEn, string Dimension, string Source, string StatusDe, string StatusEn, Color Color);
|
||||
private sealed record Purchasing3dIndicator(string Key, string TitleDe, string TitleEn, string Unit);
|
||||
|
||||
@@ -116,6 +116,7 @@ builder.Services.AddScoped<ITransformationsPageService, TransformationsPageServi
|
||||
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
||||
builder.Services.AddScoped<IPurchasingDataSourcePageService, PurchasingDataSourcePageService>();
|
||||
builder.Services.AddScoped<IPurchasingDashboardService, PurchasingDashboardService>();
|
||||
builder.Services.AddScoped<IPurchasingDataRefreshService, PurchasingDataRefreshService>();
|
||||
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
||||
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
||||
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
|
||||
|
||||
@@ -233,4 +233,61 @@ CREATE TABLE NavigationMenuItems (
|
||||
IsSystem INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);";
|
||||
|
||||
internal static string GetPurchasingEkkoCacheCreateSql() => @"
|
||||
CREATE TABLE PurchasingEkkoCache (
|
||||
Ebeln TEXT NOT NULL PRIMARY KEY,
|
||||
Bedat TEXT NULL,
|
||||
Aedat TEXT NULL,
|
||||
Lifnr TEXT NOT NULL DEFAULT '',
|
||||
Bukrs TEXT NOT NULL DEFAULT '',
|
||||
Bsart TEXT NOT NULL DEFAULT '',
|
||||
RawJson TEXT NOT NULL DEFAULT '',
|
||||
LastLoadedAtUtc TEXT NOT NULL
|
||||
);";
|
||||
|
||||
internal static string GetPurchasingEkpoCacheCreateSql() => @"
|
||||
CREATE TABLE PurchasingEkpoCache (
|
||||
Ebeln TEXT NOT NULL,
|
||||
Ebelp TEXT NOT NULL,
|
||||
Matnr TEXT NOT NULL DEFAULT '',
|
||||
Txz01 TEXT NOT NULL DEFAULT '',
|
||||
Matkl TEXT NOT NULL DEFAULT '',
|
||||
Menge TEXT NOT NULL DEFAULT '0',
|
||||
Meins TEXT NOT NULL DEFAULT '',
|
||||
Netwr TEXT NOT NULL DEFAULT '0',
|
||||
Loekz TEXT NOT NULL DEFAULT '',
|
||||
RawJson TEXT NOT NULL DEFAULT '',
|
||||
LastLoadedAtUtc TEXT NOT NULL,
|
||||
PRIMARY KEY (Ebeln, Ebelp)
|
||||
);";
|
||||
|
||||
internal static string GetPurchasingEketCacheCreateSql() => @"
|
||||
CREATE TABLE PurchasingEketCache (
|
||||
Ebeln TEXT NOT NULL,
|
||||
Ebelp TEXT NOT NULL,
|
||||
Etenr TEXT NOT NULL,
|
||||
Eindt TEXT NULL,
|
||||
Menge TEXT NOT NULL DEFAULT '0',
|
||||
Wemng TEXT NOT NULL DEFAULT '0',
|
||||
RawJson TEXT NOT NULL DEFAULT '',
|
||||
LastLoadedAtUtc TEXT NOT NULL,
|
||||
PRIMARY KEY (Ebeln, Ebelp, Etenr)
|
||||
);";
|
||||
|
||||
internal static string GetPurchasingSyncStateCreateSql() => @"
|
||||
CREATE TABLE PurchasingSyncState (
|
||||
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
Mode TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT '',
|
||||
StartedAtUtc TEXT NULL,
|
||||
CompletedAtUtc TEXT NULL,
|
||||
FromDate TEXT NULL,
|
||||
ToDate TEXT NULL,
|
||||
LastSuccessfulDeltaAtUtc TEXT NULL,
|
||||
EkkoRows INTEGER NOT NULL DEFAULT 0,
|
||||
EkpoRows INTEGER NOT NULL DEFAULT 0,
|
||||
EketRows INTEGER NOT NULL DEFAULT 0,
|
||||
Message TEXT NOT NULL DEFAULT ''
|
||||
);";
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
|
||||
EnsureManualExcelColumnMappingTable(db);
|
||||
EnsureCentralSalesRecordTable(db);
|
||||
EnsureNavigationMenuItemTable(db);
|
||||
EnsurePurchasingCacheTables(db);
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
|
||||
@@ -284,6 +285,34 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void EnsurePurchasingCacheTables(AppDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
conn.Open();
|
||||
|
||||
foreach (var createSql in new[]
|
||||
{
|
||||
DatabaseSchemaSql.GetPurchasingEkkoCacheCreateSql(),
|
||||
DatabaseSchemaSql.GetPurchasingEkpoCacheCreateSql(),
|
||||
DatabaseSchemaSql.GetPurchasingEketCacheCreateSql(),
|
||||
DatabaseSchemaSql.GetPurchasingSyncStateCreateSql()
|
||||
})
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = createSql.Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||
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();
|
||||
}
|
||||
|
||||
private static void EnsureSapSourceTable(AppDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
|
||||
@@ -140,6 +140,14 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
if (string.IsNullOrWhiteSpace(existing.ItemType)) existing.ItemType = item.ItemType;
|
||||
if (string.IsNullOrWhiteSpace(existing.Match)) existing.Match = item.Match;
|
||||
if (string.IsNullOrWhiteSpace(existing.RequiredPolicy)) existing.RequiredPolicy = item.RequiredPolicy;
|
||||
if (existing.Key == "purchasing-ideas")
|
||||
{
|
||||
existing.ItemType = item.ItemType;
|
||||
existing.Href = item.Href;
|
||||
existing.Match = item.Match;
|
||||
existing.Icon = item.Icon;
|
||||
existing.IsExpanded = item.IsExpanded;
|
||||
}
|
||||
existing.IsSystem = true;
|
||||
changed = true;
|
||||
}
|
||||
@@ -185,7 +193,13 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
Link("purchasing-open-orders", "purchasing", "Offene Bestellungen", "Open orders", "PendingActions", "einkauf/offene-bestellungen", 30, "All"),
|
||||
Link("purchasing-contracts", "purchasing", "Kontrakte", "Contracts", "Assignment", "einkauf/kontrakte", 40, "All"),
|
||||
Link("purchasing-suppliers", "purchasing", "Lieferanten", "Suppliers", "Verified", "einkauf/lieferanten", 50, "All"),
|
||||
Link("purchasing-ideas", "purchasing", "Ideen", "Ideas", "Lightbulb", "einkauf/ideen", 60, "All"),
|
||||
Group("purchasing-ideas", "purchasing", "Ideen", "Ideas", "Lightbulb", 60, expanded: true),
|
||||
Link("purchasing-ideas-overview", "purchasing-ideas", "Uebersicht", "Overview", "Lightbulb", "einkauf/ideen", 10, "All"),
|
||||
Link("purchasing-idea-data-service", "purchasing-ideas", "Einkauf-Datenservice", "Purchasing data service", "Storage", "einkauf/ideen/datenservice", 20, "All"),
|
||||
Link("purchasing-idea-delivery-risk", "purchasing-ideas", "Liefertermin-Risiko", "Delivery due-date risk", "PendingActions", "einkauf/ideen/liefertermin-risiko", 30, "All"),
|
||||
Link("purchasing-idea-price-variance", "purchasing-ideas", "Preisabweichung", "Price variance", "TrendingUp", "einkauf/ideen/preisabweichung", 40, "All"),
|
||||
Link("purchasing-idea-spend-concentration", "purchasing-ideas", "Spend-Konzentration", "Spend concentration", "PieChart", "einkauf/ideen/spend-konzentration", 50, "All"),
|
||||
Link("purchasing-idea-data-quality", "purchasing-ideas", "Datenqualitaet", "Data quality", "FactCheck", "einkauf/ideen/datenqualitaet", 60, "All"),
|
||||
Link("purchasing-kpi-catalog", "purchasing", "Kennzahlen-Katalog", "KPI catalogue", "Checklist", "einkauf/kennzahlen", 70, "All"),
|
||||
Link("purchasing-pbix", "purchasing", "PBIX Vorlage", "PBIX template", "InsertChart", "einkauf/pbix", 80, "All"),
|
||||
Link("purchasing-3d", "purchasing", "3D Simulation", "3D simulation", "ViewInAr", "einkauf/3d", 90, "All"),
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class PurchasingDashboardLiveState
|
||||
public DateTime? LatestOrderDate { get; set; }
|
||||
public int PositionSampleCount { get; set; }
|
||||
public int ScheduleSampleCount { get; set; }
|
||||
public bool UsesCache { get; set; }
|
||||
public string CacheStatus { get; set; } = string.Empty;
|
||||
public DateTime? CacheCompletedAtUtc { get; set; }
|
||||
public decimal SpendChfSample { get; set; }
|
||||
public decimal OpenQuantitySample { get; set; }
|
||||
public decimal OpenValueSample { get; set; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public interface IPurchasingDataRefreshService
|
||||
{
|
||||
Task<PurchasingDataRefreshStatus> GetStatusAsync(CancellationToken cancellationToken = default);
|
||||
Task<PurchasingDataRefreshStatus> RunFullLoadAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default);
|
||||
Task<PurchasingDataRefreshStatus> RunDeltaAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PurchasingDataRefreshStatus
|
||||
{
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime? StartedAtUtc { get; set; }
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
public DateTime? FromDate { get; set; }
|
||||
public DateTime? ToDate { get; set; }
|
||||
public DateTime? LastSuccessfulDeltaAtUtc { get; set; }
|
||||
public int EkkoRows { get; set; }
|
||||
public int EkpoRows { get; set; }
|
||||
public int EketRows { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool IsComplete => string.Equals(Status, "Success", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -33,7 +33,9 @@ public static class NavigationIconResolver
|
||||
"Settings" => Icons.Material.Filled.Settings,
|
||||
"ShoppingCart" => Icons.Material.Filled.ShoppingCart,
|
||||
"Speed" => Icons.Material.Filled.Speed,
|
||||
"Storage" => Icons.Material.Filled.Storage,
|
||||
"Transform" => Icons.Material.Filled.Transform,
|
||||
"TrendingUp" => Icons.Material.Filled.TrendingUp,
|
||||
"Tune" => Icons.Material.Filled.Tune,
|
||||
"UploadFile" => Icons.Material.Filled.UploadFile,
|
||||
"Verified" => Icons.Material.Filled.Verified,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
|
||||
@@ -23,6 +24,9 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
if (await TryLoadCacheStateAsync(db, state, cancellationToken))
|
||||
return state;
|
||||
|
||||
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken);
|
||||
var site = await db.Sites.AsNoTracking().FirstOrDefaultAsync(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc, cancellationToken);
|
||||
if (sap is null || site is null)
|
||||
@@ -104,6 +108,85 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
return state;
|
||||
}
|
||||
|
||||
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, 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)
|
||||
return false;
|
||||
|
||||
var latestStatus = await ReadCacheStatusAsync(conn, cancellationToken);
|
||||
state.UsesCache = true;
|
||||
state.SapReachable = true;
|
||||
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.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);
|
||||
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 = ''
|
||||
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 = ''
|
||||
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 = ''
|
||||
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
|
||||
FROM PurchasingEkpoCache p
|
||||
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
|
||||
WHERE p.Loekz = ''
|
||||
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
|
||||
ORDER BY Value DESC
|
||||
LIMIT 6;", cancellationToken);
|
||||
state.OpenValueChartRows = await ExecuteChartRowsAsync(conn, @"
|
||||
SELECT 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
|
||||
WHERE COALESCE(p.Loekz, '') = ''
|
||||
GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
|
||||
ORDER BY Label
|
||||
LIMIT 6;", cancellationToken);
|
||||
state.ContractChartRows = state.OpenValueChartRows.ToList();
|
||||
state.CacheStatus = latestStatus.Status;
|
||||
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
|
||||
state.Message = $"Einkauf Cache geladen: EKKO={ekkoRows:N0}, EKPO={ekpoRows:N0}, EKET={eketRows:N0}. {latestStatus.Message}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyEkpoMetrics(
|
||||
PurchasingDashboardLiveState state,
|
||||
List<Dictionary<string, object?>> ekkoRows,
|
||||
@@ -242,6 +325,74 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||
return int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScalarIntAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(value ?? 0, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<decimal> ExecuteScalarDecimalAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToDecimal(value ?? 0, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<DateTime?> ExecuteScalarDateAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
var value = Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : TryParseSapDate(value);
|
||||
}
|
||||
|
||||
private static async Task<string> ExecuteTopLabelAsync(SqliteConnection conn, string sql, string fallback, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return fallback;
|
||||
|
||||
var label = reader.GetString(0);
|
||||
var value = Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture);
|
||||
return $"{label}: CHF {value:N0}";
|
||||
}
|
||||
|
||||
private static async Task<List<PurchasingLiveChartPoint>> ExecuteChartRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<PurchasingLiveChartPoint>();
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var label = reader.GetString(0);
|
||||
var value = Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture);
|
||||
rows.Add(new PurchasingLiveChartPoint(label, value));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task<(string Status, DateTime? CompletedAtUtc, string Message)> ReadCacheStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = "SELECT Status, CompletedAtUtc, Message FROM PurchasingSyncState ORDER BY Id DESC LIMIT 1;";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return ("Cache", null, string.Empty);
|
||||
|
||||
var completedText = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||
var completed = DateTime.TryParse(completedText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: (DateTime?)null;
|
||||
return (reader.GetString(0), completed, reader.GetString(2));
|
||||
}
|
||||
|
||||
private static object? ConvertJsonValue(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public sealed class PurchasingDataRefreshService : IPurchasingDataRefreshService
|
||||
{
|
||||
private const int PageSize = 1000;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly IAppEventLogService _logService;
|
||||
|
||||
public PurchasingDataRefreshService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService logService)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logService = logService;
|
||||
}
|
||||
|
||||
public async Task<PurchasingDataRefreshStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var conn = (SqliteConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
var status = await ReadLatestStatusAsync(conn, cancellationToken);
|
||||
status.EkkoRows = await CountTableAsync(conn, "PurchasingEkkoCache", cancellationToken);
|
||||
status.EkpoRows = await CountTableAsync(conn, "PurchasingEkpoCache", cancellationToken);
|
||||
status.EketRows = await CountTableAsync(conn, "PurchasingEketCache", cancellationToken);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<PurchasingDataRefreshStatus> RunFullLoadAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
await WriteStatusAsync("Full", "Running", started, null, fromDate, null, null, 0, 0, 0, "Full Load gestartet.", cancellationToken);
|
||||
await _logService.WriteAsync("Purchasing", "Einkauf Full Load gestartet", details: fromDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
|
||||
try
|
||||
{
|
||||
var connection = await ResolveConnectionAsync(cancellationToken);
|
||||
using var client = CreateClient(connection.Username, connection.Password);
|
||||
var nowText = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||
var ekkoFilter = fromDate.HasValue ? $"Bedat ge '{fromDate.Value:yyyy-MM-dd}'" : string.Empty;
|
||||
|
||||
var ekkoRows = await ReadAllRowsAsync(client, connection.BaseUrl, "EKKOSet", "Ebeln,Bedat,Aedat,Lifnr,Bukrs,Konnr,Waers,Wkurs", ekkoFilter, "Ebeln", cancellationToken);
|
||||
var ekpoRows = await ReadAllRowsAsync(client, connection.BaseUrl, "EKPOSet", "Ebeln,Ebelp,Matnr,Txz01,Matkl,Menge,Ktmng,Netwr,Loekz,Bukrs,Werks", string.Empty, "Ebeln,Ebelp", cancellationToken);
|
||||
var eketRows = await ReadAllRowsAsync(client, connection.BaseUrl, "eketSet", "Ebeln,Ebelp,Etenr,Eindt,Menge,Wemng", string.Empty, "Ebeln,Ebelp,Etenr", cancellationToken);
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var conn = (SqliteConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync(cancellationToken);
|
||||
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEkkoCache;", cancellationToken);
|
||||
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEkpoCache;", cancellationToken);
|
||||
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEketCache;", cancellationToken);
|
||||
await UpsertEkkoAsync(conn, transaction, ekkoRows, nowText, cancellationToken);
|
||||
await UpsertEkpoAsync(conn, transaction, ekpoRows, nowText, cancellationToken);
|
||||
await UpsertEketAsync(conn, transaction, eketRows, nowText, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var completed = DateTime.UtcNow;
|
||||
var message = $"Full Load abgeschlossen: EKKO={ekkoRows.Count:N0}, EKPO={ekpoRows.Count:N0}, EKET={eketRows.Count:N0}.";
|
||||
await WriteStatusAsync("Full", "Success", started, completed, fromDate, null, completed, ekkoRows.Count, ekpoRows.Count, eketRows.Count, message, cancellationToken);
|
||||
await _logService.WriteAsync("Purchasing", "Einkauf Full Load erfolgreich", details: message);
|
||||
return await GetStatusAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var message = $"Full Load fehlgeschlagen: {ex.Message}";
|
||||
await WriteStatusAsync("Full", "Error", started, DateTime.UtcNow, fromDate, null, null, 0, 0, 0, message, cancellationToken);
|
||||
await _logService.WriteAsync("Purchasing", "Einkauf Full Load fehlgeschlagen", "Error", details: ex.ToString());
|
||||
return await GetStatusAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PurchasingDataRefreshStatus> RunDeltaAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await GetStatusAsync(cancellationToken);
|
||||
var deltaFrom = fromDate ?? current.LastSuccessfulDeltaAtUtc ?? current.CompletedAtUtc ?? DateTime.UtcNow.AddDays(-7);
|
||||
var started = DateTime.UtcNow;
|
||||
await WriteStatusAsync("Delta", "Running", started, null, deltaFrom, null, current.LastSuccessfulDeltaAtUtc, current.EkkoRows, current.EkpoRows, current.EketRows, "Delta gestartet.", cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var connection = await ResolveConnectionAsync(cancellationToken);
|
||||
using var client = CreateClient(connection.Username, connection.Password);
|
||||
var filter = $"Aedat ge '{deltaFrom:yyyy-MM-dd}'";
|
||||
var changedEkko = await ReadAllRowsAsync(client, connection.BaseUrl, "EKKOSet", "Ebeln,Bedat,Aedat,Lifnr,Bukrs,Konnr,Waers,Wkurs", filter, "Ebeln", cancellationToken);
|
||||
var ebelnKeys = changedEkko
|
||||
.Select(row => GetText(row, "Ebeln"))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var ekpoRows = new List<Dictionary<string, object?>>();
|
||||
var eketRows = new List<Dictionary<string, object?>>();
|
||||
foreach (var ebeln in ebelnKeys)
|
||||
{
|
||||
ekpoRows.AddRange(await ReadAllRowsAsync(client, connection.BaseUrl, "EKPOSet", "Ebeln,Ebelp,Matnr,Txz01,Matkl,Menge,Ktmng,Netwr,Loekz,Bukrs,Werks", $"Ebeln eq '{ebeln}'", "Ebelp", cancellationToken));
|
||||
eketRows.AddRange(await ReadAllRowsAsync(client, connection.BaseUrl, "eketSet", "Ebeln,Ebelp,Etenr,Eindt,Menge,Wemng", $"Ebeln eq '{ebeln}'", "Ebelp,Etenr", cancellationToken));
|
||||
}
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var conn = (SqliteConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
var nowText = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||
await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync(cancellationToken);
|
||||
await UpsertEkkoAsync(conn, transaction, changedEkko, nowText, cancellationToken);
|
||||
await UpsertEkpoAsync(conn, transaction, ekpoRows, nowText, cancellationToken);
|
||||
await UpsertEketAsync(conn, transaction, eketRows, nowText, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var completed = DateTime.UtcNow;
|
||||
var status = await GetStatusAsync(cancellationToken);
|
||||
var message = $"Delta abgeschlossen: geaenderte Belege={ebelnKeys.Count:N0}, EKPO={ekpoRows.Count:N0}, EKET={eketRows.Count:N0}.";
|
||||
await WriteStatusAsync("Delta", "Success", started, completed, deltaFrom, null, completed, status.EkkoRows, status.EkpoRows, status.EketRows, message, cancellationToken);
|
||||
await _logService.WriteAsync("Purchasing", "Einkauf Delta erfolgreich", details: message);
|
||||
return await GetStatusAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteStatusAsync("Delta", "Error", started, DateTime.UtcNow, deltaFrom, null, current.LastSuccessfulDeltaAtUtc, current.EkkoRows, current.EkpoRows, current.EketRows, $"Delta fehlgeschlagen: {ex.Message}", cancellationToken);
|
||||
await _logService.WriteAsync("Purchasing", "Einkauf Delta fehlgeschlagen", "Error", details: ex.ToString());
|
||||
return await GetStatusAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PurchasingSapConnection> ResolveConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken)
|
||||
?? throw new InvalidOperationException("SAP Quelle fehlt.");
|
||||
var site = await db.Sites.AsNoTracking().FirstOrDefaultAsync(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc, cancellationToken)
|
||||
?? throw new InvalidOperationException("Einkauf SAP Site fehlt.");
|
||||
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sap.CentralServiceUrl : site.SapServiceUrl;
|
||||
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sap.CentralUsername : site.UsernameOverride;
|
||||
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sap.CentralPassword : site.PasswordOverride;
|
||||
if (string.IsNullOrWhiteSpace(serviceUrl) || string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
throw new InvalidOperationException("SAP URL oder Zugangsdaten fehlen.");
|
||||
return new PurchasingSapConnection(serviceUrl.TrimEnd('/') + "/", username, password);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(string username, string password)
|
||||
{
|
||||
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<List<Dictionary<string, object?>>> ReadAllRowsAsync(HttpClient client, string baseUrl, string entitySet, string select, string filter, string orderBy, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<Dictionary<string, object?>>();
|
||||
for (var skip = 0; ; skip += PageSize)
|
||||
{
|
||||
var url = $"{baseUrl}{entitySet}?$format=json&$top={PageSize}&$skip={skip}&$select={Uri.EscapeDataString(select)}";
|
||||
if (!string.IsNullOrWhiteSpace(orderBy))
|
||||
url += $"&$orderby={Uri.EscapeDataString(orderBy)}";
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
url += $"&$filter={Uri.EscapeDataString(filter)}";
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new HttpRequestException($"SAP OData {entitySet} fehlgeschlagen ({(int)response.StatusCode} {response.ReasonPhrase}) URL={url} Antwort={TrimForLog(error)}");
|
||||
}
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var page = ParseRows(json);
|
||||
if (page.Count == 0)
|
||||
return rows;
|
||||
rows.AddRange(page);
|
||||
if (page.Count < PageSize)
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> ParseRows(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("d", out var d) ||
|
||||
!d.TryGetProperty("results", out var results) ||
|
||||
results.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return results.EnumerateArray()
|
||||
.Select(item => item.EnumerateObject()
|
||||
.Where(property => property.Name != "__metadata")
|
||||
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value), StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task UpsertEkkoAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT OR REPLACE INTO PurchasingEkkoCache (Ebeln, Bedat, Aedat, Lifnr, Bukrs, Bsart, RawJson, LastLoadedAtUtc)
|
||||
VALUES ($Ebeln, $Bedat, $Aedat, $Lifnr, $Bukrs, $Bsart, $RawJson, $LastLoadedAtUtc);";
|
||||
foreach (var row in rows)
|
||||
await ExecuteWithParametersAsync(conn, transaction, sql, new()
|
||||
{
|
||||
["$Ebeln"] = GetText(row, "Ebeln"),
|
||||
["$Bedat"] = NormalizeSapDate(GetText(row, "Bedat")),
|
||||
["$Aedat"] = NormalizeSapDate(GetText(row, "Aedat")),
|
||||
["$Lifnr"] = GetText(row, "Lifnr"),
|
||||
["$Bukrs"] = GetText(row, "Bukrs"),
|
||||
["$Bsart"] = GetText(row, "Bsart"),
|
||||
["$RawJson"] = JsonSerializer.Serialize(row),
|
||||
["$LastLoadedAtUtc"] = loadedAtUtc
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertEkpoAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT OR REPLACE INTO PurchasingEkpoCache (Ebeln, Ebelp, Matnr, Txz01, Matkl, Menge, Meins, Netwr, Loekz, RawJson, LastLoadedAtUtc)
|
||||
VALUES ($Ebeln, $Ebelp, $Matnr, $Txz01, $Matkl, $Menge, $Meins, $Netwr, $Loekz, $RawJson, $LastLoadedAtUtc);";
|
||||
foreach (var row in rows)
|
||||
await ExecuteWithParametersAsync(conn, transaction, sql, new()
|
||||
{
|
||||
["$Ebeln"] = GetText(row, "Ebeln"),
|
||||
["$Ebelp"] = GetText(row, "Ebelp"),
|
||||
["$Matnr"] = GetText(row, "Matnr"),
|
||||
["$Txz01"] = GetText(row, "Txz01"),
|
||||
["$Matkl"] = GetText(row, "Matkl"),
|
||||
["$Menge"] = GetText(row, "Menge"),
|
||||
["$Meins"] = GetText(row, "Meins"),
|
||||
["$Netwr"] = GetText(row, "Netwr"),
|
||||
["$Loekz"] = GetText(row, "Loekz"),
|
||||
["$RawJson"] = JsonSerializer.Serialize(row),
|
||||
["$LastLoadedAtUtc"] = loadedAtUtc
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertEketAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT OR REPLACE INTO PurchasingEketCache (Ebeln, Ebelp, Etenr, Eindt, Menge, Wemng, RawJson, LastLoadedAtUtc)
|
||||
VALUES ($Ebeln, $Ebelp, $Etenr, $Eindt, $Menge, $Wemng, $RawJson, $LastLoadedAtUtc);";
|
||||
foreach (var row in rows)
|
||||
await ExecuteWithParametersAsync(conn, transaction, sql, new()
|
||||
{
|
||||
["$Ebeln"] = GetText(row, "Ebeln"),
|
||||
["$Ebelp"] = GetText(row, "Ebelp"),
|
||||
["$Etenr"] = GetText(row, "Etenr"),
|
||||
["$Eindt"] = NormalizeSapDate(GetText(row, "Eindt")),
|
||||
["$Menge"] = GetText(row, "Menge"),
|
||||
["$Wemng"] = GetText(row, "Wemng"),
|
||||
["$RawJson"] = JsonSerializer.Serialize(row),
|
||||
["$LastLoadedAtUtc"] = loadedAtUtc
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WriteStatusAsync(string mode, string status, DateTime? startedAtUtc, DateTime? completedAtUtc, DateTime? fromDate, DateTime? toDate, DateTime? lastSuccessfulDeltaAtUtc, int ekkoRows, int ekpoRows, int eketRows, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var conn = (SqliteConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
const string sql = @"
|
||||
INSERT INTO PurchasingSyncState (Mode, Status, StartedAtUtc, CompletedAtUtc, FromDate, ToDate, LastSuccessfulDeltaAtUtc, EkkoRows, EkpoRows, EketRows, Message)
|
||||
VALUES ($Mode, $Status, $StartedAtUtc, $CompletedAtUtc, $FromDate, $ToDate, $LastSuccessfulDeltaAtUtc, $EkkoRows, $EkpoRows, $EketRows, $Message);";
|
||||
await ExecuteWithParametersAsync(conn, null, sql, new()
|
||||
{
|
||||
["$Mode"] = mode,
|
||||
["$Status"] = status,
|
||||
["$StartedAtUtc"] = FormatDateTime(startedAtUtc),
|
||||
["$CompletedAtUtc"] = FormatDateTime(completedAtUtc),
|
||||
["$FromDate"] = FormatDate(fromDate),
|
||||
["$ToDate"] = FormatDate(toDate),
|
||||
["$LastSuccessfulDeltaAtUtc"] = FormatDateTime(lastSuccessfulDeltaAtUtc),
|
||||
["$EkkoRows"] = ekkoRows,
|
||||
["$EkpoRows"] = ekpoRows,
|
||||
["$EketRows"] = eketRows,
|
||||
["$Message"] = message
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<PurchasingDataRefreshStatus> ReadLatestStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT Mode, Status, StartedAtUtc, CompletedAtUtc, FromDate, ToDate, LastSuccessfulDeltaAtUtc, EkkoRows, EkpoRows, EketRows, Message
|
||||
FROM PurchasingSyncState
|
||||
ORDER BY Id DESC
|
||||
LIMIT 1;";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return new PurchasingDataRefreshStatus { Status = "Empty", Message = "Noch kein Einkauf Full Load ausgefuehrt." };
|
||||
|
||||
return new PurchasingDataRefreshStatus
|
||||
{
|
||||
Mode = reader.GetString(0),
|
||||
Status = reader.GetString(1),
|
||||
StartedAtUtc = ParseDateTime(reader.GetString(2)),
|
||||
CompletedAtUtc = ParseDateTime(reader.GetString(3)),
|
||||
FromDate = ParseDate(reader.GetString(4)),
|
||||
ToDate = ParseDate(reader.GetString(5)),
|
||||
LastSuccessfulDeltaAtUtc = ParseDateTime(reader.GetString(6)),
|
||||
EkkoRows = reader.GetInt32(7),
|
||||
EkpoRows = reader.GetInt32(8),
|
||||
EketRows = reader.GetInt32(9),
|
||||
Message = reader.GetString(10)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<int> CountTableAsync(SqliteConnection conn, string tableName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.CommandText = $"SELECT COUNT(1) FROM {tableName};";
|
||||
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(SqliteConnection conn, SqliteTransaction transaction, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ExecuteWithParametersAsync(SqliteConnection conn, SqliteTransaction? transaction, string sql, Dictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = conn.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
foreach (var (key, value) in parameters)
|
||||
command.Parameters.AddWithValue(key, value ?? DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static object? ConvertJsonValue(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var number) => number,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
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 string TrimForLog(string value)
|
||||
=> value.Length <= 1000 ? value : value[..1000] + "...";
|
||||
|
||||
private static string? NormalizeSapDate(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
|
||||
return parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
return DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)
|
||||
? parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string FormatDateTime(DateTime? value)
|
||||
=> value?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
private static string FormatDate(DateTime? value)
|
||||
=> value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
private static DateTime? ParseDateTime(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : null;
|
||||
|
||||
private static DateTime? ParseDate(string value)
|
||||
=> DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : null;
|
||||
|
||||
private sealed record PurchasingSapConnection(string BaseUrl, string Username, string Password);
|
||||
}
|
||||
@@ -41,19 +41,25 @@ Das Dashboard wurde fachlich um diese Bereiche erweitert:
|
||||
- Hauptnavigation: eigener Punkt `Einkauf` mit Einkaufswagen-Icon.
|
||||
- Tabs im Einkaufsdashboard:
|
||||
- Die frueheren Tabs wurden in echte linke Navigationspunkte unter `Einkauf` umgebaut.
|
||||
- `Einkauf Dashboard`: Uebersicht, SAP-Datenfluss, Live-Status und Analyseachsen.
|
||||
- `Spend`: Spend total vergangen nach Jahr, Lieferant, Warengruppe und Artikel.
|
||||
- `Offene Bestellungen`: offene Werte, Mengen und Faelligkeiten.
|
||||
- `Kontrakte`: offene Verpflichtungen und Kontrakt-Restwerte.
|
||||
- `Lieferanten`: Lieferantenbasis, Performance und Datenstatus.
|
||||
- `Ideen`: Roadmap fuer weitere Einkaufsanalysen.
|
||||
- `Kennzahlen-Katalog`: fachlicher KPI-Katalog fuer den naechsten Ausbau.
|
||||
- `Einkauf Dashboard`: Uebersicht, SAP-Datenfluss, Live-Status und Analyseachsen.
|
||||
- `Spend`: Spend total vergangen nach Jahr, Lieferant, Warengruppe und Artikel.
|
||||
- `Offene Bestellungen`: offene Werte, Mengen und Faelligkeiten.
|
||||
- `Kontrakte`: offene Verpflichtungen und Kontrakt-Restwerte.
|
||||
- `Lieferanten`: Lieferantenbasis, Performance und Datenstatus.
|
||||
- `Ideen`: aufklappbarer Navigationspunkt fuer die naechsten Umsetzungsbausteine.
|
||||
- `Uebersicht`.
|
||||
- `Einkauf-Datenservice`.
|
||||
- `Liefertermin-Risiko`.
|
||||
- `Preisabweichung`.
|
||||
- `Spend-Konzentration`.
|
||||
- `Datenqualitaet`.
|
||||
- `Kennzahlen-Katalog`: fachlicher KPI-Katalog fuer den naechsten Ausbau.
|
||||
- `PBIX Vorlage`: aus `x.pbix` uebernommene Seiten/Visuals.
|
||||
- `3D Simulation`: drehbare 3D-What-if-Analyse.
|
||||
- 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.
|
||||
- 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.
|
||||
- EKKO, EKPO und EKET werden per SAP/OData in lokale Cache-Tabellen geladen.
|
||||
- Das Cockpit liest zuerst den Cache und nutzt nur noch als Fallback eine begrenzte Live-Probe, falls noch kein Cache vorhanden ist.
|
||||
|
||||
## Navigation und Admin-Steuerung
|
||||
|
||||
@@ -65,6 +71,11 @@ Stand 2026-06-05: Die Einkaufsbereiche sind nicht mehr als obere Tabs im Dashboa
|
||||
- `/einkauf/kontrakte`
|
||||
- `/einkauf/lieferanten`
|
||||
- `/einkauf/ideen`
|
||||
- `/einkauf/ideen/datenservice`
|
||||
- `/einkauf/ideen/liefertermin-risiko`
|
||||
- `/einkauf/ideen/preisabweichung`
|
||||
- `/einkauf/ideen/spend-konzentration`
|
||||
- `/einkauf/ideen/datenqualitaet`
|
||||
- `/einkauf/kennzahlen`
|
||||
- `/einkauf/pbix`
|
||||
- `/einkauf/3d`
|
||||
@@ -110,24 +121,40 @@ Nach Aktivierung der angepassten SAP-Methoden liefern die OData-Services:
|
||||
|
||||
Wichtig: Die OData-Property heisst `Ebeln`. Ein Filter mit `EBELN` liefert HTTP 400.
|
||||
|
||||
## Full Load / Delta Stand 2026-06-05
|
||||
|
||||
Der erste vollstaendige SAP-Load wurde am 2026-06-05 ausgefuehrt.
|
||||
|
||||
Geladene Cache-Zeilen:
|
||||
|
||||
- `PurchasingEkkoCache`: 172'874 EKKO-Koepfe.
|
||||
- `PurchasingEkpoCache`: 233'921 EKPO-Positionen.
|
||||
- `PurchasingEketCache`: 242'572 EKET-Einteilungen.
|
||||
|
||||
Technische Logik:
|
||||
|
||||
- SAP liefert pro OData-Seite maximal 1'000 Zeilen.
|
||||
- Der Loader liest deshalb mit `$top=1000`, `$skip` und stabiler Sortierung:
|
||||
- `EKKOSet`: `$orderby=Ebeln`.
|
||||
- `EKPOSet`: `$orderby=Ebeln,Ebelp`.
|
||||
- `eketSet`: `$orderby=Ebeln,Ebelp,Etenr`.
|
||||
- Nicht vorhandene OData-Felder wurden entfernt:
|
||||
- `EKKOSet.Bsart` existiert in diesem Service nicht.
|
||||
- `EKPOSet.Meins` existiert in diesem Service nicht.
|
||||
- Nach dem Full Load kann `Delta aktualisieren` genutzt werden. Delta liest geaenderte EKKO-Belege ab `Aedat` und laedt die zugehoerigen EKPO/EKET-Zeilen je Beleg nach.
|
||||
|
||||
## Live-Kennzahlen im Dashboard
|
||||
|
||||
Die Seite `/einkauf` zeigt nun echte Werte aus SAP:
|
||||
Die Seite `/einkauf` zeigt nun echte Werte aus dem SAP-Cache:
|
||||
|
||||
- `Spend total`: Summe `EKPOSet.Netwr` aus der Live-Probe.
|
||||
- `Spend total`: Summe `EKPOSet.Netwr` aus dem Cache.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
## Ideen und Kennzahlen-Katalog
|
||||
|
||||
@@ -177,15 +204,14 @@ Die Simulation nutzt feste Canvas-Groessen, sichtbare Achsen, waehlbare Diagramm
|
||||
|
||||
## Naechster Schritt fuer Live-Daten
|
||||
|
||||
Fuer definitive Vollwerte muessen die Live-Quellen noch fachlich fertig aggregiert werden:
|
||||
Die technische Vollbasis ist geladen. Fuer fachlich finale Management-Sichten muessen noch diese Abgrenzungen abgestimmt werden:
|
||||
|
||||
- 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.
|
||||
- Periodenlogik fuer historische und offene Werte.
|
||||
- Kontrakte und offene Verpflichtungen, inkl. fachlicher Abgrenzung von normalen Bestellungen und Umlagerungen.
|
||||
- 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.
|
||||
Der Delta-/Refresh-Prozess ist technisch vorbereitet und im Dashboard unter `Einkauf > Ideen > Einkauf-Datenservice` bedienbar.
|
||||
|
||||
## Geaenderte Programmstellen
|
||||
|
||||
@@ -200,7 +226,12 @@ Danach koennen Filter, Aggregationen und Delta-/Refresh-Prozess analog zu Financ
|
||||
- `Services/IPurchasingDashboardService.cs`
|
||||
- Live-State um Spend, offene Menge, offenen Wert, Kontraktwert und Live-Diagrammzeilen erweitert.
|
||||
- `Services/PurchasingDashboardService.cs`
|
||||
- Laedt EKKO, EKPO und EKET.
|
||||
- Liest EKKO, EKPO und EKET aus dem Einkauf-Cache und nutzt SAP-Live nur als Fallback.
|
||||
- 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.
|
||||
- `Services/PurchasingDataRefreshService.cs`
|
||||
- Fuehrt Full Load und Delta-Refresh fuer EKKO/EKPO/EKET aus.
|
||||
- Beruecksichtigt das SAP-Seitenlimit von 1'000 Zeilen.
|
||||
- `Services/DatabaseInitializationService.SchemaSql.cs`
|
||||
- Erstellt `PurchasingEkkoCache`, `PurchasingEkpoCache`, `PurchasingEketCache` und `PurchasingSyncState`.
|
||||
|
||||
@@ -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; die frueheren Tabs wurden in linke Navigationspunkte unter `Einkauf` aufgeteilt: Dashboard, Spend, offene Bestellungen, Kontrakte, Lieferanten, Ideen, Kennzahlen-Katalog, PBIX Vorlage und 3D Simulation.
|
||||
- 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.
|
||||
- Einkauf: `Einkauf > Datenquellen` pflegt die SAP/OData-Konfiguration grafisch und ist mit `EKKOSet`, `EKPOSet`, `eketSet`, `Data`, `Data2`, Joins und Zielmappings vorbefuellt. `/einkauf` liest jetzt den Einkauf-Cache aus SAP-Full-Load; Stand 2026-06-05: EKKO 172'874, EKPO 233'921, EKET 242'572 Zeilen. Delta-Refresh ist unter `Einkauf > Ideen > Einkauf-Datenservice` vorbereitet.
|
||||
- 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