import exxport settings, join over sap hana tables

This commit is contained in:
2026-04-14 11:34:43 +02:00
parent 36a22202bf
commit 59e195af71
21 changed files with 1369 additions and 16 deletions
+1
View File
@@ -14,5 +14,6 @@
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" /> <Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/download.js"></script>
</body> </body>
</html> </html>
@@ -116,7 +116,9 @@
Land = s.Land, Land = s.Land,
TSC = s.TSC, TSC = s.TSC,
Schema = s.Schema, Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "", ServerName = string.Equals(s.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase)
? (string.IsNullOrWhiteSpace(s.SapServiceUrl) ? "SAP Gateway" : s.SapServiceUrl)
: s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "", LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0, RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp, LastRun = log?.Timestamp,
@@ -8,12 +8,39 @@
@inject TimerBackgroundService TimerService @inject TimerBackgroundService TimerService
@inject IHanaQueryService HanaService @inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService @inject ISapGatewayService SapGatewayService
@inject IConfigTransferService ConfigTransferService
@inject IJSRuntime JS
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle> <PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText> <MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
<MudText Typo="Typo.h5" Class="mb-2">Konfiguration Import/Export</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="Mit Secrets exportieren" />
<MudText Typo="Typo.caption">
Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportConfiguration"
StartIcon="@Icons.Material.Filled.Download" Disabled="_exportingConfig">
@(_exportingConfig ? "Exportiere..." : "Konfiguration exportieren")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
@(_importingConfig ? "Importiere..." : "Konfiguration importieren")
<InputFile OnChange="ImportConfiguration" accept=".json,application/json" style="display:none" />
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* SharePoint Config *@ @* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText> <MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1"> <MudPaper Class="pa-4 mb-6" Elevation="1">
@@ -166,6 +193,9 @@
private SharePointConfig _spConfig = new(); private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new(); private ExportSettings _exportSettings = new();
private bool _testingSp; private bool _testingSp;
private bool _includeSecretsInExport;
private bool _exportingConfig;
private bool _importingConfig;
private readonly HashSet<string> _testingSystems = []; private readonly HashSet<string> _testingSystems = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -240,6 +270,60 @@
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success); Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
} }
private async Task ExportConfiguration()
{
if (_exportingConfig)
return;
_exportingConfig = true;
try
{
var json = await ConfigTransferService.ExportJsonAsync(_includeSecretsInExport);
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
Snackbar.Add("Konfiguration exportiert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Export fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_exportingConfig = false;
}
}
private async Task ImportConfiguration(InputFileChangeEventArgs args)
{
if (_importingConfig)
return;
_importingConfig = true;
try
{
var file = args.File;
await using var stream = file.OpenReadStream(5 * 1024 * 1024);
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
await ConfigTransferService.ImportJsonAsync(json);
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
TimerService.Recalculate();
Snackbar.Add("Konfiguration importiert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Import fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_importingConfig = false;
}
}
private async Task TestCentralCredentials(string sourceSystem) private async Task TestCentralCredentials(string sourceSystem)
{ {
if (sourceSystem == "SAP") if (sourceSystem == "SAP")
@@ -1,6 +1,7 @@
@page "/standorte" @page "/standorte"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using System.Text.Json @using System.Text.Json
@using System.Reflection
@using TrafagSalesExporter.Data @using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@@ -180,12 +181,110 @@
</MudText> </MudText>
} }
</MudStack> </MudStack>
<MudSelect @bind-Value="_editingSite.SapEntitySet" Label="SAP Entity Set" Required> <MudDivider Class="my-4" />
@foreach (var entitySet in _sapEntitySetsCache) <MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
{ <MudText Typo="Typo.h6">SAP Quellen</MudText>
<MudSelectItem Value="@entitySet">@entitySet</MudSelectItem> <MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton>
} </MudStack>
</MudSelect> <MudText Typo="Typo.caption" Class="mb-2">
Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`.
</MudText>
<MudTable Items="_sapSources" Dense Hover Striped>
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>Primär</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.Alias" Dense /></MudTd>
<MudTd>
<MudSelect @bind-Value="context.EntitySet" Dense>
@foreach (var entitySet in _sapEntitySetsCache)
{
<MudSelectItem Value="@entitySet">@entitySet</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapSource(context)" /></MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Joins</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
</MudStack>
<MudTable Items="_sapJoins" Dense Hover Striped>
<HeaderContent>
<MudTh>Links</MudTh>
<MudTh>Left Keys</MudTh>
<MudTh>Rechts</MudTh>
<MudTh>Right Keys</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.LeftAlias" Dense>
@foreach (var alias in GetSapAliases())
{
<MudSelectItem Value="@alias">@alias</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense Placeholder="VBELN,POSNR" /></MudTd>
<MudTd>
<MudSelect @bind-Value="context.RightAlias" Dense>
@foreach (var alias in GetSapAliases())
{
<MudSelectItem Value="@alias">@alias</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense Placeholder="VBELN,POSNR" /></MudTd>
<MudTd>
<MudSelect @bind-Value="context.JoinType" Dense>
<MudSelectItem Value="@("Left")">Left</MudSelectItem>
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapJoin(context)" /></MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
</MudStack>
<MudTable Items="_sapMappings" Dense Hover Striped>
<HeaderContent>
<MudTh>Zielfeld</MudTh>
<MudTh>Source Expression</MudTh>
<MudTh>Pflicht</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.TargetField" Dense>
@foreach (var field in _salesRecordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense Placeholder="VBAK.VBELN oder =SAP" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
} }
else else
{ {
@@ -222,6 +321,13 @@
private List<HanaServer> _servers = new(); private List<HanaServer> _servers = new();
private List<Site> _sites = new(); private List<Site> _sites = new();
private List<string> _sapEntitySetsCache = []; private List<string> _sapEntitySetsCache = [];
private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _sapMappings = [];
private readonly string[] _salesRecordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.ToArray();
private HanaServer _editingServer = new(); private HanaServer _editingServer = new();
private Site _editingSite = new(); private Site _editingSite = new();
private HanaServer _editingSiteServer = new(); private HanaServer _editingSiteServer = new();
@@ -348,9 +454,12 @@
{ {
IsActive = true, IsActive = true,
SourceSystem = "SAP", SourceSystem = "SAP",
HanaServerId = 0 HanaServerId = null
}; };
_sapEntitySetsCache = []; _sapEntitySetsCache = [];
_sapSources = [];
_sapJoins = [];
_sapMappings = [];
_editingSiteServer = CreateDefaultSiteServer(); _editingSiteServer = CreateDefaultSiteServer();
_siteDialogVisible = true; _siteDialogVisible = true;
} }
@@ -374,6 +483,10 @@
IsActive = site.IsActive IsActive = site.IsActive
}; };
_sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache); _sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
using var db = DbFactory.CreateDbContext();
_sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList();
_sapJoins = db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToList();
_sapMappings = db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToList();
_editingSiteServer = site.HanaServer is null _editingSiteServer = site.HanaServer is null
? CreateDefaultSiteServer(site) ? CreateDefaultSiteServer(site)
: CloneServer(site.HanaServer); : CloneServer(site.HanaServer);
@@ -418,6 +531,7 @@
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await SaveSapConfigurationAsync(db, _editingSite.Id);
_siteDialogVisible = false; _siteDialogVisible = false;
await LoadDataAsync(); await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success); Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -445,6 +559,14 @@
var entity = await db.Sites.FindAsync(site.Id); var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null) if (entity is not null)
{ {
var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources);
if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins);
if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings);
if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows);
db.Sites.Remove(entity); db.Sites.Remove(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -632,4 +754,97 @@
private static string SerializeSapEntitySets(List<string> entitySets) private static string SerializeSapEntitySets(List<string> entitySets)
=> JsonSerializer.Serialize(entitySets); => JsonSerializer.Serialize(entitySets);
private void AddSapSource()
{
_sapSources.Add(new SapSourceDefinition
{
Alias = $"SRC{_sapSources.Count + 1}",
EntitySet = _sapEntitySetsCache.FirstOrDefault() ?? string.Empty,
IsActive = true,
IsPrimary = _sapSources.Count == 0,
SortOrder = _sapSources.Count
});
}
private void RemoveSapSource(SapSourceDefinition source)
{
_sapSources.Remove(source);
}
private void AddSapJoin()
{
_sapJoins.Add(new SapJoinDefinition
{
JoinType = "Left",
IsActive = true,
SortOrder = _sapJoins.Count
});
}
private void RemoveSapJoin(SapJoinDefinition join)
{
_sapJoins.Remove(join);
}
private void AddSapMapping()
{
_sapMappings.Add(new SapFieldMapping
{
TargetField = _salesRecordFields.First(),
IsActive = true,
SortOrder = _sapMappings.Count
});
}
private void RemoveSapMapping(SapFieldMapping mapping)
{
_sapMappings.Remove(mapping);
}
private IEnumerable<string> GetSapAliases()
=> _sapSources.Where(s => !string.IsNullOrWhiteSpace(s.Alias)).Select(s => s.Alias).Distinct(StringComparer.OrdinalIgnoreCase);
private async Task SaveSapConfigurationAsync(AppDbContext db, int siteId)
{
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync();
var oldMappings = await db.SapFieldMappings.Where(m => m.SiteId == siteId).ToListAsync();
if (oldSources.Count > 0) db.SapSourceDefinitions.RemoveRange(oldSources);
if (oldJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(oldJoins);
if (oldMappings.Count > 0) db.SapFieldMappings.RemoveRange(oldMappings);
if (IsSapSite())
{
NormalizeSapConfigCollections();
foreach (var source in _sapSources)
source.SiteId = siteId;
foreach (var join in _sapJoins)
join.SiteId = siteId;
foreach (var mapping in _sapMappings)
mapping.SiteId = siteId;
db.SapSourceDefinitions.AddRange(_sapSources);
db.SapJoinDefinitions.AddRange(_sapJoins);
db.SapFieldMappings.AddRange(_sapMappings);
}
await db.SaveChangesAsync();
}
private void NormalizeSapConfigCollections()
{
for (var i = 0; i < _sapSources.Count; i++)
_sapSources[i].SortOrder = i;
for (var i = 0; i < _sapJoins.Count; i++)
_sapJoins[i].SortOrder = i;
for (var i = 0; i < _sapMappings.Count; i++)
_sapMappings[i].SortOrder = i;
var selectedPrimaryIndex = _sapSources.FindIndex(s => s.IsPrimary);
var primarySource = selectedPrimaryIndex >= 0 ? _sapSources[selectedPrimaryIndex] : _sapSources.FirstOrDefault();
foreach (var source in _sapSources)
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
if (_sapSources.Count > 0 && _sapSources.All(s => !s.IsPrimary))
_sapSources[0].IsPrimary = true;
}
} }
+4
View File
@@ -13,4 +13,8 @@ public class AppDbContext : DbContext
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>(); public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>(); public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>(); public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
} }
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class CentralSalesRecord
{
public int Id { get; set; }
public DateTime StoredAtUtc { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string SourceSystem { get; set; } = string.Empty;
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,102 @@
namespace TrafagSalesExporter.Models;
public class ConfigTransferPackage
{
public int Version { get; set; } = 1;
public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow;
public bool IncludesSecrets { get; set; }
public ConfigTransferSharePoint? SharePointConfig { get; set; }
public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
}
public class ConfigTransferSharePoint
{
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
}
public class ConfigTransferExportSettings
{
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
public string? SapUsername { get; set; }
public string? SapPassword { get; set; }
public string? Bi1Username { get; set; }
public string? Bi1Password { get; set; }
public string? SageUsername { get; set; }
public string? SagePassword { get; set; }
}
public class ConfigTransferHanaServer
{
public string Key { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string? Username { get; set; }
public string? Password { get; set; }
public string DatabaseName { get; set; } = string.Empty;
public bool UseSsl { get; set; }
public bool ValidateCertificate { get; set; }
public string AdditionalParams { get; set; } = string.Empty;
}
public class ConfigTransferSite
{
public string Key { get; set; } = Guid.NewGuid().ToString("N");
public string? HanaServerKey { get; set; }
public string Schema { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Land { get; set; } = string.Empty;
public string SourceSystem { get; set; } = "SAP";
public string? UsernameOverride { get; set; }
public string? PasswordOverride { get; set; }
public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty;
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
public bool IsActive { get; set; } = true;
}
public class ConfigTransferSapSourceDefinition
{
public string SiteKey { get; set; } = string.Empty;
public string Alias { get; set; } = string.Empty;
public string EntitySet { get; set; } = string.Empty;
public bool IsPrimary { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
public class ConfigTransferSapJoinDefinition
{
public string SiteKey { get; set; } = string.Empty;
public string LeftAlias { get; set; } = string.Empty;
public string RightAlias { get; set; } = string.Empty;
public string LeftKeys { get; set; } = string.Empty;
public string RightKeys { get; set; } = string.Empty;
public string JoinType { get; set; } = "Left";
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
public class ConfigTransferSapFieldMapping
{
public string SiteKey { get; set; } = string.Empty;
public string TargetField { get; set; } = string.Empty;
public string SourceExpression { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapFieldMapping
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string SourceExpression { get; set; } = string.Empty;
public bool IsRequired { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapJoinDefinition
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string LeftAlias { get; set; } = string.Empty;
[Required]
public string RightAlias { get; set; } = string.Empty;
[Required]
public string LeftKeys { get; set; } = string.Empty;
[Required]
public string RightKeys { get; set; } = string.Empty;
public string JoinType { get; set; } = "Left";
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class SapSourceDefinition
{
public int Id { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
[Required]
public string Alias { get; set; } = string.Empty;
[Required]
public string EntitySet { get; set; } = string.Empty;
public bool IsPrimary { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
+3
View File
@@ -17,6 +17,7 @@ builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>(); builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>(); builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>(); builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
@@ -28,6 +29,8 @@ builder.Services.AddSingleton<IRecordTransformationService, RecordTransformation
builder.Services.AddSingleton<ISiteExportService, SiteExportService>(); builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>(); builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>(); builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>(); builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<ExportOrchestrationService>(); builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>(); builder.Services.AddSingleton<TimerBackgroundService>();
@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class CentralSalesRecordService : ICentralSalesRecordService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records)
{
using var db = await _dbFactory.CreateDbContextAsync();
var existing = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (existing.Count > 0)
db.CentralSalesRecords.RemoveRange(existing);
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
db.CentralSalesRecords.AddRange(records.Select(record => new CentralSalesRecord
{
StoredAtUtc = DateTime.UtcNow,
SiteId = site.Id,
SourceSystem = sourceSystem,
ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc,
InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material,
Name = record.Name,
ProductGroup = record.ProductGroup,
Quantity = record.Quantity,
SupplierNumber = record.SupplierNumber,
SupplierName = record.SupplierName,
SupplierCountry = record.SupplierCountry,
CustomerNumber = record.CustomerNumber,
CustomerName = record.CustomerName,
CustomerCountry = record.CustomerCountry,
CustomerIndustry = record.CustomerIndustry,
StandardCost = record.StandardCost,
StandardCostCurrency = record.StandardCostCurrency,
PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency,
Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate,
Land = record.Land,
DocumentType = record.DocumentType
}));
await db.SaveChangesAsync();
}
public async Task<List<SalesRecord>> GetAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
return await db.CentralSalesRecords
.OrderBy(r => r.Land)
.ThenBy(r => r.Tsc)
.Select(r => new SalesRecord
{
ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
ProductGroup = r.ProductGroup,
Quantity = r.Quantity,
SupplierNumber = r.SupplierNumber,
SupplierName = r.SupplierName,
SupplierCountry = r.SupplierCountry,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
CustomerCountry = r.CustomerCountry,
CustomerIndustry = r.CustomerIndustry,
StandardCost = r.StandardCost,
StandardCostCurrency = r.StandardCostCurrency,
PurchaseOrderNumber = r.PurchaseOrderNumber,
SalesPriceValue = r.SalesPriceValue,
SalesCurrency = r.SalesCurrency,
Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate,
Land = r.Land,
DocumentType = r.DocumentType
})
.ToListAsync();
}
}
@@ -0,0 +1,317 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ConfigTransferService : IConfigTransferService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public ConfigTransferService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<string> ExportJsonAsync(bool includeSecrets)
{
using var db = await _dbFactory.CreateDbContextAsync();
var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportSettings = await db.ExportSettings.FirstOrDefaultAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var package = new ConfigTransferPackage
{
IncludesSecrets = includeSecrets,
SharePointConfig = sharePoint is null ? null : new ConfigTransferSharePoint
{
SiteUrl = sharePoint.SiteUrl,
ExportFolder = sharePoint.ExportFolder,
TenantId = sharePoint.TenantId,
ClientId = sharePoint.ClientId,
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
},
ExportSettings = exportSettings is null ? null : new ConfigTransferExportSettings
{
DateFilter = exportSettings.DateFilter,
TimerHour = exportSettings.TimerHour,
TimerMinute = exportSettings.TimerMinute,
TimerEnabled = exportSettings.TimerEnabled,
SapUsername = includeSecrets ? exportSettings.SapUsername : null,
SapPassword = includeSecrets ? exportSettings.SapPassword : null,
Bi1Username = includeSecrets ? exportSettings.Bi1Username : null,
Bi1Password = includeSecrets ? exportSettings.Bi1Password : null,
SageUsername = includeSecrets ? exportSettings.SageUsername : null,
SagePassword = includeSecrets ? exportSettings.SagePassword : null
},
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{
Key = serverKeyMap[server.Id],
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = includeSecrets ? server.Username : null,
Password = includeSecrets ? server.Password : null,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
}).ToList(),
Sites = sites.Select(site => new ConfigTransferSite
{
Key = siteKeyMap[site.Id],
HanaServerKey = site.HanaServerId.HasValue && serverKeyMap.TryGetValue(site.HanaServerId.Value, out var serverKey) ? serverKey : null,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = site.SourceSystem,
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
}).ToList(),
FieldTransformationRules = rules.Select(r => new FieldTransformationRule
{
SourceSystem = r.SourceSystem,
SourceField = r.SourceField,
TargetField = r.TargetField,
TransformationType = r.TransformationType,
Argument = r.Argument,
SortOrder = r.SortOrder,
IsActive = r.IsActive
}).ToList(),
SapSourceDefinitions = sapSources.Select(s => new ConfigTransferSapSourceDefinition
{
SiteKey = siteKeyMap[s.SiteId],
Alias = s.Alias,
EntitySet = s.EntitySet,
IsPrimary = s.IsPrimary,
IsActive = s.IsActive,
SortOrder = s.SortOrder
}).ToList(),
SapJoinDefinitions = sapJoins.Select(j => new ConfigTransferSapJoinDefinition
{
SiteKey = siteKeyMap[j.SiteId],
LeftAlias = j.LeftAlias,
RightAlias = j.RightAlias,
LeftKeys = j.LeftKeys,
RightKeys = j.RightKeys,
JoinType = j.JoinType,
IsActive = j.IsActive,
SortOrder = j.SortOrder
}).ToList(),
SapFieldMappings = sapMappings.Select(m => new ConfigTransferSapFieldMapping
{
SiteKey = siteKeyMap[m.SiteId],
TargetField = m.TargetField,
SourceExpression = m.SourceExpression,
IsRequired = m.IsRequired,
IsActive = m.IsActive,
SortOrder = m.SortOrder
}).ToList()
};
return JsonSerializer.Serialize(package, JsonOptions);
}
public async Task ImportJsonAsync(string json)
{
var package = JsonSerializer.Deserialize<ConfigTransferPackage>(json, JsonOptions)
?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden.");
using var db = await _dbFactory.CreateDbContextAsync();
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
var existingServers = await db.HanaServers.ToListAsync();
var existingSites = await db.Sites.ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
var existingSapMappings = await db.SapFieldMappings.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.ToListAsync();
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
var preservedSecrets = existingSettings is null
? new ConfigTransferExportSettings()
: new ConfigTransferExportSettings
{
SapUsername = existingSettings.SapUsername,
SapPassword = existingSettings.SapPassword,
Bi1Username = existingSettings.Bi1Username,
Bi1Password = existingSettings.Bi1Password,
SageUsername = existingSettings.SageUsername,
SagePassword = existingSettings.SagePassword
};
var preservedServerSecrets = existingServers.ToDictionary(
x => BuildServerSignature(x.Name, x.Host, x.Port, x.DatabaseName),
x => (x.Username, x.Password));
var preservedSiteSecrets = existingSites.ToDictionary(
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem),
x => (x.UsernameOverride, x.PasswordOverride));
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
if (existingSharePoint is not null) db.SharePointConfigs.Remove(existingSharePoint);
if (existingSettings is not null) db.ExportSettings.Remove(existingSettings);
await db.SaveChangesAsync();
var newSharePoint = package.SharePointConfig is null ? new SharePointConfig() : new SharePointConfig
{
SiteUrl = package.SharePointConfig.SiteUrl,
ExportFolder = package.SharePointConfig.ExportFolder,
TenantId = package.SharePointConfig.TenantId,
ClientId = package.SharePointConfig.ClientId,
ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret
};
db.SharePointConfigs.Add(newSharePoint);
var importedSettings = package.ExportSettings ?? new ConfigTransferExportSettings();
db.ExportSettings.Add(new ExportSettings
{
DateFilter = importedSettings.DateFilter,
TimerHour = importedSettings.TimerHour,
TimerMinute = importedSettings.TimerMinute,
TimerEnabled = importedSettings.TimerEnabled,
SapUsername = package.IncludesSecrets ? importedSettings.SapUsername ?? string.Empty : preservedSecrets.SapUsername ?? string.Empty,
SapPassword = package.IncludesSecrets ? importedSettings.SapPassword ?? string.Empty : preservedSecrets.SapPassword ?? string.Empty,
Bi1Username = package.IncludesSecrets ? importedSettings.Bi1Username ?? string.Empty : preservedSecrets.Bi1Username ?? string.Empty,
Bi1Password = package.IncludesSecrets ? importedSettings.Bi1Password ?? string.Empty : preservedSecrets.Bi1Password ?? string.Empty,
SageUsername = package.IncludesSecrets ? importedSettings.SageUsername ?? string.Empty : preservedSecrets.SageUsername ?? string.Empty,
SagePassword = package.IncludesSecrets ? importedSettings.SagePassword ?? string.Empty : preservedSecrets.SagePassword ?? string.Empty
});
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
preservedServerSecrets.TryGetValue(BuildServerSignature(server.Name, server.Host, server.Port, server.DatabaseName), out var preserved);
var entity = new HanaServer
{
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = package.IncludesSecrets ? server.Username ?? string.Empty : preserved.Username ?? string.Empty,
Password = package.IncludesSecrets ? server.Password ?? string.Empty : preserved.Password ?? string.Empty,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
db.HanaServers.Add(entity);
await db.SaveChangesAsync();
serverIdMap[server.Key] = entity.Id;
}
var siteIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var site in package.Sites)
{
preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved);
var entity = new Site
{
HanaServerId = !string.IsNullOrWhiteSpace(site.HanaServerKey) && serverIdMap.TryGetValue(site.HanaServerKey, out var mappedServerId)
? mappedServerId
: null,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = site.SourceSystem,
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
db.Sites.Add(entity);
await db.SaveChangesAsync();
siteIdMap[site.Key] = entity.Id;
}
if (package.FieldTransformationRules.Count > 0)
{
db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule
{
SourceSystem = r.SourceSystem,
SourceField = r.SourceField,
TargetField = r.TargetField,
TransformationType = r.TransformationType,
Argument = r.Argument,
SortOrder = r.SortOrder,
IsActive = r.IsActive
}));
}
if (package.SapSourceDefinitions.Count > 0)
{
db.SapSourceDefinitions.AddRange(package.SapSourceDefinitions
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapSourceDefinition
{
SiteId = siteIdMap[x.SiteKey],
Alias = x.Alias,
EntitySet = x.EntitySet,
IsPrimary = x.IsPrimary,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
if (package.SapJoinDefinitions.Count > 0)
{
db.SapJoinDefinitions.AddRange(package.SapJoinDefinitions
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapJoinDefinition
{
SiteId = siteIdMap[x.SiteKey],
LeftAlias = x.LeftAlias,
RightAlias = x.RightAlias,
LeftKeys = x.LeftKeys,
RightKeys = x.RightKeys,
JoinType = x.JoinType,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
if (package.SapFieldMappings.Count > 0)
{
db.SapFieldMappings.AddRange(package.SapFieldMappings
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapFieldMapping
{
SiteId = siteIdMap[x.SiteKey],
TargetField = x.TargetField,
SourceExpression = x.SourceExpression,
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
await db.SaveChangesAsync();
}
private static string BuildServerSignature(string name, string host, int port, string databaseName)
=> $"{name}|{host}|{port}|{databaseName}".ToUpperInvariant();
private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem)
=> $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant();
}
@@ -7,22 +7,26 @@ namespace TrafagSalesExporter.Services;
public class ConsolidatedExportService : IConsolidatedExportService public class ConsolidatedExportService : IConsolidatedExportService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExcelExportService _excelService; private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService; private readonly ISharePointUploadService _sharePointService;
public ConsolidatedExportService( public ConsolidatedExportService(
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
IExcelExportService excelService, IExcelExportService excelService,
ISharePointUploadService sharePointService) ISharePointUploadService sharePointService)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_excelService = excelService; _excelService = excelService;
_sharePointService = sharePointService; _sharePointService = sharePointService;
} }
public async Task<string?> ExportAsync(List<SalesRecord> records) public async Task<string?> ExportAsync(List<SalesRecord> records)
{ {
if (records.Count == 0) var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
if (consolidatedRecords.Count == 0)
return null; return null;
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
@@ -31,7 +35,7 @@ public class ConsolidatedExportService : IConsolidatedExportService
var consolidatedPath = _excelService.CreateConsolidatedExcelFile( var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
outputDir, outputDir,
DateTime.UtcNow.Date, DateTime.UtcNow.Date,
records consolidatedRecords
.OrderBy(r => r.Land) .OrderBy(r => r.Land)
.ThenBy(r => r.Tsc) .ThenBy(r => r.Tsc)
.ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue) .ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue)
@@ -43,6 +43,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db); EnsureTransformationTable(db);
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db);
} }
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db) private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
@@ -193,6 +197,115 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureSapSourceTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SapSourceDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
Alias TEXT NOT NULL,
EntitySet TEXT NOT NULL,
IsPrimary INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SapJoinDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
LeftAlias TEXT NOT NULL,
RightAlias TEXT NOT NULL,
LeftKeys TEXT NOT NULL,
RightKeys TEXT NOT NULL,
JoinType TEXT NOT NULL DEFAULT 'Left',
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapFieldMappingTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SapFieldMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceExpression TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureCentralSalesRecordTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS CentralSalesRecords (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
StoredAtUtc TEXT NOT NULL,
SiteId INTEGER NOT NULL,
SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL,
InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL,
Name TEXT NOT NULL,
ProductGroup TEXT NOT NULL,
Quantity TEXT NOT NULL,
SupplierNumber TEXT NOT NULL,
SupplierName TEXT NOT NULL,
SupplierCountry TEXT NOT NULL,
CustomerNumber TEXT NOT NULL,
CustomerName TEXT NOT NULL,
CustomerCountry TEXT NOT NULL,
CustomerIndustry TEXT NOT NULL,
StandardCost TEXT NOT NULL,
StandardCostCurrency TEXT NOT NULL,
PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL,
Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL,
InvoiceDate TEXT NULL,
OrderDate TEXT NULL,
Land TEXT NOT NULL,
DocumentType TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void SeedIfEmpty(AppDbContext db) private static void SeedIfEmpty(AppDbContext db)
{ {
if (db.HanaServers.Any()) if (db.HanaServers.Any())
@@ -0,0 +1,9 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ICentralSalesRecordService
{
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records);
Task<List<SalesRecord>> GetAllAsync();
}
@@ -0,0 +1,7 @@
namespace TrafagSalesExporter.Services;
public interface IConfigTransferService
{
Task<string> ExportJsonAsync(bool includeSecrets);
Task ImportJsonAsync(string json);
}
@@ -0,0 +1,15 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ISapCompositionService
{
Task<List<SalesRecord>> BuildSalesRecordsAsync(
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
string username,
string password,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,222 @@
using System.Globalization;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class SapCompositionService : ISapCompositionService
{
private readonly ISapGatewayService _sapGatewayService;
public SapCompositionService(ISapGatewayService sapGatewayService)
{
_sapGatewayService = sapGatewayService;
}
public async Task<List<SalesRecord>> BuildSalesRecordsAsync(
Site site,
IReadOnlyList<SapSourceDefinition> sources,
IReadOnlyList<SapJoinDefinition> joins,
IReadOnlyList<SapFieldMapping> mappings,
string username,
string password,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL.");
var activeSources = sources
.Where(s => s.IsActive)
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToList();
if (activeSources.Count == 0)
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven SAP-Quellen.");
var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken);
sourceRows[source.Alias] = rows;
}
var composedRows = sourceRows[primarySource.Alias]
.Select(r => PrefixRow(primarySource.Alias, r))
.ToList();
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
{
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
continue;
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
}
return composedRows
.Select(row => MapToSalesRecord(site, row, mappings))
.ToList();
}
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
private static List<Dictionary<string, object?>> ApplyLeftJoin(
List<Dictionary<string, object?>> leftRows,
string leftAlias,
string leftKeys,
string rightAlias,
string rightKeys,
List<Dictionary<string, object?>> rightRows)
{
var leftKeyParts = SplitKeys(leftKeys);
var rightKeyParts = SplitKeys(rightKeys);
if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count)
return leftRows;
var rightLookup = rightRows
.GroupBy(r => BuildKey(r, rightKeyParts))
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var results = new List<Dictionary<string, object?>>();
foreach (var leftRow in leftRows)
{
var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts);
if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0)
{
foreach (var match in matches)
{
var merged = new Dictionary<string, object?>(leftRow, StringComparer.OrdinalIgnoreCase);
foreach (var kvp in PrefixRow(rightAlias, match))
merged[kvp.Key] = kvp.Value;
results.Add(merged);
}
}
else
{
results.Add(leftRow);
}
}
return results;
}
private static SalesRecord MapToSalesRecord(Site site, Dictionary<string, object?> row, IReadOnlyList<SapFieldMapping> mappings)
{
var record = new SalesRecord
{
ExtractionDate = DateTime.UtcNow,
Tsc = site.TSC,
Land = site.Land,
DocumentType = "SAP"
};
foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id))
{
var value = EvaluateExpression(row, mapping.SourceExpression);
ApplyValue(record, mapping.TargetField, value);
}
if (record.ExtractionDate == default)
record.ExtractionDate = DateTime.UtcNow;
if (string.IsNullOrWhiteSpace(record.Tsc))
record.Tsc = site.TSC;
if (string.IsNullOrWhiteSpace(record.Land))
record.Land = site.Land;
return record;
}
private static object? EvaluateExpression(Dictionary<string, object?> row, string expression)
{
if (string.IsNullOrWhiteSpace(expression))
return null;
var value = expression.Trim();
if (value.StartsWith('='))
return value[1..];
if (row.TryGetValue(value, out var direct))
return direct;
return null;
}
private static void ApplyValue(SalesRecord record, string targetField, object? value)
{
var property = typeof(SalesRecord).GetProperty(targetField);
if (property is null)
return;
try
{
if (property.PropertyType == typeof(string))
{
property.SetValue(record, value?.ToString() ?? string.Empty);
return;
}
if (property.PropertyType == typeof(int))
{
if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
property.SetValue(record, intValue);
return;
}
if (property.PropertyType == typeof(decimal))
{
if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
property.SetValue(record, decimalValue);
return;
}
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
{
if (TryParseDate(value?.ToString(), out var date))
property.SetValue(record, date);
}
}
catch
{
// ignore invalid mappings and continue with remaining fields
}
}
private static bool TryParseDate(string? value, out DateTime date)
{
date = default;
if (string.IsNullOrWhiteSpace(value))
return false;
var trimmed = value.Trim();
if (trimmed.StartsWith("/Date(", StringComparison.Ordinal) && trimmed.EndsWith(")/", StringComparison.Ordinal))
{
var epochRaw = trimmed[6..^2];
var separator = epochRaw.IndexOfAny(['+', '-']);
if (separator > 0)
epochRaw = epochRaw[..separator];
if (long.TryParse(epochRaw, out var ms))
{
date = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
return true;
}
}
return DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date)
|| DateTime.TryParse(trimmed, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date);
}
private static string BuildKey(Dictionary<string, object?> row, IReadOnlyList<string> keys)
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> keys)
=> string.Join("||", keys.Select(k =>
{
row.TryGetValue($"{alias}.{k}", out var value);
return NormalizeKeyValue(value);
}));
private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty;
private static List<string> SplitKeys(string keys)
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
@@ -10,26 +10,32 @@ public class SiteExportService : ISiteExportService
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService; private readonly IHanaQueryService _hanaService;
private readonly ISapGatewayService _sapGatewayService; private readonly ISapGatewayService _sapGatewayService;
private readonly ISapCompositionService _sapCompositionService;
private readonly IExcelExportService _excelService; private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService; private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService; private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly ILogger<SiteExportService> _logger; private readonly ILogger<SiteExportService> _logger;
public SiteExportService( public SiteExportService(
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService, IHanaQueryService hanaService,
ISapGatewayService sapGatewayService, ISapGatewayService sapGatewayService,
ISapCompositionService sapCompositionService,
IExcelExportService excelService, IExcelExportService excelService,
ISharePointUploadService sharePointService, ISharePointUploadService sharePointService,
IRecordTransformationService transformationService, IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
ILogger<SiteExportService> logger) ILogger<SiteExportService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_hanaService = hanaService; _hanaService = hanaService;
_sapGatewayService = sapGatewayService; _sapGatewayService = sapGatewayService;
_sapCompositionService = sapCompositionService;
_excelService = excelService; _excelService = excelService;
_sharePointService = sharePointService; _sharePointService = sharePointService;
_transformationService = transformationService; _transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_logger = logger; _logger = logger;
} }
@@ -59,14 +65,25 @@ public class SiteExportService : ISiteExportService
var credentials = ResolveCredentials(site, settings, sourceSystem); var credentials = ResolveCredentials(site, settings, sourceSystem);
if (string.IsNullOrWhiteSpace(site.SapServiceUrl)) if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL.");
if (string.IsNullOrWhiteSpace(site.SapEntitySet)) var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
throw new InvalidOperationException($"Standort '{site.Land}' hat kein SAP Entity Set ausgewählt."); var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
if (sapSources.Count == 0)
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Quellen konfiguriert.");
if (sapMappings.Count == 0)
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
updateStatus?.Invoke("SAP Gateway Abfrage..."); updateStatus?.Invoke("SAP Quellen laden...");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, site.SapEntitySet, credentials.Username, credentials.Password); records = await _sapCompositionService.BuildSalesRecordsAsync(site, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password);
updateStatus?.Invoke("Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen..."); updateStatus?.Invoke("Excel erstellen...");
filePath = _excelService.CreateGenericExcelFile(outputDir, $"SAP_{site.TSC}_{site.SapEntitySet}", DateTime.UtcNow.Date, site.SapEntitySet, rows); filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = rows.Count; log.RowCount = records.Count;
} }
else else
{ {
@@ -87,6 +104,9 @@ public class SiteExportService : ISiteExportService
log.RowCount = records.Count; log.RowCount = records.Count;
} }
updateStatus?.Invoke("Zentrale Tabelle aktualisieren...");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records);
var fileName = Path.GetFileName(filePath); var fileName = Path.GetFileName(filePath);
if (spConfig is not null && if (spConfig is not null &&
@@ -0,0 +1,13 @@
window.trafagDownload = {
saveTextFile: function (filename, content, contentType) {
const blob = new Blob([content], { type: contentType || "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
};