diverse Aenderungen

This commit is contained in:
2026-04-15 11:18:26 +02:00
parent 59e195af71
commit 90133cd0e2
29 changed files with 1651 additions and 77 deletions
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class AppEventLogService : IAppEventLogService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.DebugLoggingEnabled)
return;
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = "Debug",
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
}
@@ -1,3 +1,4 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
@@ -6,55 +7,50 @@ namespace TrafagSalesExporter.Services;
public class CentralSalesRecordService : ICentralSalesRecordService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private const int BatchSize = 25;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory)
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IAppEventLogService _appEventLogService;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_appEventLogService = appEventLogService;
}
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records)
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null)
{
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 recordList = records.ToList();
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
db.CentralSalesRecords.AddRange(records.Select(record => new CentralSalesRecord
await db.Database.OpenConnectionAsync();
var connection = (SqliteConnection)db.Database.GetDbConnection();
try
{
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
}));
updateStatus?.Invoke("Zentrale Tabelle: bestehende Saetze zaehlen...");
var existingCount = await CountExistingAsync(connection, site.Id);
await db.SaveChangesAsync();
if (existingCount > 0)
{
updateStatus?.Invoke("Zentrale Tabelle: alte Saetze loeschen...");
await DeleteExistingAsync(connection, site.Id);
}
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
await _appEventLogService.WriteAsync(
"Export",
"Zentrale Tabelle aktualisiert",
siteId: site.Id,
land: site.Land,
details: $"Geloescht={existingCount} | Neu={recordList.Count}");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
public async Task<List<SalesRecord>> GetAllAsync()
@@ -94,4 +90,147 @@ public class CentralSalesRecordService : ICentralSalesRecordService
})
.ToListAsync();
}
private static async Task<int> CountExistingAsync(SqliteConnection connection, int siteId)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(1) FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
var scalar = await command.ExecuteScalarAsync();
return scalar is null or DBNull ? 0 : Convert.ToInt32(scalar);
}
private static async Task DeleteExistingAsync(SqliteConnection connection, int siteId)
{
await using var transaction = connection.BeginTransaction();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
await command.ExecuteNonQueryAsync();
await transaction.CommitAsync();
}
private static async Task InsertRecordsInCommittedBatchesAsync(
SqliteConnection connection,
Site site,
IReadOnlyList<SalesRecord> records,
Action<string>? updateStatus)
{
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
var total = records.Count;
var totalBatches = Math.Max(1, (int)Math.Ceiling(total / (double)BatchSize));
var processed = 0;
for (var batchIndex = 0; batchIndex < totalBatches; batchIndex++)
{
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} speichern...");
await using var transaction = connection.BeginTransaction();
await using var command = CreateInsertCommand(connection, transaction);
var batchRecords = records
.Skip(batchIndex * BatchSize)
.Take(BatchSize);
foreach (var record in batchRecords)
{
SetInsertParameters(command, site, sourceSystem, record);
await command.ExecuteNonQueryAsync();
processed++;
}
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} abschliessen...");
await transaction.CommitAsync();
}
updateStatus?.Invoke($"Zentrale Tabelle: {processed} Datensaetze gespeichert.");
}
private static SqliteCommand CreateInsertCommand(SqliteConnection connection, SqliteTransaction transaction)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
)
VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
);
""";
command.Parameters.Add("$storedAtUtc", SqliteType.Text);
command.Parameters.Add("$siteId", SqliteType.Integer);
command.Parameters.Add("$sourceSystem", SqliteType.Text);
command.Parameters.Add("$extractionDate", SqliteType.Text);
command.Parameters.Add("$tsc", SqliteType.Text);
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
command.Parameters.Add("$material", SqliteType.Text);
command.Parameters.Add("$name", SqliteType.Text);
command.Parameters.Add("$productGroup", SqliteType.Text);
command.Parameters.Add("$quantity", SqliteType.Real);
command.Parameters.Add("$supplierNumber", SqliteType.Text);
command.Parameters.Add("$supplierName", SqliteType.Text);
command.Parameters.Add("$supplierCountry", SqliteType.Text);
command.Parameters.Add("$customerNumber", SqliteType.Text);
command.Parameters.Add("$customerName", SqliteType.Text);
command.Parameters.Add("$customerCountry", SqliteType.Text);
command.Parameters.Add("$customerIndustry", SqliteType.Text);
command.Parameters.Add("$standardCost", SqliteType.Real);
command.Parameters.Add("$standardCostCurrency", SqliteType.Text);
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
command.Parameters.Add("$salesPriceValue", SqliteType.Real);
command.Parameters.Add("$salesCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text);
command.Parameters.Add("$documentType", SqliteType.Text);
return command;
}
private static void SetInsertParameters(SqliteCommand command, Site site, string sourceSystem, SalesRecord record)
{
command.Parameters["$storedAtUtc"].Value = DateTime.UtcNow.ToString("O");
command.Parameters["$siteId"].Value = site.Id;
command.Parameters["$sourceSystem"].Value = sourceSystem;
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
command.Parameters["$material"].Value = record.Material ?? string.Empty;
command.Parameters["$name"].Value = record.Name ?? string.Empty;
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
command.Parameters["$quantity"].Value = record.Quantity;
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
command.Parameters["$supplierCountry"].Value = record.SupplierCountry ?? string.Empty;
command.Parameters["$customerNumber"].Value = record.CustomerNumber ?? string.Empty;
command.Parameters["$customerName"].Value = record.CustomerName ?? string.Empty;
command.Parameters["$customerCountry"].Value = record.CustomerCountry ?? string.Empty;
command.Parameters["$customerIndustry"].Value = record.CustomerIndustry ?? string.Empty;
command.Parameters["$standardCost"].Value = record.StandardCost;
command.Parameters["$standardCostCurrency"].Value = record.StandardCostCurrency ?? string.Empty;
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty;
command.Parameters["$documentType"].Value = record.DocumentType ?? string.Empty;
}
}
@@ -47,6 +47,9 @@ public class ConfigTransferService : IConfigTransferService
TimerHour = exportSettings.TimerHour,
TimerMinute = exportSettings.TimerMinute,
TimerEnabled = exportSettings.TimerEnabled,
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
SapUsername = includeSecrets ? exportSettings.SapUsername : null,
SapPassword = includeSecrets ? exportSettings.SapPassword : null,
Bi1Username = includeSecrets ? exportSettings.Bi1Username : null,
@@ -77,6 +80,7 @@ public class ConfigTransferService : IConfigTransferService
SourceSystem = site.SourceSystem,
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
LocalExportFolderOverride = site.LocalExportFolderOverride,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -190,6 +194,9 @@ public class ConfigTransferService : IConfigTransferService
TimerHour = importedSettings.TimerHour,
TimerMinute = importedSettings.TimerMinute,
TimerEnabled = importedSettings.TimerEnabled,
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
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,
@@ -234,6 +241,7 @@ public class ConfigTransferService : IConfigTransferService
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,
LocalExportFolderOverride = site.LocalExportFolderOverride,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -31,7 +31,8 @@ public class ConsolidatedExportService : IConsolidatedExportService
using var db = await _dbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var outputDir = ResolveConsolidatedOutputDirectory(settings);
var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
outputDir,
DateTime.UtcNow.Date,
@@ -55,4 +56,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
return consolidatedPath;
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
}
@@ -18,10 +18,30 @@ public class DatabaseInitializationService : IDatabaseInitializationService
{
using var db = await _dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
ConfigureSqlite(db);
EnsureSchema(db);
SeedIfEmpty(db);
}
private static void ConfigureSqlite(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using (var wal = conn.CreateCommand())
{
wal.CommandText = "PRAGMA journal_mode=WAL;";
wal.ExecuteNonQuery();
}
using (var timeout = conn.CreateCommand())
{
timeout.CommandText = "PRAGMA busy_timeout=10000;";
timeout.ExecuteNonQuery();
}
}
private static void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(db);
@@ -32,6 +52,7 @@ 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", "LocalExportFolderOverride", "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 ''");
@@ -42,11 +63,16 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "ExportSettings", "Bi1Password", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db);
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db);
EnsureAppEventLogTable(db);
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
@@ -100,6 +126,7 @@ CREATE TABLE Sites (
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
@@ -116,7 +143,7 @@ CREATE TABLE Sites (
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
@@ -124,6 +151,7 @@ SELECT
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''),
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
@@ -306,6 +334,28 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
cmd.ExecuteNonQuery();
}
private static void EnsureAppEventLogTable(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 AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
Level TEXT NOT NULL,
Category TEXT NOT NULL,
SiteId INTEGER NULL,
Land TEXT NOT NULL,
Message TEXT NOT NULL,
Details TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any())
@@ -337,7 +387,10 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
TimerEnabled = true,
DebugLoggingEnabled = false,
LocalSiteExportFolder = "",
LocalConsolidatedExportFolder = ""
});
db.SaveChanges();
@@ -60,12 +60,12 @@ public class ExportOrchestrationService
await _consolidatedExportService.ExportAsync(consolidatedRecords);
}
public async Task ExportSiteByIdAsync(int siteId)
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
if (site is null) return null;
return await ExportSiteAsync(site);
}
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
@@ -5,20 +5,48 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService : IHanaQueryService
{
private readonly IAppEventLogService _appEventLogService;
public HanaQueryService(IAppEventLogService appEventLogService)
{
_appEventLogService = appEventLogService;
}
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
try
{
_appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land,
details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
using var connection = new HanaConnection(connectionString);
connection.Open();
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
_appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land,
details: $"Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
_appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land, details: invoiceQuery).GetAwaiter().GetResult();
var invoiceRecords = ReadRecords(connection, invoiceQuery, land, "Invoice");
result.AddRange(invoiceRecords);
_appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}").GetAwaiter().GetResult();
_appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land, details: creditNoteQuery).GetAwaiter().GetResult();
var creditRecords = ReadRecords(connection, creditNoteQuery, land, "Credit");
result.AddRange(creditRecords);
_appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}").GetAwaiter().GetResult();
}
catch (Exception ex)
{
_appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString()).GetAwaiter().GetResult();
throw;
}
foreach (var record in result)
{
@@ -43,6 +71,8 @@ public class HanaQueryService : IHanaQueryService
try
{
_appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
@@ -53,6 +83,8 @@ public class HanaQueryService : IHanaQueryService
testResult.Success = true;
testResult.Stage = "OK";
_appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
return testResult;
}
catch (Exception ex)
@@ -60,6 +92,8 @@ public class HanaQueryService : IHanaQueryService
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
_appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error",
details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}").GetAwaiter().GetResult();
return testResult;
}
}
@@ -71,12 +105,13 @@ public class HanaQueryService : IHanaQueryService
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
{
var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
var counter = 0;
while (reader.Read())
{
@@ -109,6 +144,13 @@ public class HanaQueryService : IHanaQueryService
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
counter++;
if (counter % 250 == 0)
{
_appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land,
details: $"Bisher gelesene Zeilen={counter}").GetAwaiter().GetResult();
}
}
return records;
@@ -0,0 +1,7 @@
namespace TrafagSalesExporter.Services;
public interface IAppEventLogService
{
Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null);
Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null);
}
@@ -4,6 +4,6 @@ namespace TrafagSalesExporter.Services;
public interface ICentralSalesRecordService
{
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records);
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null);
Task<List<SalesRecord>> GetAllAsync();
}
@@ -0,0 +1,9 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IManagementCockpitService
{
Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
}
@@ -4,5 +4,6 @@ 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<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, 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,387 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ManagementCockpitService : IManagementCockpitService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var exportLogs = await db.ExportLogs
.Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath))
.OrderByDescending(x => x.Timestamp)
.Take(200)
.ToListAsync();
var files = new Dictionary<string, ManagementCockpitFileOption>(StringComparer.OrdinalIgnoreCase);
foreach (var log in exportLogs)
{
if (!File.Exists(log.FilePath))
continue;
files[log.FilePath] = new ManagementCockpitFileOption
{
Path = log.FilePath,
DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}",
LastModified = File.GetLastWriteTime(log.FilePath)
};
}
foreach (var directory in GetCandidateDirectories(settings))
{
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly))
{
if (files.ContainsKey(file))
continue;
var fileName = Path.GetFileName(file);
files[file] = new ManagementCockpitFileOption
{
Path = file,
DisplayName = fileName,
LastModified = File.GetLastWriteTime(file)
};
}
}
return files.Values
.OrderByDescending(x => x.LastModified)
.ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden.");
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.First();
var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
var headerRow = usedRange.FirstRow();
var headers = headerRow.Cells()
.Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) })
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var rows = new List<CockpitRow>();
foreach (var row in usedRange.RowsUsed().Skip(1))
{
if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
continue;
rows.Add(ReadRow(row, headers));
}
if (rows.Count == 0)
throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen.");
var result = new ManagementCockpitResult
{
FilePath = filePath,
Summary = BuildSummary(rows),
Findings = BuildFindings(rows),
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.SalesValueTotal),
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.SalesValueTotal),
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.SalesValueTotal),
DataQualityCounts = BuildDataQualityCounts(rows)
};
return Task.FromResult(result);
}
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
{
yield return Path.Combine(AppContext.BaseDirectory, "output");
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
yield return settings.LocalSiteExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
yield return settings.LocalConsolidatedExportFolder.Trim();
}
private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary<string, int> headers)
{
var quantity = GetDecimal(row, headers, "quantity");
var standardCost = GetDecimal(row, headers, "standardcost");
var salesValue = GetDecimal(row, headers, "salespricevalue");
var estimatedCostTotal = quantity > 0 ? quantity * standardCost : standardCost;
return new CockpitRow
{
ExtractionDate = GetDate(row, headers, "extractiondate"),
Tsc = GetText(row, headers, "tsc"),
InvoiceNumber = GetText(row, headers, "invoicenumber"),
PositionOnInvoice = GetText(row, headers, "positiononinvoice"),
Material = GetText(row, headers, "material"),
Name = GetText(row, headers, "name"),
ProductGroup = GetText(row, headers, "productgroup"),
Quantity = quantity,
SupplierNumber = GetText(row, headers, "suppliernumber"),
SupplierName = GetText(row, headers, "suppliername"),
SupplierCountry = GetText(row, headers, "suppliercountry"),
CustomerNumber = GetText(row, headers, "customernumber"),
CustomerName = GetText(row, headers, "customername"),
CustomerCountry = GetText(row, headers, "customercountry"),
CustomerIndustry = GetText(row, headers, "customerindustry"),
StandardCost = standardCost,
SalesValueTotal = salesValue,
Incoterms2020 = GetText(row, headers, "incoterms2020"),
SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"),
InvoiceDate = GetDate(row, headers, "invoicedate"),
OrderDate = GetDate(row, headers, "orderdate"),
Land = GetText(row, headers, "land"),
EstimatedCostTotal = estimatedCostTotal,
EstimatedMarginTotal = salesValue - estimatedCostTotal
};
}
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows)
{
var salesTotal = rows.Sum(x => x.SalesValueTotal);
var costTotal = rows.Sum(x => x.EstimatedCostTotal);
var marginTotal = rows.Sum(x => x.EstimatedMarginTotal);
var serviceRows = rows.Where(x =>
x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList();
return new ManagementCockpitSummary
{
Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue),
RowCount = rows.Count,
InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
SalesValueTotal = salesTotal,
EstimatedCostTotal = costTotal,
EstimatedMarginTotal = marginTotal,
EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m,
ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m,
MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count,
MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count
};
}
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows)
{
var findings = new List<ManagementCockpitFinding>();
var salesTotal = rows.Sum(x => x.SalesValueTotal);
var topCustomer = rows
.Where(x => !string.IsNullOrWhiteSpace(x.CustomerName))
.GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.SalesValueTotal) })
.OrderByDescending(x => x.Sales)
.FirstOrDefault();
if (topCustomer is not null && salesTotal > 0)
{
var share = topCustomer.Sales / salesTotal * 100m;
findings.Add(new ManagementCockpitFinding
{
Severity = share >= 50 ? "Warning" : "Info",
Title = "Kundenkonzentration",
Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes."
});
}
var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList();
if (zeroValueRows.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info",
Title = "Nullwerte in Kosten oder Umsatz",
Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden."
});
}
var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue);
if (missingOrderDates > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info",
Title = "Fehlende Durchlaufzeit",
Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar."
});
}
var orderLeadTimes = rows
.Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue)
.Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays)
.Where(x => x >= 0)
.ToList();
if (orderLeadTimes.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info",
Title = "Durchschnittliche Fakturierungszeit",
Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage."
});
}
var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry));
if (missingIndustries > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info",
Title = "Stammdatenlücke Customer Industry",
Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig."
});
}
var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020));
if (missingIncoterms > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info",
Title = "Incoterms unvollständig",
Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe."
});
}
if (findings.Count == 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = "Info",
Title = "Keine auffälligen Datenqualitätsprobleme",
Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug."
});
}
return findings;
}
private static List<ManagementCockpitTopItem> BuildTopItems(
List<CockpitRow> rows,
Func<CockpitRow, string> keySelector,
Func<CockpitRow, decimal> valueSelector)
{
var total = rows.Sum(valueSelector);
return rows
.Select(x => new { Label = keySelector(x), Value = valueSelector(x) })
.Where(x => !string.IsNullOrWhiteSpace(x.Label))
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTopItem
{
Label = g.Key,
Value = g.Sum(x => x.Value),
SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m
})
.OrderByDescending(x => x.Value)
.Take(5)
.ToList();
}
private static Dictionary<string, int> BuildDataQualityCounts(List<CockpitRow> rows)
{
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)),
["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)),
["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue),
["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue),
["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0)
};
}
private static string NormalizeHeader(string value)
{
var chars = value
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray();
return new string(chars);
}
private static string GetText(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
=> headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return 0m;
var text = row.Cell(index).GetFormattedString().Trim();
if (decimal.TryParse(text, out var direct))
return direct;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant))
return invariant;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local))
return local;
return 0m;
}
private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return null;
var cell = row.Cell(index);
if (cell.DataType == XLDataType.DateTime)
return cell.GetDateTime();
var text = cell.GetString().Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
if (DateTime.TryParse(text, out var direct))
return direct;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant))
return invariant;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local))
return local;
return null;
}
private class CockpitRow
{
public DateTime? ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string PositionOnInvoice { get; set; } = string.Empty;
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 decimal SalesValueTotal { get; set; }
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 decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
}
}
@@ -6,10 +6,12 @@ namespace TrafagSalesExporter.Services;
public class SapCompositionService : ISapCompositionService
{
private readonly ISapGatewayService _sapGatewayService;
private readonly IAppEventLogService _appEventLogService;
public SapCompositionService(ISapGatewayService sapGatewayService)
public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService)
{
_sapGatewayService = sapGatewayService;
_appEventLogService = appEventLogService;
}
public async Task<List<SalesRecord>> BuildSalesRecordsAsync(
@@ -36,25 +38,38 @@ public class SapCompositionService : ISapCompositionService
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet}");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken);
sourceRows[source.Alias] = rows;
await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
}
var composedRows = sourceRows[primarySource.Alias]
.Select(r => PrefixRow(primarySource.Alias, r))
.ToList();
await _appEventLogService.WriteDebugAsync("SAP", "Primärquelle vorbereitet", site.Id, site.Land,
$"Alias={primarySource.Alias} | Startzeilen={composedRows.Count}");
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;
await _appEventLogService.WriteDebugAsync("SAP", "Join gestartet", site.Id, site.Land,
$"{join.LeftAlias}({join.LeftKeys}) -> {join.RightAlias}({join.RightKeys}) | RightRows={rightRows.Count}");
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
await _appEventLogService.WriteDebugAsync("SAP", "Join beendet", site.Id, site.Land,
$"{join.LeftAlias} -> {join.RightAlias} | Ergebniszeilen={composedRows.Count}");
}
return composedRows
var result = composedRows
.Select(row => MapToSalesRecord(site, row, mappings))
.ToList();
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land,
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
return result;
}
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
@@ -9,30 +9,89 @@ 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";
private readonly IAppEventLogService _appEventLogService;
public SapGatewayService(IAppEventLogService appEventLogService)
{
_appEventLogService = appEventLogService;
}
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);
var baseUrl = BuildServiceUri(serviceUrl);
await _appEventLogService.WriteAsync("SAP", "Gateway-Verbindungstest gestartet", details: baseUrl);
using var response = await client.GetAsync(baseUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await _appEventLogService.WriteAsync("SAP", "Gateway-Verbindungstest erfolgreich", details: $"{baseUrl} | HTTP {(int)response.StatusCode}");
}
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);
await _appEventLogService.WriteAsync("SAP", "Entity-Set-Refresh gestartet", details: baseUrl);
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
if (entitySets.Count > 0)
{
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus Service-Root geladen", details: $"{baseUrl} | Count={entitySets.Count}");
return entitySets;
}
return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
var metadataEntitySets = await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus $metadata geladen", details: $"{baseUrl} | Count={metadataEntitySets.Count}");
return metadataEntitySets;
}
public async Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var baseUrl = BuildServiceUri(serviceUrl);
await _appEventLogService.WriteDebugAsync("SAP", "Feldliste aus $metadata laden", details: $"{baseUrl} | EntitySet={entitySet}");
using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
var entitySetElement = document
.Descendants()
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntitySet", StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Attribute("Name")?.Value, entitySet, StringComparison.OrdinalIgnoreCase));
var entityTypeFullName = entitySetElement?.Attribute("EntityType")?.Value;
if (string.IsNullOrWhiteSpace(entityTypeFullName))
return [];
var typeName = entityTypeFullName.Split('.').LastOrDefault();
if (string.IsNullOrWhiteSpace(typeName))
return [];
var entityTypeElement = document
.Descendants()
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntityType", StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Attribute("Name")?.Value, typeName, StringComparison.OrdinalIgnoreCase));
if (entityTypeElement is null)
return [];
return entityTypeElement
.Elements()
.Where(x => string.Equals(x.Name.LocalName, "Property", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Attribute("Name")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
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";
await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl);
using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -45,6 +104,7 @@ public class SapGatewayService : ISapGatewayService
return [];
var rows = new List<Dictionary<string, object?>>();
var counter = 0;
foreach (var item in resultsNode.EnumerateArray())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
@@ -54,8 +114,15 @@ public class SapGatewayService : ISapGatewayService
}
rows.Add(row);
counter++;
if (counter % 250 == 0)
{
await _appEventLogService.WriteDebugAsync("SAP", "Entity-Read liest Daten",
details: $"{requestUrl} | Bisher gelesene Zeilen={counter}");
}
}
await _appEventLogService.WriteAsync("SAP", "Entity-Read beendet", details: $"{requestUrl} | Zeilen={rows.Count}");
return rows;
}
@@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger;
public SiteExportService(
@@ -26,6 +27,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger)
{
_dbFactory = dbFactory;
@@ -36,6 +38,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService;
_transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_appEventLogService = appEventLogService;
_logger = logger;
}
@@ -52,10 +55,12 @@ 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 = Path.Combine(AppContext.BaseDirectory, "output");
var outputDir = ResolveSiteOutputDirectory(settings, site);
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
var records = new List<SalesRecord>();
string filePath;
@@ -74,14 +79,20 @@ public class SiteExportService : ISiteExportService
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
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}");
records = await _sapCompositionService.BuildSalesRecordsAsync(site, 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);
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;
}
@@ -89,10 +100,14 @@ public class SiteExportService : ISiteExportService
{
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
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)
@@ -100,12 +115,16 @@ public class SiteExportService : ISiteExportService
_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...");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records);
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);
@@ -115,6 +134,8 @@ public class SiteExportService : ISiteExportService
!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);
@@ -123,10 +144,13 @@ public class SiteExportService : ISiteExportService
sw.Stop();
log.Status = "OK";
log.FileName = fileName;
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");
return new SiteExportResult
{
@@ -141,9 +165,12 @@ public class SiteExportService : ISiteExportService
log.Status = "Error";
log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.FilePath = string.Empty;
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());
return new SiteExportResult
{
@@ -207,4 +234,12 @@ public class SiteExportService : ISiteExportService
return string.Empty;
}
private static string ResolveSiteOutputDirectory(ExportSettings settings, Site site)
{
var configured = FirstNonEmpty(site.LocalExportFolderOverride, settings.LocalSiteExportFolder);
return string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "output")
: configured;
}
}