From bb5e5150b9e65128956c7d06358f89ab790c174a Mon Sep 17 00:00:00 2001 From: metacube Date: Fri, 5 Jun 2026 07:45:30 +0200 Subject: [PATCH] Add purchasing data sources and 3D simulation --- .../Pages/PurchasingDashboard.razor | 197 +++++++++++++ .../Pages/PurchasingDataSources.razor | 272 ++++++++++++++++++ TrafagSalesExporter/Program.cs | 1 + .../Services/DatabaseSeedService.cs | 85 ++++++ .../IPurchasingDataSourcePageService.cs | 20 ++ .../PurchasingDataSourcePageService.cs | 240 ++++++++++++++++ .../DatabaseInitializationServiceTests.cs | 8 + .../docs/PURCHASING_DASHBOARD_2026-06-05.md | 33 +++ TrafagSalesExporter/docs/rag/PROJECT.md | 3 +- TrafagSalesExporter/lastchange.md | 9 +- 10 files changed, 865 insertions(+), 3 deletions(-) create mode 100644 TrafagSalesExporter/Components/Pages/PurchasingDataSources.razor create mode 100644 TrafagSalesExporter/Services/IPurchasingDataSourcePageService.cs create mode 100644 TrafagSalesExporter/Services/PurchasingDataSourcePageService.cs diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor index 8de7df6..37344b7 100644 --- a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -1,6 +1,8 @@ @page "/einkauf" +@using System.Globalization @using TrafagSalesExporter.Models @inject TrafagSalesExporter.Services.IUiTextService UiText +@inject IJSRuntime JsRuntime @T("Einkauf", "Purchasing") @@ -119,9 +121,76 @@ + + + + + + + @foreach (var option in Purchasing3dIndicators) + { + @T(option.TitleDe, option.TitleEn) + } + + + + + @T("Balken", "Bars") + @T("Linie", "Line") + @T("Flaeche", "Surface") + @T("Kreis", "Pie") + + + + @T("Preis-/Wechselkurs-Szenario", "Price/exchange-rate scenario") +
+ -10% + + @_purchasing3dFactor.ToString("0.00", CultureInfo.InvariantCulture)x +
+ + @T("Delta", "Delta"): @FormatScenarioDelta() + +
+ + @T("Beschriftung", "Labels") +
+ + @_purchasing3dLabelScale.ToString("0.0", CultureInfo.InvariantCulture)x +
+
+ + + @T("Neu zeichnen", "Redraw") + + +
+
+ + + +
@code { + private ElementReference _purchasing3dCanvas; + private string _purchasing3dIndicator = "spend"; + private string _purchasing3dChartType = "bar"; + private double _purchasing3dFactor = 1d; + private double _purchasing3dLabelScale = 1.5d; + private readonly List KpiCards = [ new("Spend total", "Total spend", "-", "Netwr CHF historisch", "Historic Netwr CHF", Icons.Material.Filled.Payments, Color.Primary), @@ -189,12 +258,134 @@ new("Matrix Vol./WG", "Pivot, Slicer", "Sum(EKPOSet.Netwr CHF)", "Warengruppe, Lieferant, Artikel") ]; + private readonly List Purchasing3dIndicators = + [ + new("spend", "Spend CHF", "Spend CHF", "CHF"), + new("openValue", "Offener Bestellwert", "Open order value", "CHF"), + new("openQuantity", "Offene Menge", "Open quantity", "Qty"), + new("contractValue", "Kontrakt-Restwert", "Contract remaining value", "CHF"), + new("supplierScore", "Lieferantenperformance", "Supplier performance", "%") + ]; + + private readonly List Purchasing3dBaseRows = + [ + new("Lieferant A", 2024, 1450000d, 260000d, 11800d, 420000d, 91d), + new("Lieferant A", 2025, 1680000d, 310000d, 13200d, 460000d, 93d), + new("Lieferant A", 2026, 1820000d, 335000d, 14100d, 490000d, 92d), + new("Lieferant B", 2024, 980000d, 190000d, 9300d, 260000d, 86d), + new("Lieferant B", 2025, 1120000d, 225000d, 10100d, 315000d, 88d), + new("Lieferant B", 2026, 1240000d, 250000d, 10800d, 350000d, 89d), + new("Warengruppe Sensorik", 2024, 760000d, 120000d, 6400d, 210000d, 94d), + new("Warengruppe Sensorik", 2025, 890000d, 145000d, 7100d, 230000d, 95d), + new("Warengruppe Sensorik", 2026, 940000d, 155000d, 7400d, 245000d, 94d), + new("Artikel Top 10", 2024, 520000d, 83000d, 2800d, 160000d, 90d), + new("Artikel Top 10", 2025, 610000d, 97000d, 3100d, 180000d, 91d), + new("Artikel Top 10", 2026, 680000d, 105000d, 3350d, 195000d, 92d) + ]; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await RenderPurchasing3dAsync(); + } + private string T(string german, string english) => UiText.Text(german, english); + private async Task SetPurchasing3dIndicator(string value) + { + _purchasing3dIndicator = value; + await RenderPurchasing3dAsync(); + } + + private async Task SetPurchasing3dChartType(string value) + { + _purchasing3dChartType = value; + await RenderPurchasing3dAsync(); + } + + private async Task SetPurchasing3dFactor(ChangeEventArgs args) + { + if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) + { + _purchasing3dFactor = Math.Clamp(value, 0.5d, 1.5d); + await JsRuntime.InvokeVoidAsync("trafagFinance3d.updateFactor", _purchasing3dCanvas, ScenarioAffectsPurchasingValue ? _purchasing3dFactor : 1d); + } + } + + private async Task SetPurchasing3dFactorPreset(double value) + { + _purchasing3dFactor = Math.Clamp(value, 0.5d, 1.5d); + await JsRuntime.InvokeVoidAsync("trafagFinance3d.updateFactor", _purchasing3dCanvas, ScenarioAffectsPurchasingValue ? _purchasing3dFactor : 1d); + } + + private async Task SetPurchasing3dLabelScale(ChangeEventArgs args) + { + if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) + { + _purchasing3dLabelScale = Math.Clamp(value, 0.8d, 2.5d); + await RenderPurchasing3dAsync(); + } + } + + private async Task RenderPurchasing3dAsync() + { + await JsRuntime.InvokeVoidAsync("trafagFinance3d.render", _purchasing3dCanvas, BuildPurchasing3dRows(), new + { + indicator = _purchasing3dIndicator, + title = ResolvePurchasing3dIndicatorLabel(), + chartType = _purchasing3dChartType, + xAxis = T("X: Lieferant / Warengruppe / Artikel", "X: supplier / material group / article"), + yAxis = T("Y: Wert / Menge / Score", "Y: value / quantity / score"), + zAxis = T("Z: Jahr / Zeit", "Z: year / time"), + pieAxis = T("Kreis: Anteil am aktuellen Indikator", "Pie: share of current indicator"), + labelScale = _purchasing3dLabelScale, + scenarioFactor = ScenarioAffectsPurchasingValue ? _purchasing3dFactor : 1d + }); + } + + private IReadOnlyList BuildPurchasing3dRows() + => Purchasing3dBaseRows + .Select(row => new + { + country = row.Axis, + year = row.Year, + value = ResolvePurchasing3dValue(row) + }) + .Cast() + .ToList(); + + private double ResolvePurchasing3dValue(Purchasing3dBaseRow row) => _purchasing3dIndicator switch + { + "openValue" => row.OpenValue, + "openQuantity" => row.OpenQuantity, + "contractValue" => row.ContractValue, + "supplierScore" => row.SupplierScore, + _ => row.Spend + }; + + private bool ScenarioAffectsPurchasingValue => _purchasing3dIndicator is "spend" or "openValue" or "contractValue"; + + private string ResolvePurchasing3dIndicatorLabel() + => T( + Purchasing3dIndicators.FirstOrDefault(x => x.Key == _purchasing3dIndicator)?.TitleDe ?? "Spend CHF", + Purchasing3dIndicators.FirstOrDefault(x => x.Key == _purchasing3dIndicator)?.TitleEn ?? "Spend CHF"); + + private string FormatScenarioDelta() + { + if (!ScenarioAffectsPurchasingValue) + return T("nicht auf diesen Indikator angewendet", "not applied to this indicator"); + + var baseTotal = Purchasing3dBaseRows.Sum(ResolvePurchasing3dValue); + var delta = baseTotal * _purchasing3dFactor - baseTotal; + return $"{delta:N0} {Purchasing3dIndicators.First(x => x.Key == _purchasing3dIndicator).Unit}"; + } + private sealed record PurchasingKpiCard(string TitleDe, string TitleEn, string Value, string DetailDe, string DetailEn, string Icon, Color Color); private sealed record PurchasingAxis(string LabelDe, string LabelEn, string Field, string UsageDe, string UsageEn); private sealed record PurchasingSource(string Name, string Description); private sealed record PowerBiPageInfo(string Page, string Visuals, string Measure, string Dimensions); + private sealed record Purchasing3dIndicator(string Key, string TitleDe, string TitleEn, string Unit); + private sealed record Purchasing3dBaseRow(string Axis, int Year, double Spend, double OpenValue, double OpenQuantity, double ContractValue, double SupplierScore); } diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDataSources.razor b/TrafagSalesExporter/Components/Pages/PurchasingDataSources.razor new file mode 100644 index 0000000..e47ca9a --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/PurchasingDataSources.razor @@ -0,0 +1,272 @@ +@page "/einkauf/verbindungen" +@using TrafagSalesExporter.Services +@inject IPurchasingDataSourcePageService DataSourceService +@inject TrafagSalesExporter.Services.IUiTextService UiText +@inject ISnackbar Snackbar + +@T("Einkauf Datenquellen", "Purchasing data sources") + +@T("Einkauf Datenquellen", "Purchasing data sources") + + @T("Grafische SAP/OData-Anbindung fuer das Einkaufsdashboard, analog zur Finance-Quellenpflege.", + "Graphical SAP/OData connection for the purchasing dashboard, following the finance source configuration pattern.") + + +@if (_loading) +{ + +} +else +{ + + + + @T("Verbindung", "Connection") + + + + + + + + + + + + + + + + + + + + + + + @T("Speichern", "Save") + + + @T("Verbindung testen", "Test connection") + + + @T("Defaults wiederherstellen", "Restore defaults") + + + + + + + @T("Aktuelle Basis", "Current basis") +
+ + @T("Zentrale URL", "Central URL"): @Display(_state.SourceSystem?.CentralServiceUrl) +
+
+ + @T("Quellen", "Sources"): @_state.Sources.Count(x => x.IsActive) +
+
+ + @T("Joins", "Joins"): @_state.Joins.Count(x => x.IsActive) +
+
+ + @T("Mappings", "Mappings"): @_state.Mappings.Count(x => x.IsActive) +
+
+
+
+ + + + + @T("OData Entity Sets", "OData entity sets") + @T("Quelle", "Source") + + + + Alias + Entity Set + @T("Primaer", "Primary") + @T("Aktiv", "Active") + + + + + + + + + + + + + + + @foreach (var join in _state.Joins.Where(x => x.IsActive)) + { + + + @join.LeftAlias -> @join.RightAlias + @join.LeftKeys = @join.RightKeys + + + } + + + @T("Joins", "Joins") + @T("Join", "Join") + + + + @T("Links", "Left") + Left Keys + @T("Rechts", "Right") + Right Keys + @T("Typ", "Type") + @T("Aktiv", "Active") + + + + + + + + + + + + + + + + + @T("Zielfelder", "Target fields") + @T("Mapping", "Mapping") + + + + @T("Ziel", "Target") + @T("Quelle", "Source") + @T("Pflicht", "Required") + @T("Aktiv", "Active") + + + + + + + + + + + + +} + +@code { + private PurchasingDataSourceState _state = new(); + private bool _loading = true; + private bool _busy; + + protected override async Task OnInitializedAsync() + { + _state = await DataSourceService.LoadAsync(); + _loading = false; + } + + private async Task SaveAsync() + { + await RunAsync(async () => + { + _state = await DataSourceService.SaveAsync(_state); + Snackbar.Add(T("Einkaufsdatenquellen gespeichert.", "Purchasing data sources saved."), Severity.Success); + }); + } + + private async Task ResetDefaultsAsync() + { + await RunAsync(async () => + { + _state = await DataSourceService.ResetDefaultsAsync(); + Snackbar.Add(T("Einkaufsdatenquellen auf Defaults gesetzt.", "Purchasing data sources restored to defaults."), Severity.Info); + }); + } + + private async Task TestConnectionAsync() + { + await RunAsync(async () => + { + var result = await DataSourceService.TestConnectionAsync(_state); + Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error); + }); + } + + private async Task RunAsync(Func action) + { + if (_busy) + return; + + _busy = true; + try + { + await action(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + finally + { + _busy = false; + } + } + + private void AddSource() + => _state.Sources.Add(new SapSourceDefinition { Alias = "NEW", EntitySet = "NewEntitySet", IsActive = true, SortOrder = (_state.Sources.Count + 1) * 10 }); + + private void AddJoin() + => _state.Joins.Add(new SapJoinDefinition { LeftAlias = "EKKO", RightAlias = "NEW", LeftKeys = "Key", RightKeys = "Key", JoinType = "Left", IsActive = true, SortOrder = (_state.Joins.Count + 1) * 10 }); + + private void AddMapping() + => _state.Mappings.Add(new SapFieldMapping { TargetField = "NewField", SourceExpression = "Alias.Field", IsActive = true, SortOrder = (_state.Mappings.Count + 1) * 10 }); + + private void RemoveSource(SapSourceDefinition source) => _state.Sources.Remove(source); + private void RemoveJoin(SapJoinDefinition join) => _state.Joins.Remove(join); + private void RemoveMapping(SapFieldMapping mapping) => _state.Mappings.Remove(mapping); + + private string T(string german, string english) => UiText.Text(german, english); + private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value; +} + + diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 47a907d..fcb2c4f 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -114,6 +114,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/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index 610db70..0fe251b 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -17,6 +17,7 @@ public class DatabaseSeedService : IDatabaseSeedService EnsureGermanyManualExcelSite(db); EnsureUkManualExcelFolder(db); EnsureSapODataDachSite(db); + EnsurePurchasingSapSite(db); EnsureFinanceReferenceDefaults(db); EnsureBudgetExchangeRateDefaults(db); EnsureFinanceIntercompanyRuleDefaults(db); @@ -180,6 +181,7 @@ public class DatabaseSeedService : IDatabaseSeedService Link("hr-training", "hr", "HR KPI Schulung", "HR KPI training", "School", "hr-kpi/schulung", 20), Group("purchasing", null, "Einkauf", "Purchasing", "ShoppingCart", 30), Link("purchasing-dashboard", "purchasing", "Einkauf Dashboard", "Purchasing dashboard", "Dashboard", "einkauf", 10, "All"), + Link("purchasing-data-sources", "purchasing", "Datenquellen", "Data sources", "Hub", "einkauf/verbindungen", 20, "All"), Link("admin-sessions", null, "Admin Bereich", "Admin area", "PeopleAlt", "admin/sessions", 90) ]; @@ -968,6 +970,89 @@ public class DatabaseSeedService : IDatabaseSeedService db.SaveChanges(); } + private static void EnsurePurchasingSapSite(AppDbContext db) + { + if (db.Sites.Count() <= 1) + return; + + var site = db.Sites + .OrderBy(x => x.Id) + .FirstOrDefault(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc); + + var changed = false; + if (site is null) + { + site = new Site + { + Schema = string.Empty, + TSC = PurchasingDataSourcePageService.PurchasingTsc, + Land = "Einkauf SAP", + SourceSystem = "SAP", + IsActive = false + }; + db.Sites.Add(site); + db.SaveChanges(); + } + else + { + if (site.SourceSystem != "SAP") + { + site.SourceSystem = "SAP"; + changed = true; + } + + if (string.IsNullOrWhiteSpace(site.Land)) + { + site.Land = "Einkauf SAP"; + changed = true; + } + } + + if (!db.SapSourceDefinitions.Any(x => x.SiteId == site.Id)) + { + db.SapSourceDefinitions.AddRange( + new SapSourceDefinition { SiteId = site.Id, Alias = "EKKO", EntitySet = "EKKOSet", IsPrimary = true, IsActive = true, SortOrder = 10 }, + new SapSourceDefinition { SiteId = site.Id, Alias = "EKPO", EntitySet = "EKPOSet", IsPrimary = false, IsActive = true, SortOrder = 20 }, + new SapSourceDefinition { SiteId = site.Id, Alias = "EKET", EntitySet = "eketSet", IsPrimary = false, IsActive = true, SortOrder = 30 }, + new SapSourceDefinition { SiteId = site.Id, Alias = "LIEF", EntitySet = "Data", IsPrimary = false, IsActive = true, SortOrder = 40 }, + new SapSourceDefinition { SiteId = site.Id, Alias = "WG", EntitySet = "Data2", IsPrimary = false, IsActive = true, SortOrder = 50 }); + changed = true; + } + + if (!db.SapJoinDefinitions.Any(x => x.SiteId == site.Id)) + { + db.SapJoinDefinitions.AddRange( + new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKKO", RightAlias = "EKPO", LeftKeys = "Ebeln", RightKeys = "Ebeln", JoinType = "Left", IsActive = true, SortOrder = 10 }, + new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKPO", RightAlias = "EKET", LeftKeys = "Ebeln,Ebelp", RightKeys = "Ebeln,Ebelp", JoinType = "Left", IsActive = true, SortOrder = 20 }, + new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKKO", RightAlias = "LIEF", LeftKeys = "Lifnr", RightKeys = "Lifnr", JoinType = "Left", IsActive = true, SortOrder = 30 }, + new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKPO", RightAlias = "WG", LeftKeys = "Matkl", RightKeys = "Matkl", JoinType = "Left", IsActive = true, SortOrder = 40 }); + changed = true; + } + + if (!db.SapFieldMappings.Any(x => x.SiteId == site.Id)) + { + db.SapFieldMappings.AddRange( + new SapFieldMapping { SiteId = site.Id, TargetField = "PurchaseOrder", SourceExpression = "EKKO.Ebeln", IsRequired = true, IsActive = true, SortOrder = 10 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "PurchaseOrderDate", SourceExpression = "EKKO.Bedat", IsRequired = true, IsActive = true, SortOrder = 20 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "SupplierNumber", SourceExpression = "EKKO.Lifnr", IsRequired = false, IsActive = true, SortOrder = 30 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "SupplierName", SourceExpression = "LIEF.Name", IsRequired = false, IsActive = true, SortOrder = 40 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "Position", SourceExpression = "EKPO.Ebelp", IsRequired = true, IsActive = true, SortOrder = 50 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "Material", SourceExpression = "EKPO.Matnr", IsRequired = false, IsActive = true, SortOrder = 60 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialText", SourceExpression = "EKPO.Txz01", IsRequired = false, IsActive = true, SortOrder = 70 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialGroup", SourceExpression = "EKPO.Matkl", IsRequired = false, IsActive = true, SortOrder = 80 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialGroupText", SourceExpression = "WG.WgKomplett", IsRequired = false, IsActive = true, SortOrder = 90 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "NetValueChf", SourceExpression = "EKPO.NetwrChf", IsRequired = false, IsActive = true, SortOrder = 100 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "NetValueChfPerPiece", SourceExpression = "EKPO.NetwrChfStk", IsRequired = false, IsActive = true, SortOrder = 110 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "OrderQuantity", SourceExpression = "EKPO.Menge", IsRequired = false, IsActive = true, SortOrder = 120 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "ScheduleDate", SourceExpression = "EKET.Eindt", IsRequired = false, IsActive = true, SortOrder = 130 }, + new SapFieldMapping { SiteId = site.Id, TargetField = "ScheduleQuantity", SourceExpression = "EKET.Menge", IsRequired = false, IsActive = true, SortOrder = 140 }); + changed = true; + } + + if (changed) + db.SaveChanges(); + } + private static void EnsureFinanceReferenceDefaults(AppDbContext db) { var defaults = new[] diff --git a/TrafagSalesExporter/Services/IPurchasingDataSourcePageService.cs b/TrafagSalesExporter/Services/IPurchasingDataSourcePageService.cs new file mode 100644 index 0000000..99e1573 --- /dev/null +++ b/TrafagSalesExporter/Services/IPurchasingDataSourcePageService.cs @@ -0,0 +1,20 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IPurchasingDataSourcePageService +{ + Task LoadAsync(); + Task SaveAsync(PurchasingDataSourceState state); + Task ResetDefaultsAsync(); + Task TestConnectionAsync(PurchasingDataSourceState state); +} + +public sealed class PurchasingDataSourceState +{ + public Site Site { get; set; } = new(); + public SourceSystemDefinition? SourceSystem { get; set; } + public List Sources { get; set; } = []; + public List Joins { get; set; } = []; + public List Mappings { get; set; } = []; +} diff --git a/TrafagSalesExporter/Services/PurchasingDataSourcePageService.cs b/TrafagSalesExporter/Services/PurchasingDataSourcePageService.cs new file mode 100644 index 0000000..d92df42 --- /dev/null +++ b/TrafagSalesExporter/Services/PurchasingDataSourcePageService.cs @@ -0,0 +1,240 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public sealed class PurchasingDataSourcePageService : IPurchasingDataSourcePageService +{ + public const string PurchasingTsc = "PURCHASING_SAP"; + + private readonly IDbContextFactory _dbFactory; + private readonly ISapGatewayService _sapGatewayService; + + public PurchasingDataSourcePageService(IDbContextFactory dbFactory, ISapGatewayService sapGatewayService) + { + _dbFactory = dbFactory; + _sapGatewayService = sapGatewayService; + } + + public async Task LoadAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + await EnsureDefaultsAsync(db); + return await LoadStateAsync(db); + } + + public async Task SaveAsync(PurchasingDataSourceState state) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var site = await GetOrCreateSiteAsync(db); + + site.SapServiceUrl = state.Site.SapServiceUrl.Trim(); + site.UsernameOverride = state.Site.UsernameOverride.Trim(); + site.PasswordOverride = state.Site.PasswordOverride; + site.IsActive = state.Site.IsActive; + + Replace(db, db.SapSourceDefinitions.Where(x => x.SiteId == site.Id), state.Sources.Select((x, i) => new SapSourceDefinition + { + SiteId = site.Id, + Alias = x.Alias.Trim(), + EntitySet = x.EntitySet.Trim(), + IsPrimary = x.IsPrimary, + IsActive = x.IsActive, + SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder + })); + + Replace(db, db.SapJoinDefinitions.Where(x => x.SiteId == site.Id), state.Joins.Select((x, i) => new SapJoinDefinition + { + SiteId = site.Id, + LeftAlias = x.LeftAlias.Trim(), + RightAlias = x.RightAlias.Trim(), + LeftKeys = x.LeftKeys.Trim(), + RightKeys = x.RightKeys.Trim(), + JoinType = string.IsNullOrWhiteSpace(x.JoinType) ? "Left" : x.JoinType.Trim(), + IsActive = x.IsActive, + SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder + })); + + Replace(db, db.SapFieldMappings.Where(x => x.SiteId == site.Id), state.Mappings.Select((x, i) => new SapFieldMapping + { + SiteId = site.Id, + TargetField = x.TargetField.Trim(), + SourceExpression = x.SourceExpression.Trim(), + IsRequired = x.IsRequired, + IsActive = x.IsActive, + SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder + })); + + await db.SaveChangesAsync(); + return await LoadStateAsync(db); + } + + public async Task ResetDefaultsAsync() + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var site = await GetOrCreateSiteAsync(db); + + db.SapSourceDefinitions.RemoveRange(db.SapSourceDefinitions.Where(x => x.SiteId == site.Id)); + db.SapJoinDefinitions.RemoveRange(db.SapJoinDefinitions.Where(x => x.SiteId == site.Id)); + db.SapFieldMappings.RemoveRange(db.SapFieldMappings.Where(x => x.SiteId == site.Id)); + await db.SaveChangesAsync(); + + AddDefaultSources(db, site.Id); + AddDefaultJoins(db, site.Id); + AddDefaultMappings(db, site.Id); + await db.SaveChangesAsync(); + + return await LoadStateAsync(db); + } + + public async Task TestConnectionAsync(PurchasingDataSourceState state) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var sourceSystem = await db.SourceSystemDefinitions + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == "SAP"); + + var serviceUrl = string.IsNullOrWhiteSpace(state.Site.SapServiceUrl) + ? sourceSystem?.CentralServiceUrl ?? string.Empty + : state.Site.SapServiceUrl; + var username = string.IsNullOrWhiteSpace(state.Site.UsernameOverride) + ? sourceSystem?.CentralUsername ?? string.Empty + : state.Site.UsernameOverride; + var password = string.IsNullOrWhiteSpace(state.Site.PasswordOverride) + ? sourceSystem?.CentralPassword ?? string.Empty + : state.Site.PasswordOverride; + + if (string.IsNullOrWhiteSpace(serviceUrl)) + return PageActionResult.WarningResult("Keine SAP Service URL gepflegt."); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return PageActionResult.WarningResult("Keine SAP Gateway Zugangsdaten gepflegt."); + + try + { + await _sapGatewayService.TestConnectionAsync(serviceUrl.Trim(), username.Trim(), password); + return PageActionResult.SuccessResult("SAP OData Verbindung erfolgreich."); + } + catch (Exception ex) + { + return PageActionResult.ErrorResult($"SAP OData Verbindung fehlgeschlagen: {ex.Message}"); + } + } + + private async Task LoadStateAsync(AppDbContext db) + { + var site = await GetOrCreateSiteAsync(db); + var sourceSystem = await db.SourceSystemDefinitions + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == "SAP"); + + return new PurchasingDataSourceState + { + Site = Clone(site), + SourceSystem = sourceSystem, + Sources = await db.SapSourceDefinitions.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(), + Joins = await db.SapJoinDefinitions.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(), + Mappings = await db.SapFieldMappings.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync() + }; + } + + private static async Task EnsureDefaultsAsync(AppDbContext db) + { + var site = await GetOrCreateSiteAsync(db); + var hasSources = await db.SapSourceDefinitions.AnyAsync(x => x.SiteId == site.Id); + if (hasSources) + return; + + AddDefaultSources(db, site.Id); + AddDefaultJoins(db, site.Id); + AddDefaultMappings(db, site.Id); + await db.SaveChangesAsync(); + } + + private static async Task GetOrCreateSiteAsync(AppDbContext db) + { + var site = await db.Sites.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.TSC == PurchasingTsc); + if (site is not null) + return site; + + site = new Site + { + Schema = string.Empty, + TSC = PurchasingTsc, + Land = "Einkauf SAP", + SourceSystem = "SAP", + IsActive = false + }; + db.Sites.Add(site); + await db.SaveChangesAsync(); + return site; + } + + private static void AddDefaultSources(AppDbContext db, int siteId) + { + db.SapSourceDefinitions.AddRange( + new SapSourceDefinition { SiteId = siteId, Alias = "EKKO", EntitySet = "EKKOSet", IsPrimary = true, IsActive = true, SortOrder = 10 }, + new SapSourceDefinition { SiteId = siteId, Alias = "EKPO", EntitySet = "EKPOSet", IsPrimary = false, IsActive = true, SortOrder = 20 }, + new SapSourceDefinition { SiteId = siteId, Alias = "EKET", EntitySet = "eketSet", IsPrimary = false, IsActive = true, SortOrder = 30 }, + new SapSourceDefinition { SiteId = siteId, Alias = "LIEF", EntitySet = "Data", IsPrimary = false, IsActive = true, SortOrder = 40 }, + new SapSourceDefinition { SiteId = siteId, Alias = "WG", EntitySet = "Data2", IsPrimary = false, IsActive = true, SortOrder = 50 }); + } + + private static void AddDefaultJoins(AppDbContext db, int siteId) + { + db.SapJoinDefinitions.AddRange( + new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKKO", RightAlias = "EKPO", LeftKeys = "Ebeln", RightKeys = "Ebeln", JoinType = "Left", IsActive = true, SortOrder = 10 }, + new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKPO", RightAlias = "EKET", LeftKeys = "Ebeln,Ebelp", RightKeys = "Ebeln,Ebelp", JoinType = "Left", IsActive = true, SortOrder = 20 }, + new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKKO", RightAlias = "LIEF", LeftKeys = "Lifnr", RightKeys = "Lifnr", JoinType = "Left", IsActive = true, SortOrder = 30 }, + new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKPO", RightAlias = "WG", LeftKeys = "Matkl", RightKeys = "Matkl", JoinType = "Left", IsActive = true, SortOrder = 40 }); + } + + private static void AddDefaultMappings(AppDbContext db, int siteId) + { + db.SapFieldMappings.AddRange( + new SapFieldMapping { SiteId = siteId, TargetField = "PurchaseOrder", SourceExpression = "EKKO.Ebeln", IsRequired = true, IsActive = true, SortOrder = 10 }, + new SapFieldMapping { SiteId = siteId, TargetField = "PurchaseOrderDate", SourceExpression = "EKKO.Bedat", IsRequired = true, IsActive = true, SortOrder = 20 }, + new SapFieldMapping { SiteId = siteId, TargetField = "SupplierNumber", SourceExpression = "EKKO.Lifnr", IsRequired = false, IsActive = true, SortOrder = 30 }, + new SapFieldMapping { SiteId = siteId, TargetField = "SupplierName", SourceExpression = "LIEF.Name", IsRequired = false, IsActive = true, SortOrder = 40 }, + new SapFieldMapping { SiteId = siteId, TargetField = "Position", SourceExpression = "EKPO.Ebelp", IsRequired = true, IsActive = true, SortOrder = 50 }, + new SapFieldMapping { SiteId = siteId, TargetField = "Material", SourceExpression = "EKPO.Matnr", IsRequired = false, IsActive = true, SortOrder = 60 }, + new SapFieldMapping { SiteId = siteId, TargetField = "MaterialText", SourceExpression = "EKPO.Txz01", IsRequired = false, IsActive = true, SortOrder = 70 }, + new SapFieldMapping { SiteId = siteId, TargetField = "MaterialGroup", SourceExpression = "EKPO.Matkl", IsRequired = false, IsActive = true, SortOrder = 80 }, + new SapFieldMapping { SiteId = siteId, TargetField = "MaterialGroupText", SourceExpression = "WG.WgKomplett", IsRequired = false, IsActive = true, SortOrder = 90 }, + new SapFieldMapping { SiteId = siteId, TargetField = "NetValueChf", SourceExpression = "EKPO.NetwrChf", IsRequired = false, IsActive = true, SortOrder = 100 }, + new SapFieldMapping { SiteId = siteId, TargetField = "NetValueChfPerPiece", SourceExpression = "EKPO.NetwrChfStk", IsRequired = false, IsActive = true, SortOrder = 110 }, + new SapFieldMapping { SiteId = siteId, TargetField = "OrderQuantity", SourceExpression = "EKPO.Menge", IsRequired = false, IsActive = true, SortOrder = 120 }, + new SapFieldMapping { SiteId = siteId, TargetField = "ScheduleDate", SourceExpression = "EKET.Eindt", IsRequired = false, IsActive = true, SortOrder = 130 }, + new SapFieldMapping { SiteId = siteId, TargetField = "ScheduleQuantity", SourceExpression = "EKET.Menge", IsRequired = false, IsActive = true, SortOrder = 140 }); + } + + private static void Replace(AppDbContext db, IQueryable oldRows, IEnumerable newRows) + where TEntity : class + { + var set = db.Set(); + set.RemoveRange(oldRows); + set.AddRange(newRows); + } + + private static Site Clone(Site site) => new() + { + Id = site.Id, + HanaServerId = site.HanaServerId, + Schema = site.Schema, + TSC = site.TSC, + Land = site.Land, + SourceSystem = site.SourceSystem, + UsernameOverride = site.UsernameOverride, + PasswordOverride = site.PasswordOverride, + LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, + SapServiceUrl = site.SapServiceUrl, + SapEntitySet = site.SapEntitySet, + SapEntitySetsCache = site.SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, + IsActive = site.IsActive + }; +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs index 8554f44..1e342ac 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/DatabaseInitializationServiceTests.cs @@ -102,6 +102,14 @@ public class DatabaseInitializationServiceTests : IDisposable x.TargetField == nameof(SalesRecord.DocumentType) && x.SourceHeader == "=Alphaplan Excel"); Assert.Equal(2, db.FieldTransformationRules.Count(x => x.SourceSystem == "MANUAL_EXCEL")); + + var purchasing = Assert.Single(db.Sites, x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc); + Assert.Equal("SAP", purchasing.SourceSystem); + Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKKO" && x.EntitySet == "EKKOSet" && x.IsPrimary); + Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKPO" && x.EntitySet == "EKPOSet"); + Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKET" && x.EntitySet == "eketSet"); + Assert.Contains(db.SapJoinDefinitions, x => x.SiteId == purchasing.Id && x.LeftAlias == "EKKO" && x.RightAlias == "EKPO"); + Assert.Contains(db.SapFieldMappings, x => x.SiteId == purchasing.Id && x.TargetField == "NetValueChf" && x.SourceExpression == "EKPO.NetwrChf"); } private async Task PrepareLegacySitesTableAsync() diff --git a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md index 5a0bbcb..a93e14f 100644 --- a/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md +++ b/TrafagSalesExporter/docs/PURCHASING_DASHBOARD_2026-06-05.md @@ -46,9 +46,42 @@ Das Dashboard wurde fachlich um diese Bereiche erweitert: - `Kontrakte` - `Lieferanten` - `PBIX Vorlage` + - `3D Simulation` +- 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. - Die Kennzahlen sind noch nicht live an SAP gebunden. +## SAP/OData-Konfiguration + +Vorbefuellte Quellen: + +- `EKKO -> EKKOSet` +- `EKPO -> EKPOSet` +- `EKET -> eketSet` +- `LIEF -> Data` +- `WG -> Data2` + +Vorbefuellte Joins: + +- `EKKO.Ebeln = EKPO.Ebeln` +- `EKPO.Ebeln,Ebelp = EKET.Ebeln,Ebelp` +- `EKKO.Lifnr = LIEF.Lifnr` +- `EKPO.Matkl = WG.Matkl` + +Die Seite verwendet dieselben Grundtabellen wie die Finance-/Standorte-Quellenpflege: `Sites`, `SapSourceDefinitions`, `SapJoinDefinitions`, `SapFieldMappings`. + +## 3D Simulation + +Das Einkaufsdashboard hat eine eigene 3D-Simulation fuer wichtige Einkaufsindikatoren: + +- Spend CHF. +- Offener Bestellwert. +- Offene Menge. +- Kontrakt-Restwert. +- Lieferantenperformance. + +Die Simulation nutzt feste Canvas-Groessen, sichtbare Achsen, waehlbare Diagrammarten, Labelgroesse und einen Szenario-Slider fuer Preis-/Wechselkurswirkung. + ## Naechster Schritt fuer Live-Daten Fuer echte Werte muessen die Einkaufsquellen sauber gemappt werden: diff --git a/TrafagSalesExporter/docs/rag/PROJECT.md b/TrafagSalesExporter/docs/rag/PROJECT.md index 99032aa..62bbea2 100644 --- a/TrafagSalesExporter/docs/rag/PROJECT.md +++ b/TrafagSalesExporter/docs/rag/PROJECT.md @@ -11,7 +11,8 @@ Stand: 2026-06-05 - Neu im Finance/Management-Cockpit: einfache Schnelluebersicht links sichtbar; tiefere Funktionen bleiben unter `Experten`. - 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; `/einkauf` enthaelt jetzt Struktur fuer Spend, offene Bestellungen, Mengenkontrakte, Lieferantenperformance und die PBIX-Reportseiten. Live-SAP-Anbindung ist noch offen. +- Einkauf: `x.pbix` wurde als Vorlage analysiert; `/einkauf` enthaelt jetzt Struktur fuer Spend, offene Bestellungen, Mengenkontrakte, Lieferantenperformance, PBIX-Reportseiten und 3D-Simulation. +- Einkauf: `Einkauf > Datenquellen` pflegt die SAP/OData-Konfiguration grafisch und ist mit `EKKOSet`, `EKPOSet`, `eketSet`, `Data`, `Data2`, Joins und Zielmappings vorbefuellt. Realer Kennzahlenimport ist noch offen. - 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. diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index e0738f6..1277eb4 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -52,7 +52,9 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert. - Neu umgesetzt: Neuer Hauptpunkt `Einkauf` mit Einkaufswagen-Icon und vorbereiteter Einstiegseite `Einkauf Dashboard`. - Neu umgesetzt: `x.pbix` als Einkaufs-/SAP-Vorlage analysiert und `Einkauf Dashboard` auf Spend, offene Bestellungen, Kontrakte, Lieferantenperformance und PBIX-Vorlagenstruktur erweitert. - Wichtig Einkauf: Aktuell ist die Seite fachlich strukturiert, aber noch nicht live an SAP/OData angebunden; fuer Echtwerte muessen Einkaufsquellen wie `EKKOSet`, `EKPOSet`, ggf. Termin-/Kontrakt- und Lieferantenbewertungsdaten gemappt werden. -- Letzte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `83/83` Tests gruen. +- Neu umgesetzt: `Einkauf > Datenquellen` als grafische SAP/OData-Quellenpflege analog Finance/Standorte; vorbefuellt mit `EKKOSet`, `EKPOSet`, `eketSet`, Lieferanten- und Warengruppen-Mapping, Joins und Zielmappings. +- Neu umgesetzt: `Einkauf Dashboard > 3D Simulation` mit festen Canvas-Abmessungen, Achsenbeschriftung, Diagrammarten, Labelgroesse und Szenario-Slider fuer Preis-/Wechselkurswirkung. +- Letzte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `83/83` Tests gruen; Test prueft auch Einkaufs-SAP-Seed mit Quellen/Joins/Mappings. ## Nachtrag 2026-06-05 Einkauf / PBIX @@ -65,7 +67,10 @@ Quelle: Umgesetzt: - Einkaufsseite `/einkauf` von Platzhalter zu fachlichem Cockpit erweitert. -- Tabs: `Uebersicht`, `Spend`, `Offene Bestellungen`, `Kontrakte`, `Lieferanten`, `PBIX Vorlage`. +- Tabs: `Uebersicht`, `Spend`, `Offene Bestellungen`, `Kontrakte`, `Lieferanten`, `PBIX Vorlage`, `3D Simulation`. +- Neuer Unterpunkt `Einkauf > Datenquellen` fuer die grafische SAP/OData-Konfiguration. +- Standardquellen: `EKKO -> EKKOSet`, `EKPO -> EKPOSet`, `EKET -> eketSet`, `LIEF -> Data`, `WG -> Data2`. +- Standardjoins: `EKKO.Ebeln = EKPO.Ebeln`, `EKPO.Ebeln,Ebelp = EKET.Ebeln,Ebelp`, `EKKO.Lifnr = LIEF.Lifnr`, `EKPO.Matkl = WG.Matkl`. - Zusaetzlich zu den PBIX-Sichten wurden die vom Einkauf genannten SAP-Themen aufgenommen: - Spend total vergangen nach Jahr, Lieferant, Warengruppe, Artikel. - Offene Bestellwerte und Mengen nach Lieferant, Warengruppe, Artikel.