diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor index a548237..16b759e 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -1,8 +1,10 @@ @page "/einkauf" @using System.Globalization @using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services @inject TrafagSalesExporter.Services.IUiTextService UiText @inject IJSRuntime JsRuntime +@inject IPurchasingDashboardService PurchasingDashboardService @T("Einkauf", "Purchasing") @@ -13,8 +15,7 @@ - @T("Die PBIX-Vorlage liefert Struktur und Kennzahlenlogik. Live-Werte werden sichtbar, sobald die SAP-Einkaufsdatenquelle angebunden ist.", - "The PBIX template provides structure and KPI logic. Live values will appear once the SAP purchasing data source is connected.") + @PurchasingStatusText @@ -186,6 +187,8 @@ @code { private ElementReference _purchasing3dCanvas; + private PurchasingDashboardLiveState _liveState = new(); + private bool _liveLoading = true; private string _purchasing3dIndicator = "spend"; private string _purchasing3dChartType = "bar"; private double _purchasing3dFactor = 1d; @@ -193,10 +196,10 @@ private IReadOnlyList 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("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("Kontrakte", "Contracts", FormatChf(Purchasing3dBaseRows.Sum(x => x.ContractValue)), "Restverpflichtungen aus Simulationsbasis", "Remaining commitments from simulation basis", 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("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", _liveState.EkkoLoaded ? _liveState.PurchaseOrderCount.ToString("N0") : "-", _liveState.EkkoLoaded ? "EKKO-Belege seit Jahresbeginn" : "Noch nicht geladen", _liveState.EkkoLoaded ? "EKKO orders since start of year" : "Not loaded yet", Icons.Material.Filled.PendingActions, Color.Warning), + new("Kontrakte", "Contracts", _liveState.EketLoaded ? _liveState.ScheduleSampleCount.ToString("N0") : T("wartet auf EKET", "waiting for EKET"), _liveState.EketLoaded ? "Termin-/Einteilungsprobe verfuegbar" : "EKKO live, Terminwerte fehlen noch", _liveState.EketLoaded ? "Schedule sample available" : "EKKO live, schedule values still missing", Icons.Material.Filled.Assignment, Color.Info), + new("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 AnalysisAxes = @@ -283,6 +286,12 @@ 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) { if (firstRender) @@ -291,6 +300,15 @@ private string T(string german, string english) => UiText.Text(german, english); 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) { diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index fcb2c4f..f545cd2 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -115,6 +115,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/TrafagSalesExporter/Services/IPurchasingDashboardService.cs b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs new file mode 100644 index 0000000..dab4f68 --- /dev/null +++ b/TrafagSalesExporter/Services/IPurchasingDashboardService.cs @@ -0,0 +1,20 @@ +namespace TrafagSalesExporter.Services; + +public interface IPurchasingDashboardService +{ + Task 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; +} diff --git a/TrafagSalesExporter/Services/PurchasingDashboardService.cs b/TrafagSalesExporter/Services/PurchasingDashboardService.cs new file mode 100644 index 0000000..567d4b2 --- /dev/null +++ b/TrafagSalesExporter/Services/PurchasingDashboardService.cs @@ -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 _dbFactory; + + public PurchasingDashboardService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task 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() + .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>> 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 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 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; + } +}