zentraler export

This commit is contained in:
2026-04-15 14:47:32 +02:00
parent 7891dfb3dd
commit 264e64bbf5
13 changed files with 610 additions and 154 deletions
@@ -81,6 +81,8 @@ public class ConfigTransferService : IConfigTransferService
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -242,6 +244,8 @@ public class ConfigTransferService : IConfigTransferService
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,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -54,6 +54,8 @@ public class DatabaseInitializationService : IDatabaseInitializationService
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", "ManualImportFilePath", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "ManualImportLastUploadedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
@@ -128,6 +130,8 @@ CREATE TABLE Sites (
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
@@ -145,7 +149,7 @@ CREATE TABLE Sites (
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
ManualImportFilePath, ManualImportLastUploadedAtUtc, SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
Id, HanaServerId, Schema, TSC, Land,
@@ -153,6 +157,8 @@ SELECT
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''),
COALESCE(ManualImportFilePath, ''),
ManualImportLastUploadedAtUtc,
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
@@ -14,6 +14,8 @@ public class ExportOrchestrationService
public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new();
private bool _consolidatedExportRunning;
private string _consolidatedExportStatus = string.Empty;
private readonly object _lock = new();
public ExportOrchestrationService(
@@ -44,6 +46,22 @@ public class ExportOrchestrationService
}
}
public bool IsConsolidatedExporting()
{
lock (_lock)
{
return _consolidatedExportRunning;
}
}
public string GetConsolidatedExportStatus()
{
lock (_lock)
{
return _consolidatedExportStatus;
}
}
public async Task ExportAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
@@ -57,7 +75,12 @@ public class ExportOrchestrationService
consolidatedRecords.AddRange(result.Records);
}
await _consolidatedExportService.ExportAsync(consolidatedRecords);
await RunConsolidatedExportAsync(consolidatedRecords);
}
public async Task<string?> ExportConsolidatedOnlyAsync()
{
return await RunConsolidatedExportAsync(null);
}
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
@@ -112,4 +135,31 @@ public class ExportOrchestrationService
{
OnExportStatusChanged?.Invoke();
}
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records)
{
lock (_lock)
{
if (_consolidatedExportRunning)
return null;
_consolidatedExportRunning = true;
_consolidatedExportStatus = "Zentrale Datei erzeugen...";
}
NotifyChanged();
try
{
return await _consolidatedExportService.ExportAsync(records ?? []);
}
finally
{
lock (_lock)
{
_consolidatedExportRunning = false;
_consolidatedExportStatus = string.Empty;
}
NotifyChanged();
}
}
}
@@ -0,0 +1,8 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IManualExcelImportService
{
Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site);
}
@@ -0,0 +1,187 @@
using System.Globalization;
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ManualExcelImportService : IManualExcelImportService
{
private static readonly Dictionary<string, string> HeaderMap = new(StringComparer.OrdinalIgnoreCase)
{
["extractiondate"] = nameof(SalesRecord.ExtractionDate),
["tsc"] = nameof(SalesRecord.Tsc),
["invoicenumber"] = nameof(SalesRecord.InvoiceNumber),
["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice),
["material"] = nameof(SalesRecord.Material),
["name"] = nameof(SalesRecord.Name),
["productgroup"] = nameof(SalesRecord.ProductGroup),
["quantity"] = nameof(SalesRecord.Quantity),
["suppliernumber"] = nameof(SalesRecord.SupplierNumber),
["suppliername"] = nameof(SalesRecord.SupplierName),
["suppliercountry"] = nameof(SalesRecord.SupplierCountry),
["customernumber"] = nameof(SalesRecord.CustomerNumber),
["customername"] = nameof(SalesRecord.CustomerName),
["customercountry"] = nameof(SalesRecord.CustomerCountry),
["customerindustry"] = nameof(SalesRecord.CustomerIndustry),
["standardcost"] = nameof(SalesRecord.StandardCost),
["standardcostcurrency"] = nameof(SalesRecord.StandardCostCurrency),
["purchaseordernumber"] = nameof(SalesRecord.PurchaseOrderNumber),
["salespricevalue"] = nameof(SalesRecord.SalesPriceValue),
["salescurrency"] = nameof(SalesRecord.SalesCurrency),
["incoterms2020"] = nameof(SalesRecord.Incoterms2020),
["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee),
["invoicedate"] = nameof(SalesRecord.InvoiceDate),
["orderdate"] = nameof(SalesRecord.OrderDate),
["land"] = nameof(SalesRecord.Land),
["documenttype"] = nameof(SalesRecord.DocumentType)
};
public Task<List<SalesRecord>> ReadSalesRecordsAsync(string filePath, Site site)
{
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.FirstOrDefault()
?? throw new InvalidOperationException("Die Excel-Datei enthält kein Arbeitsblatt.");
var usedRange = worksheet.RangeUsed()
?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
var headerRow = usedRange.FirstRow();
var headerIndexes = BuildHeaderIndexMap(headerRow);
var rows = new List<SalesRecord>();
foreach (var row in usedRange.RowsUsed().Skip(1))
{
if (IsRowEmpty(row))
continue;
rows.Add(new SalesRecord
{
ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow,
Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC),
InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)),
PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))),
Material = ReadString(headerIndexes, row, nameof(SalesRecord.Material)),
Name = ReadString(headerIndexes, row, nameof(SalesRecord.Name)),
ProductGroup = ReadString(headerIndexes, row, nameof(SalesRecord.ProductGroup)),
Quantity = ReadDecimal(headerIndexes, row, nameof(SalesRecord.Quantity)),
SupplierNumber = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierNumber)),
SupplierName = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierName)),
SupplierCountry = ReadString(headerIndexes, row, nameof(SalesRecord.SupplierCountry)),
CustomerNumber = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerNumber)),
CustomerName = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerName)),
CustomerCountry = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerCountry)),
CustomerIndustry = ReadString(headerIndexes, row, nameof(SalesRecord.CustomerIndustry)),
StandardCost = ReadDecimal(headerIndexes, row, nameof(SalesRecord.StandardCost)),
StandardCostCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.StandardCostCurrency)),
PurchaseOrderNumber = ReadString(headerIndexes, row, nameof(SalesRecord.PurchaseOrderNumber)),
SalesPriceValue = ReadDecimal(headerIndexes, row, nameof(SalesRecord.SalesPriceValue)),
SalesCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.SalesCurrency)),
Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)),
InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)),
OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)),
Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land),
DocumentType = ReadString(headerIndexes, row, nameof(SalesRecord.DocumentType))
});
}
return Task.FromResult(rows);
}
private static Dictionary<string, int> BuildHeaderIndexMap(IXLRangeRow headerRow)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var cell in headerRow.CellsUsed())
{
var normalizedHeader = NormalizeHeader(cell.GetString());
if (string.IsNullOrWhiteSpace(normalizedHeader))
continue;
if (HeaderMap.TryGetValue(normalizedHeader, out var targetField))
result[targetField] = cell.Address.ColumnNumber;
}
if (!result.ContainsKey(nameof(SalesRecord.InvoiceNumber)))
throw new InvalidOperationException("Die Excel-Datei hat nicht das erwartete Exportformat. Spalte 'Invoice Number' fehlt.");
return result;
}
private static bool IsRowEmpty(IXLRangeRow row)
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
private static string ReadString(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName, string fallback = "")
{
if (!headerIndexes.TryGetValue(fieldName, out var index))
return fallback;
var value = row.Cell(index).GetFormattedString().Trim();
return string.IsNullOrWhiteSpace(value) ? fallback : value;
}
private static decimal ReadDecimal(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName)
{
if (!headerIndexes.TryGetValue(fieldName, out var index))
return 0m;
var cell = row.Cell(index);
if (cell.TryGetValue<decimal>(out var decimalValue))
return decimalValue;
if (cell.TryGetValue<double>(out var doubleValue))
return Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture);
var text = cell.GetFormattedString().Trim();
if (string.IsNullOrWhiteSpace(text))
return 0m;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out decimalValue))
return decimalValue;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out decimalValue))
return decimalValue;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-DE"), out decimalValue))
return decimalValue;
return 0m;
}
private static DateTime? ReadDate(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName)
{
if (!headerIndexes.TryGetValue(fieldName, out var index))
return null;
var cell = row.Cell(index);
if (cell.TryGetValue<DateTime>(out var dateValue))
return dateValue;
var text = cell.GetFormattedString().Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
var formats = new[]
{
"dd.MM.yyyy HH:mm:ss",
"dd.MM.yyyy",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd",
"O"
};
if (DateTime.TryParseExact(text, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dateValue))
return dateValue;
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out dateValue))
return dateValue;
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.AssumeLocal, out dateValue))
return dateValue;
return null;
}
private static string NormalizeHeader(string value)
{
var chars = value
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray();
return new string(chars);
}
}
@@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IManualExcelImportService _manualExcelImportService;
private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger;
@@ -27,6 +28,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
IManualExcelImportService manualExcelImportService,
IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger)
{
@@ -38,6 +40,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService;
_transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_manualExcelImportService = manualExcelImportService;
_appEventLogService = appEventLogService;
_logger = logger;
}
@@ -96,6 +99,30 @@ public class SiteExportService : ISiteExportService
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count;
}
else if (sourceSystem == "MANUAL_EXCEL")
{
if (string.IsNullOrWhiteSpace(site.ManualImportFilePath))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei.");
if (!File.Exists(site.ManualImportFilePath))
throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {site.ManualImportFilePath}");
updateStatus?.Invoke("Manuelle Excel lesen...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land,
details: site.ManualImportFilePath);
records = await _manualExcelImportService.ReadSalesRecordsAsync(site.ManualImportFilePath, site);
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);
filePath = site.ManualImportFilePath;
log.RowCount = records.Count;
}
else
{
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);