From 59e195af714066b56a047a6ddade7231c2194b76 Mon Sep 17 00:00:00 2001 From: metacube Date: Tue, 14 Apr 2026 11:34:43 +0200 Subject: [PATCH] import exxport settings, join over sap hana tables --- TrafagSalesExporter/Components/App.razor | 1 + .../Components/Pages/Dashboard.razor | 4 +- .../Components/Pages/Settings.razor | 84 +++++ .../Components/Pages/Standorte.razor | 229 ++++++++++++- TrafagSalesExporter/Data/AppDbContext.cs | 4 + .../Models/CentralSalesRecord.cs | 41 +++ .../Models/ConfigTransferPackage.cs | 102 ++++++ TrafagSalesExporter/Models/SapFieldMapping.cs | 26 ++ .../Models/SapJoinDefinition.cs | 32 ++ .../Models/SapSourceDefinition.cs | 26 ++ TrafagSalesExporter/Program.cs | 3 + .../Services/CentralSalesRecordService.cs | 97 ++++++ .../Services/ConfigTransferService.cs | 317 ++++++++++++++++++ .../Services/ConsolidatedExportService.cs | 8 +- .../Services/DatabaseInitializationService.cs | 113 +++++++ .../Services/ICentralSalesRecordService.cs | 9 + .../Services/IConfigTransferService.cs | 7 + .../Services/ISapCompositionService.cs | 15 + .../Services/SapCompositionService.cs | 222 ++++++++++++ .../Services/SiteExportService.cs | 32 +- TrafagSalesExporter/wwwroot/js/download.js | 13 + 21 files changed, 1369 insertions(+), 16 deletions(-) create mode 100644 TrafagSalesExporter/Models/CentralSalesRecord.cs create mode 100644 TrafagSalesExporter/Models/ConfigTransferPackage.cs create mode 100644 TrafagSalesExporter/Models/SapFieldMapping.cs create mode 100644 TrafagSalesExporter/Models/SapJoinDefinition.cs create mode 100644 TrafagSalesExporter/Models/SapSourceDefinition.cs create mode 100644 TrafagSalesExporter/Services/CentralSalesRecordService.cs create mode 100644 TrafagSalesExporter/Services/ConfigTransferService.cs create mode 100644 TrafagSalesExporter/Services/ICentralSalesRecordService.cs create mode 100644 TrafagSalesExporter/Services/IConfigTransferService.cs create mode 100644 TrafagSalesExporter/Services/ISapCompositionService.cs create mode 100644 TrafagSalesExporter/Services/SapCompositionService.cs create mode 100644 TrafagSalesExporter/wwwroot/js/download.js diff --git a/TrafagSalesExporter/Components/App.razor b/TrafagSalesExporter/Components/App.razor index f517dd1..cd44eb8 100644 --- a/TrafagSalesExporter/Components/App.razor +++ b/TrafagSalesExporter/Components/App.razor @@ -14,5 +14,6 @@ + diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index d8241d5..d36fecc 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -116,7 +116,9 @@ Land = s.Land, TSC = s.TSC, 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 ?? "", RowCount = log?.RowCount ?? 0, LastRun = log?.Timestamp, diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor index 92f5f41..38c900a 100644 --- a/TrafagSalesExporter/Components/Pages/Settings.razor +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -8,12 +8,39 @@ @inject TimerBackgroundService TimerService @inject IHanaQueryService HanaService @inject ISapGatewayService SapGatewayService +@inject IConfigTransferService ConfigTransferService +@inject IJSRuntime JS @inject ISnackbar Snackbar Settings Settings +Konfiguration Import/Export + + + + + + Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten. + + + + + + @(_exportingConfig ? "Exportiere..." : "Konfiguration exportieren") + + + @(_importingConfig ? "Importiere..." : "Konfiguration importieren") + + + + + + + @* SharePoint Config *@ SharePoint Konfiguration @@ -166,6 +193,9 @@ private SharePointConfig _spConfig = new(); private ExportSettings _exportSettings = new(); private bool _testingSp; + private bool _includeSecretsInExport; + private bool _exportingConfig; + private bool _importingConfig; private readonly HashSet _testingSystems = []; protected override async Task OnInitializedAsync() @@ -240,6 +270,60 @@ 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) { if (sourceSystem == "SAP") diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index ef57040..ee4ed52 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -1,6 +1,7 @@ @page "/standorte" @using Microsoft.EntityFrameworkCore @using System.Text.Json +@using System.Reflection @using TrafagSalesExporter.Data @using TrafagSalesExporter.Models @using TrafagSalesExporter.Services @@ -180,12 +181,110 @@ } - - @foreach (var entitySet in _sapEntitySetsCache) - { - @entitySet - } - + + + SAP Quellen + Quelle hinzufügen + + + 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`. + + + + Alias + Entity Set + Primär + Aktiv + Aktionen + + + + + + @foreach (var entitySet in _sapEntitySetsCache) + { + @entitySet + } + + + + + + + + + + + SAP Joins + Join hinzufügen + + + + Links + Left Keys + Rechts + Right Keys + Typ + Aktiv + Aktionen + + + + + @foreach (var alias in GetSapAliases()) + { + @alias + } + + + + + + @foreach (var alias in GetSapAliases()) + { + @alias + } + + + + + + Left + + + + + + + + + + Feldmappings ins zentrale Schema + Mapping hinzufügen + + + + Zielfeld + Source Expression + Pflicht + Aktiv + Aktionen + + + + + @foreach (var field in _salesRecordFields) + { + @field + } + + + + + + + + } else { @@ -222,6 +321,13 @@ private List _servers = new(); private List _sites = new(); private List _sapEntitySetsCache = []; + private List _sapSources = []; + private List _sapJoins = []; + private List _sapMappings = []; + private readonly string[] _salesRecordFields = typeof(SalesRecord) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToArray(); private HanaServer _editingServer = new(); private Site _editingSite = new(); private HanaServer _editingSiteServer = new(); @@ -348,9 +454,12 @@ { IsActive = true, SourceSystem = "SAP", - HanaServerId = 0 + HanaServerId = null }; _sapEntitySetsCache = []; + _sapSources = []; + _sapJoins = []; + _sapMappings = []; _editingSiteServer = CreateDefaultSiteServer(); _siteDialogVisible = true; } @@ -374,6 +483,10 @@ IsActive = site.IsActive }; _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 ? CreateDefaultSiteServer(site) : CloneServer(site.HanaServer); @@ -418,6 +531,7 @@ } await db.SaveChangesAsync(); + await SaveSapConfigurationAsync(db, _editingSite.Id); _siteDialogVisible = false; await LoadDataAsync(); Snackbar.Add("Standort gespeichert", Severity.Success); @@ -445,6 +559,14 @@ var entity = await db.Sites.FindAsync(site.Id); 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); await db.SaveChangesAsync(); } @@ -632,4 +754,97 @@ private static string SerializeSapEntitySets(List 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 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; + } } diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs index d647eb5..57f6126 100644 --- a/TrafagSalesExporter/Data/AppDbContext.cs +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -13,4 +13,8 @@ public class AppDbContext : DbContext public DbSet ExportSettings => Set(); public DbSet ExportLogs => Set(); public DbSet FieldTransformationRules => Set(); + public DbSet SapSourceDefinitions => Set(); + public DbSet SapJoinDefinitions => Set(); + public DbSet SapFieldMappings => Set(); + public DbSet CentralSalesRecords => Set(); } diff --git a/TrafagSalesExporter/Models/CentralSalesRecord.cs b/TrafagSalesExporter/Models/CentralSalesRecord.cs new file mode 100644 index 0000000..187bc90 --- /dev/null +++ b/TrafagSalesExporter/Models/CentralSalesRecord.cs @@ -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; +} diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs new file mode 100644 index 0000000..12690aa --- /dev/null +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -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 HanaServers { get; set; } = []; + public List Sites { get; set; } = []; + public List FieldTransformationRules { get; set; } = []; + public List SapSourceDefinitions { get; set; } = []; + public List SapJoinDefinitions { get; set; } = []; + public List 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; } +} diff --git a/TrafagSalesExporter/Models/SapFieldMapping.cs b/TrafagSalesExporter/Models/SapFieldMapping.cs new file mode 100644 index 0000000..d10243e --- /dev/null +++ b/TrafagSalesExporter/Models/SapFieldMapping.cs @@ -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; } +} diff --git a/TrafagSalesExporter/Models/SapJoinDefinition.cs b/TrafagSalesExporter/Models/SapJoinDefinition.cs new file mode 100644 index 0000000..d139091 --- /dev/null +++ b/TrafagSalesExporter/Models/SapJoinDefinition.cs @@ -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; } +} diff --git a/TrafagSalesExporter/Models/SapSourceDefinition.cs b/TrafagSalesExporter/Models/SapSourceDefinition.cs new file mode 100644 index 0000000..f242072 --- /dev/null +++ b/TrafagSalesExporter/Models/SapSourceDefinition.cs @@ -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; } +} diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 1a7dd0a..878172c 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -17,6 +17,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -28,6 +29,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/CentralSalesRecordService.cs b/TrafagSalesExporter/Services/CentralSalesRecordService.cs new file mode 100644 index 0000000..ebe1744 --- /dev/null +++ b/TrafagSalesExporter/Services/CentralSalesRecordService.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class CentralSalesRecordService : ICentralSalesRecordService +{ + private readonly IDbContextFactory _dbFactory; + + public CentralSalesRecordService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task ReplaceForSiteAsync(Site site, IEnumerable 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> 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(); + } +} diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs new file mode 100644 index 0000000..b339f16 --- /dev/null +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -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 _dbFactory; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public ConfigTransferService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task 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(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(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(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(); +} diff --git a/TrafagSalesExporter/Services/ConsolidatedExportService.cs b/TrafagSalesExporter/Services/ConsolidatedExportService.cs index 2a800e9..9e2034c 100644 --- a/TrafagSalesExporter/Services/ConsolidatedExportService.cs +++ b/TrafagSalesExporter/Services/ConsolidatedExportService.cs @@ -7,22 +7,26 @@ namespace TrafagSalesExporter.Services; public class ConsolidatedExportService : IConsolidatedExportService { private readonly IDbContextFactory _dbFactory; + private readonly ICentralSalesRecordService _centralSalesRecordService; private readonly IExcelExportService _excelService; private readonly ISharePointUploadService _sharePointService; public ConsolidatedExportService( IDbContextFactory dbFactory, + ICentralSalesRecordService centralSalesRecordService, IExcelExportService excelService, ISharePointUploadService sharePointService) { _dbFactory = dbFactory; + _centralSalesRecordService = centralSalesRecordService; _excelService = excelService; _sharePointService = sharePointService; } public async Task ExportAsync(List records) { - if (records.Count == 0) + var consolidatedRecords = await _centralSalesRecordService.GetAllAsync(); + if (consolidatedRecords.Count == 0) return null; using var db = await _dbFactory.CreateDbContextAsync(); @@ -31,7 +35,7 @@ public class ConsolidatedExportService : IConsolidatedExportService var consolidatedPath = _excelService.CreateConsolidatedExcelFile( outputDir, DateTime.UtcNow.Date, - records + consolidatedRecords .OrderBy(r => r.Land) .ThenBy(r => r.Tsc) .ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue) diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs index 9fe48e6..a341f11 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs @@ -43,6 +43,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''"); EnsureTransformationTable(db); + EnsureSapSourceTable(db); + EnsureSapJoinTable(db); + EnsureSapFieldMappingTable(db); + EnsureCentralSalesRecordTable(db); } private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db) @@ -193,6 +197,115 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules ( 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) { if (db.HanaServers.Any()) diff --git a/TrafagSalesExporter/Services/ICentralSalesRecordService.cs b/TrafagSalesExporter/Services/ICentralSalesRecordService.cs new file mode 100644 index 0000000..1ef174a --- /dev/null +++ b/TrafagSalesExporter/Services/ICentralSalesRecordService.cs @@ -0,0 +1,9 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface ICentralSalesRecordService +{ + Task ReplaceForSiteAsync(Site site, IEnumerable records); + Task> GetAllAsync(); +} diff --git a/TrafagSalesExporter/Services/IConfigTransferService.cs b/TrafagSalesExporter/Services/IConfigTransferService.cs new file mode 100644 index 0000000..ed4acc5 --- /dev/null +++ b/TrafagSalesExporter/Services/IConfigTransferService.cs @@ -0,0 +1,7 @@ +namespace TrafagSalesExporter.Services; + +public interface IConfigTransferService +{ + Task ExportJsonAsync(bool includeSecrets); + Task ImportJsonAsync(string json); +} diff --git a/TrafagSalesExporter/Services/ISapCompositionService.cs b/TrafagSalesExporter/Services/ISapCompositionService.cs new file mode 100644 index 0000000..f01cd52 --- /dev/null +++ b/TrafagSalesExporter/Services/ISapCompositionService.cs @@ -0,0 +1,15 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface ISapCompositionService +{ + Task> BuildSalesRecordsAsync( + Site site, + IReadOnlyList sources, + IReadOnlyList joins, + IReadOnlyList mappings, + string username, + string password, + CancellationToken cancellationToken = default); +} diff --git a/TrafagSalesExporter/Services/SapCompositionService.cs b/TrafagSalesExporter/Services/SapCompositionService.cs new file mode 100644 index 0000000..c030bfd --- /dev/null +++ b/TrafagSalesExporter/Services/SapCompositionService.cs @@ -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> BuildSalesRecordsAsync( + Site site, + IReadOnlyList sources, + IReadOnlyList joins, + IReadOnlyList 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>>(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 PrefixRow(string alias, Dictionary row) + => row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); + + private static List> ApplyLeftJoin( + List> leftRows, + string leftAlias, + string leftKeys, + string rightAlias, + string rightKeys, + List> 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>(); + 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(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 row, IReadOnlyList 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 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 row, IReadOnlyList keys) + => string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null))); + + private static string BuildKey(Dictionary row, string alias, IReadOnlyList 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 SplitKeys(string keys) + => keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); +} diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 01f2e65..c574de7 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -10,26 +10,32 @@ public class SiteExportService : ISiteExportService private readonly IDbContextFactory _dbFactory; private readonly IHanaQueryService _hanaService; private readonly ISapGatewayService _sapGatewayService; + private readonly ISapCompositionService _sapCompositionService; private readonly IExcelExportService _excelService; private readonly ISharePointUploadService _sharePointService; private readonly IRecordTransformationService _transformationService; + private readonly ICentralSalesRecordService _centralSalesRecordService; private readonly ILogger _logger; public SiteExportService( IDbContextFactory dbFactory, IHanaQueryService hanaService, ISapGatewayService sapGatewayService, + ISapCompositionService sapCompositionService, IExcelExportService excelService, ISharePointUploadService sharePointService, IRecordTransformationService transformationService, + ICentralSalesRecordService centralSalesRecordService, ILogger logger) { _dbFactory = dbFactory; _hanaService = hanaService; _sapGatewayService = sapGatewayService; + _sapCompositionService = sapCompositionService; _excelService = excelService; _sharePointService = sharePointService; _transformationService = transformationService; + _centralSalesRecordService = centralSalesRecordService; _logger = logger; } @@ -59,14 +65,25 @@ public class SiteExportService : ISiteExportService var credentials = ResolveCredentials(site, settings, sourceSystem); if (string.IsNullOrWhiteSpace(site.SapServiceUrl)) throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); - if (string.IsNullOrWhiteSpace(site.SapEntitySet)) - throw new InvalidOperationException($"Standort '{site.Land}' hat kein SAP Entity Set ausgewählt."); + var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync(); + 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..."); - var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, site.SapEntitySet, credentials.Username, credentials.Password); + updateStatus?.Invoke("SAP Quellen laden..."); + 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..."); - filePath = _excelService.CreateGenericExcelFile(outputDir, $"SAP_{site.TSC}_{site.SapEntitySet}", DateTime.UtcNow.Date, site.SapEntitySet, rows); - log.RowCount = rows.Count; + filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); + log.RowCount = records.Count; } else { @@ -87,6 +104,9 @@ public class SiteExportService : ISiteExportService log.RowCount = records.Count; } + updateStatus?.Invoke("Zentrale Tabelle aktualisieren..."); + await _centralSalesRecordService.ReplaceForSiteAsync(site, records); + var fileName = Path.GetFileName(filePath); if (spConfig is not null && diff --git a/TrafagSalesExporter/wwwroot/js/download.js b/TrafagSalesExporter/wwwroot/js/download.js new file mode 100644 index 0000000..7e75da1 --- /dev/null +++ b/TrafagSalesExporter/wwwroot/js/download.js @@ -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); + } +};