This commit is contained in:
2026-04-14 10:54:52 +02:00
parent df90a4a172
commit 36a22202bf
14 changed files with 678 additions and 78 deletions
@@ -24,6 +24,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
private static void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(db);
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
@@ -31,6 +32,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "ExportSettings", "SapUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SapPassword", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "Bi1Username", "TEXT NOT NULL DEFAULT ''");
@@ -40,6 +45,104 @@ public class DatabaseInitializationService : IDatabaseInitializationService
EnsureTransformationTable(db);
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var hanaServerIdIsRequired = false;
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA table_info(Sites)";
using var reader = pragma.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
{
hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
break;
}
}
}
if (!hanaServerIdIsRequired)
return;
using var disableFk = conn.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = conn.BeginTransaction();
using (var rename = conn.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
rename.ExecuteNonQuery();
}
using (var create = conn.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = @"
CREATE TABLE Sites (
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
HanaServerId INTEGER NULL,
Schema TEXT NOT NULL,
TSC TEXT NOT NULL,
Land TEXT NOT NULL,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
SapEntitySetsRefreshedAtUtc TEXT NULL,
IsActive INTEGER NOT NULL,
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
);";
create.ExecuteNonQuery();
}
using (var copy = conn.CreateCommand())
{
copy.Transaction = transaction;
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
Id, HanaServerId, Schema, TSC, Land,
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
SapEntitySetsRefreshedAtUtc,
IsActive
FROM Sites_old;";
copy.ExecuteNonQuery();
}
using (var drop = conn.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = "DROP TABLE Sites_old;";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = conn.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
@@ -23,6 +23,16 @@ public class ExcelExportService : IExcelExportService
return fullPath;
}
public string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
Directory.CreateDirectory(outputDirectory);
var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "Export" : filePrefix.Trim();
var fileName = $"{safePrefix}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteGenericWorkbook(fullPath, worksheetName, rows);
return fullPath;
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records)
{
using var workbook = new XLWorkbook();
@@ -99,4 +109,35 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
}
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
using var workbook = new XLWorkbook();
var sheetName = string.IsNullOrWhiteSpace(worksheetName) ? "Export" : worksheetName.Trim();
var ws = workbook.Worksheets.Add(sheetName.Length > 31 ? sheetName[..31] : sheetName);
var headers = rows
.SelectMany(r => r.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
for (var i = 0; i < headers.Count; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
for (var colIndex = 0; colIndex < headers.Count; colIndex++)
{
row.TryGetValue(headers[colIndex], out var value);
ws.Cell(rowIndex + 2, colIndex + 1).Value = value?.ToString() ?? string.Empty;
}
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
}
}
@@ -70,7 +70,6 @@ public class ExportOrchestrationService
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return null;
SiteExportResult? result = null;
lock (_lock)
@@ -6,4 +6,5 @@ public interface IExcelExportService
{
string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records);
string CreateConsolidatedExcelFile(string outputDirectory, DateTime fileDate, List<SalesRecord> records);
string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows);
}
@@ -0,0 +1,8 @@
namespace TrafagSalesExporter.Services;
public interface ISapGatewayService
{
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,140 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
namespace TrafagSalesExporter.Services;
public class SapGatewayService : ISapGatewayService
{
private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
private static readonly XNamespace EdmNs = "http://docs.oasis-open.org/odata/ns/edm";
public async Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
using var response = await client.GetAsync(BuildServiceUri(serviceUrl), cancellationToken);
response.EnsureSuccessStatusCode();
}
public async Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var baseUrl = BuildServiceUri(serviceUrl);
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
if (entitySets.Count > 0)
return entitySets;
return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
}
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("d", out var dNode))
return [];
if (!dNode.TryGetProperty("results", out var resultsNode) || resultsNode.ValueKind != JsonValueKind.Array)
return [];
var rows = new List<Dictionary<string, object?>>();
foreach (var item in resultsNode.EnumerateArray())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in item.EnumerateObject())
{
row[property.Name] = ConvertJsonValue(property.Value);
}
rows.Add(row);
}
return rows;
}
private static HttpClient CreateClient(string username, string password)
{
var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(15);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/atomsvc+xml"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
return client;
}
private static string BuildServiceUri(string serviceUrl)
{
var trimmed = serviceUrl.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
throw new InvalidOperationException("SAP Service URL darf nicht leer sein.");
var entityPathMarker = "/sap/opu/odata/sap/";
var markerIndex = trimmed.IndexOf(entityPathMarker, StringComparison.OrdinalIgnoreCase);
if (markerIndex >= 0)
{
var servicePath = trimmed[(markerIndex + entityPathMarker.Length)..].Trim('/');
var parts = servicePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1)
{
trimmed = $"{trimmed[..(markerIndex + entityPathMarker.Length)]}{parts[0]}/";
}
}
return trimmed.EndsWith('/') ? trimmed : $"{trimmed}/";
}
private static async Task<List<string>> TryReadEntitySetsFromServiceRootAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(baseUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
return document
.Descendants(AppNs + "collection")
.Select(x => x.Attribute("href")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<List<string>> ReadEntitySetsFromMetadataAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
{
using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
return document
.Descendants(EdmNs + "EntitySet")
.Select(x => x.Attribute("Name")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static object? ConvertJsonValue(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.ToString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.ToString()
};
}
@@ -9,6 +9,7 @@ public class SiteExportService : ISiteExportService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService;
private readonly ISapGatewayService _sapGatewayService;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
@@ -17,6 +18,7 @@ public class SiteExportService : ISiteExportService
public SiteExportService(
IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService,
ISapGatewayService sapGatewayService,
IExcelExportService excelService,
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
@@ -24,6 +26,7 @@ public class SiteExportService : ISiteExportService
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_sapGatewayService = sapGatewayService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
@@ -32,9 +35,6 @@ public class SiteExportService : ISiteExportService
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null)
{
if (site.HanaServer is null)
throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
@@ -49,22 +49,44 @@ public class SiteExportService : ISiteExportService
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportServer = BuildEffectiveServer(site, settings);
updateStatus?.Invoke("HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
updateStatus?.Invoke("Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
var records = new List<SalesRecord>();
string filePath;
if (sourceSystem == "SAP")
{
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.");
updateStatus?.Invoke("SAP Gateway Abfrage...");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, site.SapEntitySet, credentials.Username, credentials.Password);
updateStatus?.Invoke("Excel erstellen...");
filePath = _excelService.CreateGenericExcelFile(outputDir, $"SAP_{site.TSC}_{site.SapEntitySet}", DateTime.UtcNow.Date, site.SapEntitySet, rows);
log.RowCount = rows.Count;
}
else
{
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
updateStatus?.Invoke("HANA Abfrage...");
records = await Task.Run(() => _hanaService.GetSalesRecords(
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
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.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count;
}
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
@@ -80,12 +102,11 @@ public class SiteExportService : ISiteExportService
sw.Stop();
log.Status = "OK";
log.RowCount = records.Count;
log.FileName = fileName;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
return new SiteExportResult
{
@@ -113,14 +134,12 @@ public class SiteExportService : ISiteExportService
}
}
private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings)
private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings, string sourceSystem)
{
if (site.HanaServer is null)
throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem.Trim().ToUpperInvariant();
var inheritedUsername = GetCentralUsername(sourceSystem, settings);
var inheritedPassword = GetCentralPassword(sourceSystem, settings);
var credentials = ResolveCredentials(site, settings, sourceSystem);
return new HanaServer
{
@@ -128,8 +147,8 @@ public class SiteExportService : ISiteExportService
Name = site.HanaServer.Name,
Host = site.HanaServer.Host,
Port = site.HanaServer.Port,
Username = FirstNonEmpty(site.UsernameOverride, inheritedUsername, site.HanaServer.Username),
Password = FirstNonEmpty(site.PasswordOverride, inheritedPassword, site.HanaServer.Password),
Username = FirstNonEmpty(credentials.Username, site.HanaServer.Username),
Password = FirstNonEmpty(credentials.Password, site.HanaServer.Password),
DatabaseName = site.HanaServer.DatabaseName,
UseSsl = site.HanaServer.UseSsl,
ValidateCertificate = site.HanaServer.ValidateCertificate,
@@ -137,6 +156,10 @@ public class SiteExportService : ISiteExportService
};
}
private static (string Username, string Password) ResolveCredentials(Site site, ExportSettings settings, string sourceSystem)
=> (FirstNonEmpty(site.UsernameOverride, GetCentralUsername(sourceSystem, settings)),
FirstNonEmpty(site.PasswordOverride, GetCentralPassword(sourceSystem, settings)));
private static string GetCentralUsername(string sourceSystem, ExportSettings settings) => sourceSystem switch
{
"BI1" => settings.Bi1Username,
@@ -151,6 +174,9 @@ public class SiteExportService : ISiteExportService
_ => settings.SapPassword
};
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)