From 82ac7df0ecd952fe86340c0ed7a925ca72925e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 12:11:35 +0000 Subject: [PATCH] DataSourceAdapter-Pattern + SiteExportService schlanker + Page-Services Scoped - IDataSourceAdapter mit 3 Implementierungen (HANA, SAP_GATEWAY, MANUAL_EXCEL) und DataSourceAdapterResolver ersetzen das if/else auf ConnectionKind. - SiteExportService von 338 auf 187 Zeilen reduziert: Pipeline Resolve -> Fetch -> Transform -> Excel -> Central -> SharePoint. - Page-Services auf Scoped (per Blazor-Circuit); Orchestrator bleibt Singleton fuer geteilten Export-Status. --- TrafagSalesExporter/Program.cs | 28 +- .../DataSources/DataSourceAdapterResolver.cs | 27 ++ .../DataSources/DataSourceCredentials.cs | 24 ++ .../DataSources/DataSourceFetchContext.cs | 12 + .../DataSources/DataSourceFetchResult.cs | 14 + .../DataSources/HanaDataSourceAdapter.cs | 71 +++++ .../DataSources/IDataSourceAdapter.cs | 11 + .../DataSources/IDataSourceAdapterResolver.cs | 6 + .../ManualExcelDataSourceAdapter.cs | 93 ++++++ .../SapGatewayDataSourceAdapter.cs | 81 +++++ .../Services/SiteExportService.cs | 295 +++++------------- 11 files changed, 431 insertions(+), 231 deletions(-) create mode 100644 TrafagSalesExporter/Services/DataSources/DataSourceAdapterResolver.cs create mode 100644 TrafagSalesExporter/Services/DataSources/DataSourceCredentials.cs create mode 100644 TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs create mode 100644 TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs create mode 100644 TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs create mode 100644 TrafagSalesExporter/Services/DataSources/IDataSourceAdapter.cs create mode 100644 TrafagSalesExporter/Services/DataSources/IDataSourceAdapterResolver.cs create mode 100644 TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs create mode 100644 TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 01d56ad..61ca2b7 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using TrafagSalesExporter.Data; using TrafagSalesExporter.Services; +using TrafagSalesExporter.Services.DataSources; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +15,7 @@ builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.AddDbContextFactory(options => options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); +// Stateless Infrastruktur- und Connector-Services: Singleton. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -36,7 +38,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -44,18 +45,29 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// Datenquellen-Adapter (Strategy per ConnectionKind). +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Orchestrator mit gemeinsamem Status ueber alle Circuits. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// UI-/Page-Services: Scoped = pro Blazor-Circuit. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); using (var scope = app.Services.CreateScope()) diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceAdapterResolver.cs b/TrafagSalesExporter/Services/DataSources/DataSourceAdapterResolver.cs new file mode 100644 index 0000000..e0a1acb --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/DataSourceAdapterResolver.cs @@ -0,0 +1,27 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class DataSourceAdapterResolver : IDataSourceAdapterResolver +{ + private readonly Dictionary _adapters; + + public DataSourceAdapterResolver(IEnumerable adapters) + { + _adapters = adapters.ToDictionary( + a => a.ConnectionKind, + StringComparer.OrdinalIgnoreCase); + } + + public IDataSourceAdapter Resolve(string connectionKind) + { + if (string.IsNullOrWhiteSpace(connectionKind)) + connectionKind = SourceSystemConnectionKinds.Hana; + + if (_adapters.TryGetValue(connectionKind, out var adapter)) + return adapter; + + throw new InvalidOperationException( + $"Kein DataSourceAdapter fuer ConnectionKind '{connectionKind}' registriert."); + } +} diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceCredentials.cs b/TrafagSalesExporter/Services/DataSources/DataSourceCredentials.cs new file mode 100644 index 0000000..3c59e39 --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/DataSourceCredentials.cs @@ -0,0 +1,24 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +internal static class DataSourceCredentials +{ + public static (string Username, string Password) Resolve(Site site, SourceSystemDefinition sourceDefinition) + => (FirstNonEmpty(site.UsernameOverride, sourceDefinition.CentralUsername), + FirstNonEmpty(site.PasswordOverride, sourceDefinition.CentralPassword)); + + public static string ResolveSapServiceUrl(Site site, SourceSystemDefinition sourceDefinition) + => FirstNonEmpty(site.SapServiceUrl, sourceDefinition.CentralServiceUrl); + + public static string FirstNonEmpty(params string[] values) + { + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim(); + } + + return string.Empty; + } +} diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs b/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs new file mode 100644 index 0000000..afd9c98 --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs @@ -0,0 +1,12 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class DataSourceFetchContext +{ + public required Site Site { get; init; } + public required SourceSystemDefinition SourceDefinition { get; init; } + public required ExportSettings Settings { get; init; } + public SharePointConfig? SharePointConfig { get; init; } + public Action? UpdateStatus { get; init; } +} diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs b/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs new file mode 100644 index 0000000..d76b13a --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs @@ -0,0 +1,14 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class DataSourceFetchResult +{ + public required List Records { get; init; } + + /// + /// Wenn gesetzt, liefert der Adapter bereits eine Referenz-Datei (z. B. manueller Excel-Import). + /// SiteExportService erzeugt dann keine neue Excel-Datei. + /// + public string? ReferenceFilePath { get; init; } +} diff --git a/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs new file mode 100644 index 0000000..298ef1b --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class HanaDataSourceAdapter : IDataSourceAdapter +{ + private readonly IDbContextFactory _dbFactory; + private readonly IHanaQueryService _hanaService; + private readonly IAppEventLogService _appEventLogService; + + public HanaDataSourceAdapter( + IDbContextFactory dbFactory, + IHanaQueryService hanaService, + IAppEventLogService appEventLogService) + { + _dbFactory = dbFactory; + _hanaService = hanaService; + _appEventLogService = appEventLogService; + } + + public string ConnectionKind => SourceSystemConnectionKinds.Hana; + + public async Task FetchAsync(DataSourceFetchContext context) + { + var site = context.Site; + var sourceDefinition = context.SourceDefinition; + + using var db = await _dbFactory.CreateDbContextAsync(); + var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition); + + context.UpdateStatus?.Invoke("HANA Abfrage..."); + await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet", + siteId: site.Id, land: site.Land, + details: exportServer.GetConnectionStringPreview()); + + var records = await Task.Run(() => _hanaService.GetSalesRecords( + exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter)); + + return new DataSourceFetchResult { Records = records }; + } + + private static async Task BuildEffectiveServerAsync( + AppDbContext db, Site site, SourceSystemDefinition sourceDefinition) + { + var centralServer = await db.HanaServers + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.SourceSystem == sourceDefinition.Code) + ?? throw new InvalidOperationException( + $"Fuer Quellsystem '{sourceDefinition.Code}' ist keine zentrale HANA-Konfiguration vorhanden."); + + var credentials = DataSourceCredentials.Resolve(site, sourceDefinition); + + return new HanaServer + { + Id = centralServer.Id, + SourceSystem = centralServer.SourceSystem, + Name = centralServer.Name, + Host = centralServer.Host, + Port = centralServer.Port, + Username = credentials.Username, + Password = credentials.Password, + DatabaseName = centralServer.DatabaseName, + UseSsl = centralServer.UseSsl, + ValidateCertificate = centralServer.ValidateCertificate, + AdditionalParams = centralServer.AdditionalParams + }; + } +} diff --git a/TrafagSalesExporter/Services/DataSources/IDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/IDataSourceAdapter.cs new file mode 100644 index 0000000..cb190da --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/IDataSourceAdapter.cs @@ -0,0 +1,11 @@ +namespace TrafagSalesExporter.Services.DataSources; + +public interface IDataSourceAdapter +{ + /// + /// Der Wert aus , den dieser Adapter behandelt. + /// + string ConnectionKind { get; } + + Task FetchAsync(DataSourceFetchContext context); +} diff --git a/TrafagSalesExporter/Services/DataSources/IDataSourceAdapterResolver.cs b/TrafagSalesExporter/Services/DataSources/IDataSourceAdapterResolver.cs new file mode 100644 index 0000000..9110941 --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/IDataSourceAdapterResolver.cs @@ -0,0 +1,6 @@ +namespace TrafagSalesExporter.Services.DataSources; + +public interface IDataSourceAdapterResolver +{ + IDataSourceAdapter Resolve(string connectionKind); +} diff --git a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs new file mode 100644 index 0000000..9d23f5c --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs @@ -0,0 +1,93 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter +{ + private readonly ISharePointUploadService _sharePointService; + private readonly IManualExcelImportService _manualExcelImportService; + private readonly IAppEventLogService _appEventLogService; + + public ManualExcelDataSourceAdapter( + ISharePointUploadService sharePointService, + IManualExcelImportService manualExcelImportService, + IAppEventLogService appEventLogService) + { + _sharePointService = sharePointService; + _manualExcelImportService = manualExcelImportService; + _appEventLogService = appEventLogService; + } + + public string ConnectionKind => SourceSystemConnectionKinds.ManualExcel; + + public async Task FetchAsync(DataSourceFetchContext context) + { + var site = context.Site; + + if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) + throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei."); + + var manualImportPath = site.ManualImportFilePath.Trim(); + string filePath; + string? tempManualImportPath = null; + try + { + if (File.Exists(manualImportPath)) + { + filePath = manualImportPath; + } + else if (LooksLikeSharePointReference(manualImportPath)) + { + var spConfig = context.SharePointConfig + ?? throw new InvalidOperationException( + "Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); + + if (string.IsNullOrWhiteSpace(spConfig.TenantId) || + string.IsNullOrWhiteSpace(spConfig.ClientId) || + string.IsNullOrWhiteSpace(spConfig.ClientSecret) || + string.IsNullOrWhiteSpace(spConfig.SiteUrl)) + { + throw new InvalidOperationException( + "Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); + } + + context.UpdateStatus?.Invoke("Manuelle Excel von SharePoint laden..."); + await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", + siteId: site.Id, land: site.Land, details: manualImportPath); + + tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, + spConfig.SiteUrl, manualImportPath); + filePath = manualImportPath; + } + else + { + throw new InvalidOperationException( + $"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); + } + + var readPath = tempManualImportPath ?? filePath; + context.UpdateStatus?.Invoke("Manuelle Excel lesen..."); + await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", + siteId: site.Id, land: site.Land, details: filePath); + + var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); + return new DataSourceFetchResult + { + Records = records, + ReferenceFilePath = filePath + }; + } + finally + { + if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) + File.Delete(tempManualImportPath); + } + } + + private static bool LooksLikeSharePointReference(string path) + => path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); +} diff --git a/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs new file mode 100644 index 0000000..263d7ea --- /dev/null +++ b/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services.DataSources; + +public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter +{ + private readonly IDbContextFactory _dbFactory; + private readonly ISapCompositionService _sapCompositionService; + private readonly IAppEventLogService _appEventLogService; + + public SapGatewayDataSourceAdapter( + IDbContextFactory dbFactory, + ISapCompositionService sapCompositionService, + IAppEventLogService appEventLogService) + { + _dbFactory = dbFactory; + _sapCompositionService = sapCompositionService; + _appEventLogService = appEventLogService; + } + + public string ConnectionKind => SourceSystemConnectionKinds.SapGateway; + + public async Task FetchAsync(DataSourceFetchContext context) + { + var site = context.Site; + var sourceDefinition = context.SourceDefinition; + + var credentials = DataSourceCredentials.Resolve(site, sourceDefinition); + var sapServiceUrl = DataSourceCredentials.ResolveSapServiceUrl(site, sourceDefinition); + if (string.IsNullOrWhiteSpace(sapServiceUrl)) + throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); + + using var db = await _dbFactory.CreateDbContextAsync(); + 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."); + + context.UpdateStatus?.Invoke("SAP Quellen laden..."); + await _appEventLogService.WriteAsync("Export", "SAP Quellen laden", + siteId: site.Id, land: site.Land, + details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}"); + + var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); + var records = await _sapCompositionService.BuildSalesRecordsAsync( + effectiveSite, sapSources, sapJoins, sapMappings, + credentials.Username, credentials.Password); + + return new DataSourceFetchResult { Records = records }; + } + + private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl) + { + return new Site + { + Id = site.Id, + HanaServerId = site.HanaServerId, + HanaServer = site.HanaServer, + Schema = site.Schema, + TSC = site.TSC, + Land = site.Land, + SourceSystem = site.SourceSystem, + UsernameOverride = site.UsernameOverride, + PasswordOverride = site.PasswordOverride, + LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, + SapServiceUrl = sapServiceUrl, + SapEntitySet = site.SapEntitySet, + SapEntitySetsCache = site.SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, + IsActive = site.IsActive + }; + } +} diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index c68068e..8c6ff2c 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -2,45 +2,37 @@ using Microsoft.EntityFrameworkCore; using System.Diagnostics; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services.DataSources; namespace TrafagSalesExporter.Services; public class SiteExportService : ISiteExportService { private readonly IDbContextFactory _dbFactory; - private readonly IHanaQueryService _hanaService; - private readonly ISapGatewayService _sapGatewayService; - private readonly ISapCompositionService _sapCompositionService; + private readonly IDataSourceAdapterResolver _dataSourceResolver; private readonly IExcelExportService _excelService; private readonly ISharePointUploadService _sharePointService; private readonly IRecordTransformationService _transformationService; private readonly ICentralSalesRecordService _centralSalesRecordService; - private readonly IManualExcelImportService _manualExcelImportService; private readonly IAppEventLogService _appEventLogService; private readonly ILogger _logger; public SiteExportService( IDbContextFactory dbFactory, - IHanaQueryService hanaService, - ISapGatewayService sapGatewayService, - ISapCompositionService sapCompositionService, + IDataSourceAdapterResolver dataSourceResolver, IExcelExportService excelService, ISharePointUploadService sharePointService, IRecordTransformationService transformationService, ICentralSalesRecordService centralSalesRecordService, - IManualExcelImportService manualExcelImportService, IAppEventLogService appEventLogService, ILogger logger) { _dbFactory = dbFactory; - _hanaService = hanaService; - _sapGatewayService = sapGatewayService; - _sapCompositionService = sapCompositionService; + _dataSourceResolver = dataSourceResolver; _excelService = excelService; _sharePointService = sharePointService; _transformationService = transformationService; _centralSalesRecordService = centralSalesRecordService; - _manualExcelImportService = manualExcelImportService; _appEventLogService = appEventLogService; _logger = logger; } @@ -58,167 +50,63 @@ public class SiteExportService : ISiteExportService try { - await _appEventLogService.WriteAsync("Export", "Export gestartet", siteId: site.Id, land: site.Land, - details: $"Quelle={NormalizeSourceSystem(site.SourceSystem)} | TSC={site.TSC}"); - using var db = await _dbFactory.CreateDbContextAsync(); - var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); - var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); - var outputDir = ResolveSiteOutputDirectory(settings, site); var sourceSystem = NormalizeSourceSystem(site.SourceSystem); - var sourceDefinition = await db.SourceSystemDefinitions - .AsNoTracking() - .OrderBy(x => x.Id) - .FirstOrDefaultAsync(x => x.Code == sourceSystem) - ?? throw new InvalidOperationException($"Quellsystem '{sourceSystem}' ist nicht konfiguriert."); - var records = new List(); - string filePath; + await _appEventLogService.WriteAsync("Export", "Export gestartet", + siteId: site.Id, land: site.Land, + details: $"Quelle={sourceSystem} | TSC={site.TSC}"); - if (string.Equals(sourceDefinition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + var (settings, spConfig, sourceDefinition, rules) = await LoadExportConfigAsync(site, sourceSystem); + var outputDir = ResolveSiteOutputDirectory(settings, site); + + var adapter = _dataSourceResolver.Resolve(sourceDefinition.ConnectionKind); + var fetchResult = await adapter.FetchAsync(new DataSourceFetchContext { - var credentials = ResolveCredentials(site, sourceDefinition); - var sapServiceUrl = ResolveSapServiceUrl(site, sourceDefinition); - if (string.IsNullOrWhiteSpace(sapServiceUrl)) - throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); - 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."); + Site = site, + SourceDefinition = sourceDefinition, + Settings = settings, + SharePointConfig = spConfig, + UpdateStatus = updateStatus + }); - updateStatus?.Invoke("SAP Quellen laden..."); - await _appEventLogService.WriteAsync("Export", "SAP Quellen laden", siteId: site.Id, land: site.Land, - details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}"); - var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); - records = await _sapCompositionService.BuildSalesRecordsAsync(effectiveSite, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password); - updateStatus?.Invoke("Transformationen anwenden..."); - await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, - details: $"Records vor Transformation={records.Count}"); - var rules = await db.FieldTransformationRules - .Where(r => r.IsActive && r.SourceSystem == sourceSystem) - .OrderBy(r => r.SortOrder) - .ToListAsync(); - _transformationService.Apply(records, rules); + var records = fetchResult.Records; + + updateStatus?.Invoke("Transformationen anwenden..."); + await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", + siteId: site.Id, land: site.Land, + details: $"Records vor Transformation={records.Count}"); + _transformationService.Apply(records, rules); + + var filePath = fetchResult.ReferenceFilePath; + if (string.IsNullOrWhiteSpace(filePath)) + { updateStatus?.Invoke("Excel erstellen..."); - await _appEventLogService.WriteAsync("Export", "Excel erstellen", siteId: site.Id, land: site.Land, + await _appEventLogService.WriteAsync("Export", "Excel erstellen", + siteId: site.Id, land: site.Land, details: $"Records={records.Count}"); filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); - log.RowCount = records.Count; } - else if (string.Equals(sourceDefinition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) - { - if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) - throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei."); - string? tempManualImportPath = null; - try - { - var manualImportPath = site.ManualImportFilePath.Trim(); - if (File.Exists(manualImportPath)) - { - filePath = manualImportPath; - } - else if (LooksLikeSharePointReference(manualImportPath)) - { - if (spConfig is null || - string.IsNullOrWhiteSpace(spConfig.TenantId) || - string.IsNullOrWhiteSpace(spConfig.ClientId) || - string.IsNullOrWhiteSpace(spConfig.ClientSecret) || - string.IsNullOrWhiteSpace(spConfig.SiteUrl)) - { - throw new InvalidOperationException("Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); - } - updateStatus?.Invoke("Manuelle Excel von SharePoint laden..."); - await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", siteId: site.Id, land: site.Land, - details: manualImportPath); - tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( - spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, manualImportPath); - filePath = manualImportPath; - } - else - { - throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); - } - - var readPath = tempManualImportPath ?? filePath; - updateStatus?.Invoke("Manuelle Excel lesen..."); - await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land, - details: filePath); - records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); - } - finally - { - if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) - File.Delete(tempManualImportPath); - } - - updateStatus?.Invoke("Transformationen anwenden..."); - await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, - details: $"Records vor Transformation={records.Count}"); - var rules = await db.FieldTransformationRules - .Where(r => r.IsActive && r.SourceSystem == sourceSystem) - .OrderBy(r => r.SortOrder) - .ToListAsync(); - _transformationService.Apply(records, rules); - - log.RowCount = records.Count; - } - else - { - var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition); - updateStatus?.Invoke("HANA Abfrage..."); - await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet", siteId: site.Id, land: site.Land, - details: exportServer.GetConnectionStringPreview()); - records = await Task.Run(() => _hanaService.GetSalesRecords( - exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter)); - - updateStatus?.Invoke("Transformationen anwenden..."); - await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, - details: $"Records vor Transformation={records.Count}"); - 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..."); - await _appEventLogService.WriteAsync("Export", "Excel erstellen", siteId: site.Id, land: site.Land, - details: $"Records={records.Count}"); - filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); - log.RowCount = records.Count; - } + log.RowCount = records.Count; updateStatus?.Invoke("Zentrale Tabelle aktualisieren..."); - await _appEventLogService.WriteAsync("Export", "Zentrale Tabelle aktualisieren", siteId: site.Id, land: site.Land, + await _appEventLogService.WriteAsync("Export", "Zentrale Tabelle aktualisieren", + siteId: site.Id, land: site.Land, details: $"Records={records.Count}"); await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus); - var fileName = Path.GetFileName(filePath); - - if (spConfig is not null && - !string.IsNullOrWhiteSpace(spConfig.TenantId) && - !string.IsNullOrWhiteSpace(spConfig.ClientId) && - !string.IsNullOrWhiteSpace(spConfig.ClientSecret)) - { - updateStatus?.Invoke("SharePoint Upload..."); - await _appEventLogService.WriteAsync("Export", "SharePoint Upload gestartet", siteId: site.Id, land: site.Land, - details: $"{spConfig.SiteUrl} | {spConfig.ExportFolder}"); - await _sharePointService.UploadAsync( - spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, - spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath); - } + await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus); sw.Stop(); log.Status = "OK"; - log.FileName = fileName; + log.FileName = Path.GetFileName(filePath); log.FilePath = filePath; log.DurationSeconds = sw.Elapsed.TotalSeconds; _logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s", site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds); - await _appEventLogService.WriteAsync("Export", "Export erfolgreich", siteId: site.Id, land: site.Land, - details: $"Rows={log.RowCount} | Datei={fileName} | Pfad={filePath} | Dauer={sw.Elapsed.TotalSeconds:F1}s"); + await _appEventLogService.WriteAsync("Export", "Export erfolgreich", + siteId: site.Id, land: site.Land, + details: $"Rows={log.RowCount} | Datei={log.FileName} | Pfad={filePath} | Dauer={sw.Elapsed.TotalSeconds:F1}s"); return new SiteExportResult { @@ -237,8 +125,8 @@ public class SiteExportService : ISiteExportService log.DurationSeconds = sw.Elapsed.TotalSeconds; _logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC); - await _appEventLogService.WriteAsync("Export", "Export fehlgeschlagen", "Error", siteId: site.Id, land: site.Land, - details: ex.ToString()); + await _appEventLogService.WriteAsync("Export", "Export fehlgeschlagen", "Error", + siteId: site.Id, land: site.Land, details: ex.ToString()); return new SiteExportResult { @@ -249,90 +137,51 @@ public class SiteExportService : ISiteExportService } } - private static async Task BuildEffectiveServerAsync(AppDbContext db, Site site, SourceSystemDefinition sourceDefinition) + private async Task<(ExportSettings settings, SharePointConfig? spConfig, SourceSystemDefinition sourceDefinition, List rules)> + LoadExportConfigAsync(Site site, string sourceSystem) { - var centralServer = await db.HanaServers + using var db = await _dbFactory.CreateDbContextAsync(); + var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); + var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); + var sourceDefinition = await db.SourceSystemDefinitions .AsNoTracking() .OrderBy(x => x.Id) - .FirstOrDefaultAsync(x => x.SourceSystem == sourceDefinition.Code); - - if (centralServer is null) - throw new InvalidOperationException($"Fuer Quellsystem '{sourceDefinition.Code}' ist keine zentrale HANA-Konfiguration vorhanden."); - - var credentials = ResolveCredentials(site, sourceDefinition); - - return new HanaServer - { - Id = centralServer.Id, - SourceSystem = centralServer.SourceSystem, - Name = centralServer.Name, - Host = centralServer.Host, - Port = centralServer.Port, - Username = credentials.Username, - Password = credentials.Password, - DatabaseName = centralServer.DatabaseName, - UseSsl = centralServer.UseSsl, - ValidateCertificate = centralServer.ValidateCertificate, - AdditionalParams = centralServer.AdditionalParams - }; + .FirstOrDefaultAsync(x => x.Code == sourceSystem) + ?? throw new InvalidOperationException($"Quellsystem '{sourceSystem}' ist nicht konfiguriert."); + var rules = await db.FieldTransformationRules + .Where(r => r.IsActive && r.SourceSystem == sourceSystem) + .OrderBy(r => r.SortOrder) + .ToListAsync(); + return (settings, spConfig, sourceDefinition, rules); } - private static (string Username, string Password) ResolveCredentials(Site site, SourceSystemDefinition sourceDefinition) - => (FirstNonEmpty(site.UsernameOverride, sourceDefinition.CentralUsername), - FirstNonEmpty(site.PasswordOverride, sourceDefinition.CentralPassword)); + private async Task UploadToSharePointIfConfiguredAsync( + Site site, SharePointConfig? spConfig, string filePath, Action? updateStatus) + { + if (spConfig is null || + string.IsNullOrWhiteSpace(spConfig.TenantId) || + string.IsNullOrWhiteSpace(spConfig.ClientId) || + string.IsNullOrWhiteSpace(spConfig.ClientSecret)) + return; - private static string ResolveSapServiceUrl(Site site, SourceSystemDefinition sourceDefinition) - => FirstNonEmpty(site.SapServiceUrl, sourceDefinition.CentralServiceUrl); + updateStatus?.Invoke("SharePoint Upload..."); + await _appEventLogService.WriteAsync("Export", "SharePoint Upload gestartet", + siteId: site.Id, land: site.Land, + details: $"{spConfig.SiteUrl} | {spConfig.ExportFolder}"); + await _sharePointService.UploadAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, + spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath); + } private static string NormalizeSourceSystem(string? sourceSystem) => string.IsNullOrWhiteSpace(sourceSystem) ? "SAP" : sourceSystem.Trim().ToUpperInvariant(); - private static string FirstNonEmpty(params string[] values) - { - foreach (var value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - return value.Trim(); - } - - return string.Empty; - } - private static string ResolveSiteOutputDirectory(ExportSettings settings, Site site) { - var configured = FirstNonEmpty(site.LocalExportFolderOverride, settings.LocalSiteExportFolder); + var configured = DataSourceCredentials.FirstNonEmpty( + site.LocalExportFolderOverride, settings.LocalSiteExportFolder); return string.IsNullOrWhiteSpace(configured) ? Path.Combine(AppContext.BaseDirectory, "output") : configured; } - - private static bool LooksLikeSharePointReference(string path) - => path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); - - private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl) - { - return new Site - { - Id = site.Id, - HanaServerId = site.HanaServerId, - HanaServer = site.HanaServer, - Schema = site.Schema, - TSC = site.TSC, - Land = site.Land, - SourceSystem = site.SourceSystem, - UsernameOverride = site.UsernameOverride, - PasswordOverride = site.PasswordOverride, - LocalExportFolderOverride = site.LocalExportFolderOverride, - ManualImportFilePath = site.ManualImportFilePath, - ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, - SapServiceUrl = sapServiceUrl, - SapEntitySet = site.SapEntitySet, - SapEntitySetsCache = site.SapEntitySetsCache, - SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, - IsActive = site.IsActive - }; - } }