Merge pull request #61 from metacube2/claude/review-trafag-tool-JONMq

Refactor SiteExportService to use adapter pattern for data sources
This commit is contained in:
2026-04-17 14:17:09 +02:00
committed by GitHub
11 changed files with 431 additions and 231 deletions
+20 -8
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services; using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -14,6 +15,7 @@ builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
// Stateless Infrastruktur- und Connector-Services: Singleton.
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>(); builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>(); builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>(); builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
@@ -36,7 +38,6 @@ builder.Services.AddSingleton<IRecordTransformationService, RecordTransformation
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>(); builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>(); builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>(); builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>(); builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>(); builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>(); builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
@@ -44,18 +45,29 @@ builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>(); builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>(); builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>(); builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<ISettingsPageService, SettingsPageService>();
builder.Services.AddSingleton<IStandortePageService, StandortePageService>();
builder.Services.AddSingleton<IStandorteSapEditorService, StandorteSapEditorService>();
builder.Services.AddSingleton<IManagementCockpitPageService, ManagementCockpitPageService>();
builder.Services.AddSingleton<IDashboardPageService, DashboardPageService>();
builder.Services.AddSingleton<ILogsPageService, LogsPageService>();
builder.Services.AddSingleton<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>(); builder.Services.AddSingleton<IUiTextService, UiTextService>();
// Datenquellen-Adapter (Strategy per ConnectionKind).
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, SapGatewayDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, ManualExcelDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapterResolver, DataSourceAdapterResolver>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
// Orchestrator mit gemeinsamem Status ueber alle Circuits.
builder.Services.AddSingleton<ExportOrchestrationService>(); builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>(); builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
// UI-/Page-Services: Scoped = pro Blazor-Circuit.
builder.Services.AddScoped<ISettingsPageService, SettingsPageService>();
builder.Services.AddScoped<IStandortePageService, StandortePageService>();
builder.Services.AddScoped<IStandorteSapEditorService, StandorteSapEditorService>();
builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageService>();
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
@@ -0,0 +1,27 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services.DataSources;
public sealed class DataSourceAdapterResolver : IDataSourceAdapterResolver
{
private readonly Dictionary<string, IDataSourceAdapter> _adapters;
public DataSourceAdapterResolver(IEnumerable<IDataSourceAdapter> 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.");
}
}
@@ -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;
}
}
@@ -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<string>? UpdateStatus { get; init; }
}
@@ -0,0 +1,14 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services.DataSources;
public sealed class DataSourceFetchResult
{
public required List<SalesRecord> Records { get; init; }
/// <summary>
/// Wenn gesetzt, liefert der Adapter bereits eine Referenz-Datei (z. B. manueller Excel-Import).
/// SiteExportService erzeugt dann keine neue Excel-Datei.
/// </summary>
public string? ReferenceFilePath { get; init; }
}
@@ -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<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService;
private readonly IAppEventLogService _appEventLogService;
public HanaDataSourceAdapter(
IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService,
IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_appEventLogService = appEventLogService;
}
public string ConnectionKind => SourceSystemConnectionKinds.Hana;
public async Task<DataSourceFetchResult> 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<HanaServer> 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
};
}
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Services.DataSources;
public interface IDataSourceAdapter
{
/// <summary>
/// Der Wert aus <see cref="Models.SourceSystemConnectionKinds"/>, den dieser Adapter behandelt.
/// </summary>
string ConnectionKind { get; }
Task<DataSourceFetchResult> FetchAsync(DataSourceFetchContext context);
}
@@ -0,0 +1,6 @@
namespace TrafagSalesExporter.Services.DataSources;
public interface IDataSourceAdapterResolver
{
IDataSourceAdapter Resolve(string connectionKind);
}
@@ -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<DataSourceFetchResult> 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);
}
@@ -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<AppDbContext> _dbFactory;
private readonly ISapCompositionService _sapCompositionService;
private readonly IAppEventLogService _appEventLogService;
public SapGatewayDataSourceAdapter(
IDbContextFactory<AppDbContext> dbFactory,
ISapCompositionService sapCompositionService,
IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_sapCompositionService = sapCompositionService;
_appEventLogService = appEventLogService;
}
public string ConnectionKind => SourceSystemConnectionKinds.SapGateway;
public async Task<DataSourceFetchResult> 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
};
}
}
+72 -223
View File
@@ -2,45 +2,37 @@ using Microsoft.EntityFrameworkCore;
using System.Diagnostics; using System.Diagnostics;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services.DataSources;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class SiteExportService : ISiteExportService public class SiteExportService : ISiteExportService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService; private readonly IDataSourceAdapterResolver _dataSourceResolver;
private readonly ISapGatewayService _sapGatewayService;
private readonly ISapCompositionService _sapCompositionService;
private readonly IExcelExportService _excelService; private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService; private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService; private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService; private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IManualExcelImportService _manualExcelImportService;
private readonly IAppEventLogService _appEventLogService; private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger; private readonly ILogger<SiteExportService> _logger;
public SiteExportService( public SiteExportService(
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService, IDataSourceAdapterResolver dataSourceResolver,
ISapGatewayService sapGatewayService,
ISapCompositionService sapCompositionService,
IExcelExportService excelService, IExcelExportService excelService,
ISharePointUploadService sharePointService, ISharePointUploadService sharePointService,
IRecordTransformationService transformationService, IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService, ICentralSalesRecordService centralSalesRecordService,
IManualExcelImportService manualExcelImportService,
IAppEventLogService appEventLogService, IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger) ILogger<SiteExportService> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_hanaService = hanaService; _dataSourceResolver = dataSourceResolver;
_sapGatewayService = sapGatewayService;
_sapCompositionService = sapCompositionService;
_excelService = excelService; _excelService = excelService;
_sharePointService = sharePointService; _sharePointService = sharePointService;
_transformationService = transformationService; _transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService; _centralSalesRecordService = centralSalesRecordService;
_manualExcelImportService = manualExcelImportService;
_appEventLogService = appEventLogService; _appEventLogService = appEventLogService;
_logger = logger; _logger = logger;
} }
@@ -58,167 +50,63 @@ public class SiteExportService : ISiteExportService
try 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 sourceSystem = NormalizeSourceSystem(site.SourceSystem);
var sourceDefinition = await db.SourceSystemDefinitions await _appEventLogService.WriteAsync("Export", "Export gestartet",
.AsNoTracking() siteId: site.Id, land: site.Land,
.OrderBy(x => x.Id) details: $"Quelle={sourceSystem} | TSC={site.TSC}");
.FirstOrDefaultAsync(x => x.Code == sourceSystem)
?? throw new InvalidOperationException($"Quellsystem '{sourceSystem}' ist nicht konfiguriert.");
var records = new List<SalesRecord>();
string filePath;
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); Site = site,
var sapServiceUrl = ResolveSapServiceUrl(site, sourceDefinition); SourceDefinition = sourceDefinition,
if (string.IsNullOrWhiteSpace(sapServiceUrl)) Settings = settings,
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); SharePointConfig = spConfig,
var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync(); UpdateStatus = updateStatus
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 Quellen laden..."); var records = fetchResult.Records;
await _appEventLogService.WriteAsync("Export", "SAP Quellen laden", siteId: site.Id, land: site.Land,
details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}"); updateStatus?.Invoke("Transformationen anwenden...");
var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); await _appEventLogService.WriteAsync("Export", "Transformationen anwenden",
records = await _sapCompositionService.BuildSalesRecordsAsync(effectiveSite, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password); siteId: site.Id, land: site.Land,
updateStatus?.Invoke("Transformationen anwenden..."); details: $"Records vor Transformation={records.Count}");
await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, _transformationService.Apply(records, rules);
details: $"Records vor Transformation={records.Count}");
var rules = await db.FieldTransformationRules var filePath = fetchResult.ReferenceFilePath;
.Where(r => r.IsActive && r.SourceSystem == sourceSystem) if (string.IsNullOrWhiteSpace(filePath))
.OrderBy(r => r.SortOrder) {
.ToListAsync();
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen..."); 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}"); details: $"Records={records.Count}");
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); 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..."); log.RowCount = records.Count;
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;
}
updateStatus?.Invoke("Zentrale Tabelle aktualisieren..."); 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}"); details: $"Records={records.Count}");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus); await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus);
var fileName = Path.GetFileName(filePath); await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus);
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);
}
sw.Stop(); sw.Stop();
log.Status = "OK"; log.Status = "OK";
log.FileName = fileName; log.FileName = Path.GetFileName(filePath);
log.FilePath = filePath; log.FilePath = filePath;
log.DurationSeconds = sw.Elapsed.TotalSeconds; log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s", _logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds); site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
await _appEventLogService.WriteAsync("Export", "Export erfolgreich", siteId: site.Id, land: site.Land, await _appEventLogService.WriteAsync("Export", "Export erfolgreich",
details: $"Rows={log.RowCount} | Datei={fileName} | Pfad={filePath} | Dauer={sw.Elapsed.TotalSeconds:F1}s"); siteId: site.Id, land: site.Land,
details: $"Rows={log.RowCount} | Datei={log.FileName} | Pfad={filePath} | Dauer={sw.Elapsed.TotalSeconds:F1}s");
return new SiteExportResult return new SiteExportResult
{ {
@@ -237,8 +125,8 @@ public class SiteExportService : ISiteExportService
log.DurationSeconds = sw.Elapsed.TotalSeconds; log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC); _logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
await _appEventLogService.WriteAsync("Export", "Export fehlgeschlagen", "Error", siteId: site.Id, land: site.Land, await _appEventLogService.WriteAsync("Export", "Export fehlgeschlagen", "Error",
details: ex.ToString()); siteId: site.Id, land: site.Land, details: ex.ToString());
return new SiteExportResult return new SiteExportResult
{ {
@@ -249,90 +137,51 @@ public class SiteExportService : ISiteExportService
} }
} }
private static async Task<HanaServer> BuildEffectiveServerAsync(AppDbContext db, Site site, SourceSystemDefinition sourceDefinition) private async Task<(ExportSettings settings, SharePointConfig? spConfig, SourceSystemDefinition sourceDefinition, List<FieldTransformationRule> 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() .AsNoTracking()
.OrderBy(x => x.Id) .OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == sourceDefinition.Code); .FirstOrDefaultAsync(x => x.Code == sourceSystem)
?? throw new InvalidOperationException($"Quellsystem '{sourceSystem}' ist nicht konfiguriert.");
if (centralServer is null) var rules = await db.FieldTransformationRules
throw new InvalidOperationException($"Fuer Quellsystem '{sourceDefinition.Code}' ist keine zentrale HANA-Konfiguration vorhanden."); .Where(r => r.IsActive && r.SourceSystem == sourceSystem)
.OrderBy(r => r.SortOrder)
var credentials = ResolveCredentials(site, sourceDefinition); .ToListAsync();
return (settings, spConfig, sourceDefinition, rules);
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
};
} }
private static (string Username, string Password) ResolveCredentials(Site site, SourceSystemDefinition sourceDefinition) private async Task UploadToSharePointIfConfiguredAsync(
=> (FirstNonEmpty(site.UsernameOverride, sourceDefinition.CentralUsername), Site site, SharePointConfig? spConfig, string filePath, Action<string>? updateStatus)
FirstNonEmpty(site.PasswordOverride, sourceDefinition.CentralPassword)); {
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) updateStatus?.Invoke("SharePoint Upload...");
=> FirstNonEmpty(site.SapServiceUrl, sourceDefinition.CentralServiceUrl); 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) private static string NormalizeSourceSystem(string? sourceSystem)
=> string.IsNullOrWhiteSpace(sourceSystem) ? "SAP" : sourceSystem.Trim().ToUpperInvariant(); => 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) 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) return string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "output") ? Path.Combine(AppContext.BaseDirectory, "output")
: configured; : 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
};
}
} }