Load purchasing dashboard live EKKO data
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
@page "/einkauf"
|
@page "/einkauf"
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject IPurchasingDashboardService PurchasingDashboardService
|
||||||
|
|
||||||
<PageTitle>@T("Einkauf", "Purchasing")</PageTitle>
|
<PageTitle>@T("Einkauf", "Purchasing")</PageTitle>
|
||||||
|
|
||||||
@@ -13,8 +15,7 @@
|
|||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||||
@T("Die PBIX-Vorlage liefert Struktur und Kennzahlenlogik. Live-Werte werden sichtbar, sobald die SAP-Einkaufsdatenquelle angebunden ist.",
|
@PurchasingStatusText
|
||||||
"The PBIX template provides structure and KPI logic. Live values will appear once the SAP purchasing data source is connected.")
|
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
|
|
||||||
<MudGrid Class="mb-4" Spacing="2">
|
<MudGrid Class="mb-4" Spacing="2">
|
||||||
@@ -186,6 +187,8 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private ElementReference _purchasing3dCanvas;
|
private ElementReference _purchasing3dCanvas;
|
||||||
|
private PurchasingDashboardLiveState _liveState = new();
|
||||||
|
private bool _liveLoading = true;
|
||||||
private string _purchasing3dIndicator = "spend";
|
private string _purchasing3dIndicator = "spend";
|
||||||
private string _purchasing3dChartType = "bar";
|
private string _purchasing3dChartType = "bar";
|
||||||
private double _purchasing3dFactor = 1d;
|
private double _purchasing3dFactor = 1d;
|
||||||
@@ -193,10 +196,10 @@
|
|||||||
|
|
||||||
private IReadOnlyList<PurchasingKpiCard> KpiCards =>
|
private IReadOnlyList<PurchasingKpiCard> KpiCards =>
|
||||||
[
|
[
|
||||||
new("Spend total", "Total spend", FormatChf(Purchasing3dBaseRows.Sum(x => x.Spend)), "Simulationsbasis bis SAP-Liveimport", "Simulation basis until SAP live import", Icons.Material.Filled.Payments, Color.Primary),
|
new("Spend total", "Total spend", _liveState.EkpoLoaded ? "EKPO live" : T("wartet auf EKPO", "waiting for EKPO"), _liveState.EkpoLoaded ? "Positionsdaten verfuegbar" : "EKKO live, Positionswerte fehlen noch", _liveState.EkpoLoaded ? "Position data available" : "EKKO live, position values still missing", Icons.Material.Filled.Payments, Color.Primary),
|
||||||
new("Offene Bestellungen", "Open orders", FormatChf(Purchasing3dBaseRows.Sum(x => x.OpenValue)), "Wert offen aus Simulationsbasis", "Open value from simulation basis", Icons.Material.Filled.PendingActions, Color.Warning),
|
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", FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), "Restverpflichtungen aus Simulationsbasis", "Remaining commitments from simulation basis", Icons.Material.Filled.Assignment, Color.Info),
|
new("Kontrakte", "Contracts", _liveState.EketLoaded ? _liveState.ScheduleSampleCount.ToString("N0") : T("wartet auf EKET", "waiting for EKET"), _liveState.EketLoaded ? "Termin-/Einteilungsprobe verfuegbar" : "EKKO live, Terminwerte fehlen noch", _liveState.EketLoaded ? "Schedule sample available" : "EKKO live, schedule values still missing", Icons.Material.Filled.Assignment, Color.Info),
|
||||||
new("Lieferantenperformance", "Supplier performance", $"{Purchasing3dBaseRows.Average(x => x.SupplierScore):N1}%", "Durchschnitt Score aus Simulationsbasis", "Average score from simulation basis", Icons.Material.Filled.Verified, Color.Success)
|
new("Lieferantenperformance", "Supplier performance", _liveState.EkkoLoaded ? _liveState.SupplierCount.ToString("N0") : "-", _liveState.EkkoLoaded ? "Lieferanten in EKKO-Liveprobe" : "Noch nicht geladen", _liveState.EkkoLoaded ? "Suppliers in EKKO live sample" : "Not loaded yet", Icons.Material.Filled.Verified, Color.Success)
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<PurchasingAxis> AnalysisAxes =
|
private readonly List<PurchasingAxis> AnalysisAxes =
|
||||||
@@ -283,6 +286,12 @@
|
|||||||
new("Artikel Top 10", 2026, 680000d, 105000d, 3350d, 195000d, 92d)
|
new("Artikel Top 10", 2026, 680000d, 105000d, 3350d, 195000d, 92d)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_liveState = await PurchasingDashboardService.LoadAsync();
|
||||||
|
_liveLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
@@ -291,6 +300,15 @@
|
|||||||
|
|
||||||
private string T(string german, string english) => UiText.Text(german, english);
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
private static string FormatChf(double value) => $"CHF {value:N0}";
|
private static string FormatChf(double value) => $"CHF {value:N0}";
|
||||||
|
private string PurchasingStatusText
|
||||||
|
=> _liveLoading
|
||||||
|
? T("SAP-Einkaufsdaten werden geladen...", "Loading SAP purchasing data...")
|
||||||
|
: $"{_liveState.Message} {FormatLatestOrderDate()}";
|
||||||
|
|
||||||
|
private string FormatLatestOrderDate()
|
||||||
|
=> _liveState.LatestOrderDate.HasValue
|
||||||
|
? $"{T("Letztes EKKO-Datum", "Latest EKKO date")}: {_liveState.LatestOrderDate.Value:yyyy-MM-dd}."
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
private async Task SetPurchasing3dIndicator(string value)
|
private async Task SetPurchasing3dIndicator(string value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ builder.Services.AddScoped<ILogsPageService, LogsPageService>();
|
|||||||
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
|
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
|
||||||
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
|
||||||
builder.Services.AddScoped<IPurchasingDataSourcePageService, PurchasingDataSourcePageService>();
|
builder.Services.AddScoped<IPurchasingDataSourcePageService, PurchasingDataSourcePageService>();
|
||||||
|
builder.Services.AddScoped<IPurchasingDashboardService, PurchasingDashboardService>();
|
||||||
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
|
||||||
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
|
||||||
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
|
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IPurchasingDashboardService
|
||||||
|
{
|
||||||
|
Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PurchasingDashboardLiveState
|
||||||
|
{
|
||||||
|
public bool SapReachable { get; set; }
|
||||||
|
public bool EkkoLoaded { get; set; }
|
||||||
|
public bool EkpoLoaded { get; set; }
|
||||||
|
public bool EketLoaded { get; set; }
|
||||||
|
public int PurchaseOrderCount { get; set; }
|
||||||
|
public int SupplierCount { get; set; }
|
||||||
|
public DateTime? LatestOrderDate { get; set; }
|
||||||
|
public int PositionSampleCount { get; set; }
|
||||||
|
public int ScheduleSampleCount { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public sealed class PurchasingDashboardService : IPurchasingDashboardService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public PurchasingDashboardService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var state = new PurchasingDashboardLiveState();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
state.Message = "SAP Einkaufsquelle ist noch nicht konfiguriert.";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
state.Message = "SAP URL oder Zugangsdaten fehlen.";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = CreateClient(username, password);
|
||||||
|
var baseUrl = serviceUrl.TrimEnd('/') + "/";
|
||||||
|
var currentYear = DateTime.Today.Year;
|
||||||
|
var ekkoFilter = Uri.EscapeDataString($"Bedat ge '{currentYear}-01-01'");
|
||||||
|
var ekkoCount = await ReadCountAsync(
|
||||||
|
client,
|
||||||
|
$"{baseUrl}EKKOSet/$count?$filter={ekkoFilter}",
|
||||||
|
cancellationToken);
|
||||||
|
var ekkoRows = await ReadRowsAsync(
|
||||||
|
client,
|
||||||
|
$"{baseUrl}EKKOSet?$format=json&$top=1000&$filter={ekkoFilter}&$select=Ebeln,Bedat,Lifnr",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
state.SapReachable = true;
|
||||||
|
state.EkkoLoaded = ekkoRows.Count > 0;
|
||||||
|
state.PurchaseOrderCount = ekkoCount ?? ekkoRows.Count;
|
||||||
|
state.SupplierCount = ekkoRows
|
||||||
|
.Select(row => GetText(row, "Lifnr"))
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Count();
|
||||||
|
state.LatestOrderDate = ekkoRows
|
||||||
|
.Select(row => TryParseSapDate(GetText(row, "Bedat")))
|
||||||
|
.Where(date => date.HasValue)
|
||||||
|
.Select(date => date!.Value)
|
||||||
|
.OrderByDescending(date => date)
|
||||||
|
.Cast<DateTime?>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var firstEbeln = ekkoRows.Select(row => GetText(row, "Ebeln")).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
if (!string.IsNullOrWhiteSpace(firstEbeln))
|
||||||
|
{
|
||||||
|
var ekpoRows = await ReadRowsAsync(
|
||||||
|
client,
|
||||||
|
$"{baseUrl}EKPOSet?$format=json&$top=50&$filter={Uri.EscapeDataString($"Ebeln eq '{firstEbeln}'")}",
|
||||||
|
cancellationToken);
|
||||||
|
state.PositionSampleCount = ekpoRows.Count;
|
||||||
|
state.EkpoLoaded = ekpoRows.Count > 0;
|
||||||
|
|
||||||
|
var eketRows = await ReadRowsAsync(
|
||||||
|
client,
|
||||||
|
$"{baseUrl}eketSet?$format=json&$top=50&$filter={Uri.EscapeDataString($"Ebeln eq '{firstEbeln}'")}",
|
||||||
|
cancellationToken);
|
||||||
|
state.ScheduleSampleCount = eketRows.Count;
|
||||||
|
state.EketLoaded = eketRows.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Message = state.EkpoLoaded
|
||||||
|
? "SAP Einkaufsdaten geladen."
|
||||||
|
: "EKKO ist live geladen; EKPO/EKET liefern aktuell noch keine Positionsdaten.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
state.Message = $"SAP Einkauf konnte nicht geladen werden: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateClient(string username, string password)
|
||||||
|
{
|
||||||
|
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(45) };
|
||||||
|
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?>>> ReadRowsAsync(HttpClient client, string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
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<int?> ReadCountAsync(HttpClient client, string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var text = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 DateTime? TryParseSapDate(string value)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
return DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user