Add purchasing data sources and 3D simulation

This commit is contained in:
2026-06-05 07:45:30 +02:00
parent 9b287c15ef
commit bb5e5150b9
10 changed files with 865 additions and 3 deletions
@@ -1,6 +1,8 @@
@page "/einkauf" @page "/einkauf"
@using System.Globalization
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
@inject IJSRuntime JsRuntime
<PageTitle>@T("Einkauf", "Purchasing")</PageTitle> <PageTitle>@T("Einkauf", "Purchasing")</PageTitle>
@@ -119,9 +121,76 @@
</MudTable> </MudTable>
</MudPaper> </MudPaper>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@T("3D Simulation", "3D simulation")" Icon="@Icons.Material.Filled.ViewInAr">
<MudPaper Class="pa-3 mb-3" Outlined="true">
<MudGrid Spacing="2">
<MudItem xs="12" md="3">
<MudSelect T="string" Value="_purchasing3dIndicator" ValueChanged="SetPurchasing3dIndicator" Label="@T("Indikator", "Indicator")" Dense="true">
@foreach (var option in Purchasing3dIndicators)
{
<MudSelectItem Value="@option.Key">@T(option.TitleDe, option.TitleEn)</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" Value="_purchasing3dChartType" ValueChanged="SetPurchasing3dChartType" Label="@T("Grafik", "Chart")" Dense="true">
<MudSelectItem Value="@("bar")">@T("Balken", "Bars")</MudSelectItem>
<MudSelectItem Value="@("line")">@T("Linie", "Line")</MudSelectItem>
<MudSelectItem Value="@("surface")">@T("Flaeche", "Surface")</MudSelectItem>
<MudSelectItem Value="@("pie")">@T("Kreis", "Pie")</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudText Typo="Typo.caption">@T("Preis-/Wechselkurs-Szenario", "Price/exchange-rate scenario")</MudText>
<div class="finance-3d-range-row">
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => SetPurchasing3dFactorPreset(0.9d))">-10%</MudButton>
<input class="finance-3d-range"
type="range"
min="0.5"
max="1.5"
step="0.01"
value="@_purchasing3dFactor.ToString("0.00", CultureInfo.InvariantCulture)"
@oninput="SetPurchasing3dFactor" />
<MudText Typo="Typo.body2" Class="finance-3d-factor">@_purchasing3dFactor.ToString("0.00", CultureInfo.InvariantCulture)x</MudText>
</div>
<MudText Typo="Typo.caption">
@T("Delta", "Delta"): @FormatScenarioDelta()
</MudText>
</MudItem>
<MudItem xs="12" md="2">
<MudText Typo="Typo.caption">@T("Beschriftung", "Labels")</MudText>
<div class="finance-3d-range-row">
<input class="finance-3d-range"
type="range"
min="0.8"
max="2.5"
step="0.1"
value="@_purchasing3dLabelScale.ToString("0.0", CultureInfo.InvariantCulture)"
@oninput="SetPurchasing3dLabelScale" />
<MudText Typo="Typo.body2" Class="finance-3d-factor">@_purchasing3dLabelScale.ToString("0.0", CultureInfo.InvariantCulture)x</MudText>
</div>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Refresh" OnClick="RenderPurchasing3dAsync">
@T("Neu zeichnen", "Redraw")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="pa-0 finance-3d-surface purchasing-3d-surface" Elevation="1">
<canvas @ref="_purchasing3dCanvas" class="finance-3d-canvas" style="display:block;width:100%;height:100%;touch-action:none;"></canvas>
</MudPaper>
</MudTabPanel>
</MudTabs> </MudTabs>
@code { @code {
private ElementReference _purchasing3dCanvas;
private string _purchasing3dIndicator = "spend";
private string _purchasing3dChartType = "bar";
private double _purchasing3dFactor = 1d;
private double _purchasing3dLabelScale = 1.5d;
private readonly List<PurchasingKpiCard> KpiCards = private readonly List<PurchasingKpiCard> KpiCards =
[ [
new("Spend total", "Total spend", "-", "Netwr CHF historisch", "Historic Netwr CHF", Icons.Material.Filled.Payments, Color.Primary), 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") new("Matrix Vol./WG", "Pivot, Slicer", "Sum(EKPOSet.Netwr CHF)", "Warengruppe, Lieferant, Artikel")
]; ];
private readonly List<Purchasing3dIndicator> 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<Purchasing3dBaseRow> 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 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<object> BuildPurchasing3dRows()
=> Purchasing3dBaseRows
.Select(row => new
{
country = row.Axis,
year = row.Year,
value = ResolvePurchasing3dValue(row)
})
.Cast<object>()
.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 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 PurchasingAxis(string LabelDe, string LabelEn, string Field, string UsageDe, string UsageEn);
private sealed record PurchasingSource(string Name, string Description); private sealed record PurchasingSource(string Name, string Description);
private sealed record PowerBiPageInfo(string Page, string Visuals, string Measure, string Dimensions); 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);
} }
<style> <style>
@@ -218,4 +409,10 @@
.purchasing-source-row:last-child { .purchasing-source-row:last-child {
border-bottom: 0; border-bottom: 0;
} }
.purchasing-3d-surface {
height: calc(100vh - 300px);
min-height: 620px;
overflow: hidden;
}
</style> </style>
@@ -0,0 +1,272 @@
@page "/einkauf/verbindungen"
@using TrafagSalesExporter.Services
@inject IPurchasingDataSourcePageService DataSourceService
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Einkauf Datenquellen", "Purchasing data sources")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-1">@T("Einkauf Datenquellen", "Purchasing data sources")</MudText>
<MudText Typo="Typo.body2" Class="mb-4 purchasing-muted">
@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.")
</MudText>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" md="7">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-3">@T("Verbindung", "Connection")</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.TSC" Label="TSC" ReadOnly="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.SourceSystem" Label="@T("Quellsystem", "Source system")" ReadOnly="true" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="_state.Site.SapServiceUrl"
Label="SAP Service URL Override"
HelperText="@T("Leer lassen = zentrale SAP OData URL aus Settings verwenden.", "Leave empty = use central SAP OData URL from settings.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.UsernameOverride"
Label="Username Override"
HelperText="@T("Leer lassen = zentraler SAP User.", "Leave empty = central SAP user.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.PasswordOverride"
Label="Password Override"
InputType="InputType.Password"
HelperText="@T("Leer lassen = zentrales SAP Passwort.", "Leave empty = central SAP password.")" />
</MudItem>
<MudItem xs="12">
<MudCheckBox @bind-Value="_state.Site.IsActive"
Label="@T("Einkaufsquelle fuer Import aktivieren", "Activate purchasing source for import")" />
</MudItem>
</MudGrid>
<MudStack Row="true" Spacing="2" Class="mt-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_busy">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestConnectionAsync" Disabled="_busy">
@T("Verbindung testen", "Test connection")
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetDefaultsAsync" Disabled="_busy">
@T("Defaults wiederherstellen", "Restore defaults")
</MudButton>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-2">@T("Aktuelle Basis", "Current basis")</MudText>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.CloudQueue" Size="Size.Small" />
<span>@T("Zentrale URL", "Central URL"): <code>@Display(_state.SourceSystem?.CentralServiceUrl)</code></span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.TableChart" Size="Size.Small" />
<span>@T("Quellen", "Sources"): @_state.Sources.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.AccountTree" Size="Size.Small" />
<span>@T("Joins", "Joins"): @_state.Joins.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.Schema" Size="Size.Small" />
<span>@T("Mappings", "Mappings"): @_state.Mappings.Count(x => x.IsActive)</span>
</div>
</MudPaper>
</MudItem>
</MudGrid>
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Quellen", "Sources")" Icon="@Icons.Material.Filled.Hub">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("OData Entity Sets", "OData entity sets")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSource">@T("Quelle", "Source")</MudButton>
</MudStack>
<MudTable Items="_state.Sources" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>@T("Primaer", "Primary")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.Alias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.EntitySet" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSource(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Join-Fluss", "Join flow")" Icon="@Icons.Material.Filled.AccountTree">
<MudGrid Spacing="2" Class="mb-3">
@foreach (var join in _state.Joins.Where(x => x.IsActive))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 purchasing-flow" Outlined="true">
<MudText Typo="Typo.subtitle2">@join.LeftAlias -> @join.RightAlias</MudText>
<MudText Typo="Typo.caption">@join.LeftKeys = @join.RightKeys</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Joins", "Joins")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddJoin">@T("Join", "Join")</MudButton>
</MudStack>
<MudTable Items="_state.Joins" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Links", "Left")</MudTh>
<MudTh>Left Keys</MudTh>
<MudTh>@T("Rechts", "Right")</MudTh>
<MudTh>Right Keys</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.LeftAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.JoinType" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveJoin(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Mapping", "Mapping")" Icon="@Icons.Material.Filled.Schema">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Zielfelder", "Target fields")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddMapping">@T("Mapping", "Mapping")</MudButton>
</MudStack>
<MudTable Items="_state.Mappings" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Ziel", "Target")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Pflicht", "Required")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.TargetField" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
</MudTabs>
}
@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<Task> 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;
}
<style>
.purchasing-muted {
color: var(--mud-palette-text-secondary);
}
.purchasing-flow {
min-height: 86px;
}
.purchasing-source-row {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.purchasing-source-row:last-child {
border-bottom: 0;
}
</style>
+1
View File
@@ -114,6 +114,7 @@ builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>(); 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<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>();
@@ -17,6 +17,7 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureGermanyManualExcelSite(db); EnsureGermanyManualExcelSite(db);
EnsureUkManualExcelFolder(db); EnsureUkManualExcelFolder(db);
EnsureSapODataDachSite(db); EnsureSapODataDachSite(db);
EnsurePurchasingSapSite(db);
EnsureFinanceReferenceDefaults(db); EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db); EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(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), Link("hr-training", "hr", "HR KPI Schulung", "HR KPI training", "School", "hr-kpi/schulung", 20),
Group("purchasing", null, "Einkauf", "Purchasing", "ShoppingCart", 30), Group("purchasing", null, "Einkauf", "Purchasing", "ShoppingCart", 30),
Link("purchasing-dashboard", "purchasing", "Einkauf Dashboard", "Purchasing dashboard", "Dashboard", "einkauf", 10, "All"), 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) Link("admin-sessions", null, "Admin Bereich", "Admin area", "PeopleAlt", "admin/sessions", 90)
]; ];
@@ -968,6 +970,89 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges(); 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) private static void EnsureFinanceReferenceDefaults(AppDbContext db)
{ {
var defaults = new[] var defaults = new[]
@@ -0,0 +1,20 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IPurchasingDataSourcePageService
{
Task<PurchasingDataSourceState> LoadAsync();
Task<PurchasingDataSourceState> SaveAsync(PurchasingDataSourceState state);
Task<PurchasingDataSourceState> ResetDefaultsAsync();
Task<PageActionResult> TestConnectionAsync(PurchasingDataSourceState state);
}
public sealed class PurchasingDataSourceState
{
public Site Site { get; set; } = new();
public SourceSystemDefinition? SourceSystem { get; set; }
public List<SapSourceDefinition> Sources { get; set; } = [];
public List<SapJoinDefinition> Joins { get; set; } = [];
public List<SapFieldMapping> Mappings { get; set; } = [];
}
@@ -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<AppDbContext> _dbFactory;
private readonly ISapGatewayService _sapGatewayService;
public PurchasingDataSourcePageService(IDbContextFactory<AppDbContext> dbFactory, ISapGatewayService sapGatewayService)
{
_dbFactory = dbFactory;
_sapGatewayService = sapGatewayService;
}
public async Task<PurchasingDataSourceState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
await EnsureDefaultsAsync(db);
return await LoadStateAsync(db);
}
public async Task<PurchasingDataSourceState> 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<PurchasingDataSourceState> 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<PageActionResult> 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<PurchasingDataSourceState> 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<Site> 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<TEntity>(AppDbContext db, IQueryable<TEntity> oldRows, IEnumerable<TEntity> newRows)
where TEntity : class
{
var set = db.Set<TEntity>();
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
};
}
@@ -102,6 +102,14 @@ public class DatabaseInitializationServiceTests : IDisposable
x.TargetField == nameof(SalesRecord.DocumentType) && x.TargetField == nameof(SalesRecord.DocumentType) &&
x.SourceHeader == "=Alphaplan Excel"); x.SourceHeader == "=Alphaplan Excel");
Assert.Equal(2, db.FieldTransformationRules.Count(x => x.SourceSystem == "MANUAL_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() private async Task PrepareLegacySitesTableAsync()
@@ -46,9 +46,42 @@ Das Dashboard wurde fachlich um diese Bereiche erweitert:
- `Kontrakte` - `Kontrakte`
- `Lieferanten` - `Lieferanten`
- `PBIX Vorlage` - `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 Seite ist als Cockpit-Struktur umgesetzt und zweisprachig ueber den vorhandenen UI-Sprachservice vorbereitet.
- Die Kennzahlen sind noch nicht live an SAP gebunden. - 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 ## Naechster Schritt fuer Live-Daten
Fuer echte Werte muessen die Einkaufsquellen sauber gemappt werden: Fuer echte Werte muessen die Einkaufsquellen sauber gemappt werden:
+2 -1
View File
@@ -11,7 +11,8 @@ Stand: 2026-06-05
- Neu im Finance/Management-Cockpit: einfache Schnelluebersicht links sichtbar; tiefere Funktionen bleiben unter `Experten`. - 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 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`. - 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. - 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: `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. - Spanien: Default-Range ist heute minus 7 Tage bis heute; `ToDate` ist exklusiv.
+7 -2
View File
@@ -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: 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. - 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. - 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 ## Nachtrag 2026-06-05 Einkauf / PBIX
@@ -65,7 +67,10 @@ Quelle:
Umgesetzt: Umgesetzt:
- Einkaufsseite `/einkauf` von Platzhalter zu fachlichem Cockpit erweitert. - 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: - Zusaetzlich zu den PBIX-Sichten wurden die vom Einkauf genannten SAP-Themen aufgenommen:
- Spend total vergangen nach Jahr, Lieferant, Warengruppe, Artikel. - Spend total vergangen nach Jahr, Lieferant, Warengruppe, Artikel.
- Offene Bestellwerte und Mengen nach Lieferant, Warengruppe, Artikel. - Offene Bestellwerte und Mengen nach Lieferant, Warengruppe, Artikel.