Add audit CSV central source option

This commit is contained in:
2026-06-11 08:57:18 +02:00
parent f23fa1662e
commit dcd845d337
22 changed files with 822 additions and 89 deletions
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ICentralSalesDataProvider
{
Task<List<SalesRecord>> GetRecordsAsync();
Task<bool> UsesAuditCsvAsync();
}
public sealed class CentralSalesDataProvider : ICentralSalesDataProvider
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExportAuditCsvService _auditCsvService;
public CentralSalesDataProvider(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
IExportAuditCsvService auditCsvService)
{
_dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_auditCsvService = auditCsvService;
}
public async Task<List<SalesRecord>> GetRecordsAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
if (!settings.UseAuditCsvAsCentralSource)
return await _centralSalesRecordService.GetAllAsync();
var records = await _auditCsvService.ReadLatestSiteAuditCsvRecordsAsync(settings);
if (records.Count == 0)
{
var directory = _auditCsvService.ResolveAuditCsvDirectory(settings);
throw new InvalidOperationException(
$"Audit-CSV ist als zentrale Quelle aktiv, aber im Ordner '{directory}' wurden keine Sales_*.csv-Dateien gefunden.");
}
return records
.OrderBy(r => r.Land)
.ThenBy(r => r.Tsc)
.ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue)
.ThenBy(r => r.InvoiceNumber)
.ThenBy(r => r.PositionOnInvoice)
.ToList();
}
public async Task<bool> UsesAuditCsvAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
return settings.UseAuditCsvAsCentralSource;
}
}
@@ -62,6 +62,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
.ThenBy(r => r.Tsc)
.Select(r => new SalesRecord
{
SourceSystem = r.SourceSystem,
ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
@@ -71,6 +71,9 @@ public class ConfigTransferService : IConfigTransferService
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
AuditCsvEnabled = exportSettings.AuditCsvEnabled,
UseAuditCsvAsCentralSource = exportSettings.UseAuditCsvAsCentralSource,
LocalAuditCsvFolder = exportSettings.LocalAuditCsvFolder,
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(exportSettings.ExchangeRateDateField)
},
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
@@ -285,6 +288,9 @@ public class ConfigTransferService : IConfigTransferService
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
AuditCsvEnabled = importedSettings.AuditCsvEnabled,
UseAuditCsvAsCentralSource = importedSettings.UseAuditCsvAsCentralSource,
LocalAuditCsvFolder = importedSettings.LocalAuditCsvFolder,
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(importedSettings.ExchangeRateDateField)
});
@@ -7,25 +7,25 @@ namespace TrafagSalesExporter.Services;
public class ConsolidatedExportService : IConsolidatedExportService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly ICentralSalesDataProvider _centralSalesDataProvider;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
public ConsolidatedExportService(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
ICentralSalesDataProvider centralSalesDataProvider,
IExcelExportService excelService,
ISharePointUploadService sharePointService)
{
_dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_centralSalesDataProvider = centralSalesDataProvider;
_excelService = excelService;
_sharePointService = sharePointService;
}
public async Task<string?> ExportAsync()
{
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
var consolidatedRecords = await _centralSalesDataProvider.GetRecordsAsync();
if (consolidatedRecords.Count == 0)
return null;
@@ -28,6 +28,9 @@ CREATE TABLE ExportSettings (
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT '',
AuditCsvEnabled INTEGER NOT NULL DEFAULT 1,
UseAuditCsvAsCentralSource INTEGER NOT NULL DEFAULT 0,
LocalAuditCsvFolder TEXT NOT NULL DEFAULT '',
ExchangeRateDateField TEXT NOT NULL DEFAULT 'PostingDate'
);";
@@ -29,6 +29,9 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
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, "ExportSettings", "AuditCsvEnabled", "INTEGER NOT NULL DEFAULT 1");
AddColumnIfMissing(db, "ExportSettings", "UseAuditCsvAsCentralSource", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalAuditCsvFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "ExchangeRateDateField", "TEXT NOT NULL DEFAULT 'PostingDate'");
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
@@ -0,0 +1,376 @@
using System.Globalization;
using System.Text;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IExportAuditCsvService
{
Task<string?> WriteSiteAuditCsvAsync(
Site site,
ExportSettings settings,
string sourceSystem,
string fallbackOutputDirectory,
IReadOnlyList<SalesRecord> records);
Task<List<SalesRecord>> ReadLatestSiteAuditCsvRecordsAsync(ExportSettings settings);
string ResolveAuditCsvDirectory(ExportSettings settings, string? fallbackOutputDirectory = null);
}
public sealed class ExportAuditCsvService : IExportAuditCsvService
{
private const char Delimiter = ';';
private static readonly string[] Headers =
[
"SourceSystem",
"ExtractionDate",
"TSC",
"SourceLineId",
"DocumentEntry",
"InvoiceNumber",
"PositionOnInvoice",
"Material",
"Name",
"ProductGroup",
"ProductHierarchyCode",
"ProductHierarchyText",
"ProductFamilyCode",
"ProductFamilyText",
"ProductDivisionCode",
"ProductDivisionText",
"ProductMappingAssigned",
"Quantity",
"SupplierNumber",
"SupplierName",
"SupplierCountry",
"CustomerNumber",
"CustomerName",
"CustomerCountry",
"CustomerIndustry",
"StandardCost",
"StandardCostCurrency",
"PurchaseOrderNumber",
"SalesPriceValue",
"SalesCurrency",
"DocumentCurrency",
"DocumentTotalForeignCurrency",
"DocumentTotalLocalCurrency",
"VatSumForeignCurrency",
"VatSumLocalCurrency",
"DocumentRate",
"CompanyCurrency",
"Incoterms2020",
"SalesResponsibleEmployee",
"PostingDate",
"InvoiceDate",
"OrderDate",
"Land",
"DocumentType"
];
public async Task<string?> WriteSiteAuditCsvAsync(
Site site,
ExportSettings settings,
string sourceSystem,
string fallbackOutputDirectory,
IReadOnlyList<SalesRecord> records)
{
if (!settings.AuditCsvEnabled)
return null;
var directory = ResolveAuditCsvDirectory(settings, fallbackOutputDirectory);
Directory.CreateDirectory(directory);
var tsc = string.IsNullOrWhiteSpace(site.TSC) ? "UNKNOWN" : site.TSC.Trim();
var fileName = $"Sales_{SanitizeFileNamePart(tsc)}_{DateTime.UtcNow:yyyy-MM-dd}.csv";
var path = Path.Combine(directory, fileName);
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
await writer.WriteLineAsync(string.Join(Delimiter, Headers.Select(Escape)));
foreach (var record in records)
{
await writer.WriteLineAsync(string.Join(Delimiter, BuildRow(site, sourceSystem, record).Select(Escape)));
}
return path;
}
public async Task<List<SalesRecord>> ReadLatestSiteAuditCsvRecordsAsync(ExportSettings settings)
{
var directory = ResolveAuditCsvDirectory(settings);
if (!Directory.Exists(directory))
return [];
var latestFiles = Directory.EnumerateFiles(directory, "Sales_*.csv", SearchOption.TopDirectoryOnly)
.GroupBy(ResolveTscFromFileName, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(File.GetLastWriteTimeUtc)
.ThenByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
.First())
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var records = new List<SalesRecord>();
foreach (var file in latestFiles)
records.AddRange(await ReadFileAsync(file));
return records;
}
public string ResolveAuditCsvDirectory(ExportSettings settings, string? fallbackOutputDirectory = null)
{
if (!string.IsNullOrWhiteSpace(settings.LocalAuditCsvFolder))
return settings.LocalAuditCsvFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return Path.Combine(settings.LocalSiteExportFolder.Trim(), "audit-csv");
return Path.Combine(AppContext.BaseDirectory, "output", "audit-csv");
}
private static IEnumerable<string> BuildRow(Site site, string sourceSystem, SalesRecord record)
{
yield return string.IsNullOrWhiteSpace(record.SourceSystem) ? sourceSystem : record.SourceSystem;
yield return FormatDate(record.ExtractionDate);
yield return record.Tsc;
yield return record.SourceLineId;
yield return FormatInt(record.DocumentEntry);
yield return record.InvoiceNumber;
yield return FormatInt(record.PositionOnInvoice);
yield return record.Material;
yield return record.Name;
yield return record.ProductGroup;
yield return record.ProductHierarchyCode;
yield return record.ProductHierarchyText;
yield return record.ProductFamilyCode;
yield return record.ProductFamilyText;
yield return record.ProductDivisionCode;
yield return record.ProductDivisionText;
yield return record.ProductMappingAssigned;
yield return FormatDecimal(record.Quantity);
yield return record.SupplierNumber;
yield return record.SupplierName;
yield return record.SupplierCountry;
yield return record.CustomerNumber;
yield return record.CustomerName;
yield return record.CustomerCountry;
yield return record.CustomerIndustry;
yield return FormatDecimal(record.StandardCost);
yield return record.StandardCostCurrency;
yield return record.PurchaseOrderNumber;
yield return FormatDecimal(record.SalesPriceValue);
yield return record.SalesCurrency;
yield return record.DocumentCurrency;
yield return FormatDecimal(record.DocumentTotalForeignCurrency);
yield return FormatDecimal(record.DocumentTotalLocalCurrency);
yield return FormatDecimal(record.VatSumForeignCurrency);
yield return FormatDecimal(record.VatSumLocalCurrency);
yield return FormatDecimal(record.DocumentRate);
yield return record.CompanyCurrency;
yield return record.Incoterms2020;
yield return record.SalesResponsibleEmployee;
yield return FormatNullableDate(record.PostingDate);
yield return FormatNullableDate(record.InvoiceDate);
yield return FormatNullableDate(record.OrderDate);
yield return string.IsNullOrWhiteSpace(record.Land) ? site.Land : record.Land;
yield return record.DocumentType;
}
private static async Task<List<SalesRecord>> ReadFileAsync(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var headerLine = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(headerLine))
return [];
var headers = ParseLine(headerLine)
.Select((value, index) => new { Header = NormalizeHeader(value), Index = index })
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var records = new List<SalesRecord>();
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line))
continue;
var values = ParseLine(line);
records.Add(new SalesRecord
{
SourceSystem = GetText(values, headers, "SourceSystem"),
ExtractionDate = GetDate(values, headers, "ExtractionDate") ?? File.GetLastWriteTime(path),
Tsc = GetText(values, headers, "TSC"),
SourceLineId = GetText(values, headers, "SourceLineId"),
DocumentEntry = GetInt(values, headers, "DocumentEntry"),
InvoiceNumber = GetText(values, headers, "InvoiceNumber"),
PositionOnInvoice = GetInt(values, headers, "PositionOnInvoice"),
Material = GetText(values, headers, "Material"),
Name = GetText(values, headers, "Name"),
ProductGroup = GetText(values, headers, "ProductGroup"),
ProductHierarchyCode = GetText(values, headers, "ProductHierarchyCode"),
ProductHierarchyText = GetText(values, headers, "ProductHierarchyText"),
ProductFamilyCode = GetText(values, headers, "ProductFamilyCode"),
ProductFamilyText = GetText(values, headers, "ProductFamilyText"),
ProductDivisionCode = GetText(values, headers, "ProductDivisionCode"),
ProductDivisionText = GetText(values, headers, "ProductDivisionText"),
ProductMappingAssigned = GetText(values, headers, "ProductMappingAssigned"),
Quantity = GetDecimal(values, headers, "Quantity"),
SupplierNumber = GetText(values, headers, "SupplierNumber"),
SupplierName = GetText(values, headers, "SupplierName"),
SupplierCountry = GetText(values, headers, "SupplierCountry"),
CustomerNumber = GetText(values, headers, "CustomerNumber"),
CustomerName = GetText(values, headers, "CustomerName"),
CustomerCountry = GetText(values, headers, "CustomerCountry"),
CustomerIndustry = GetText(values, headers, "CustomerIndustry"),
StandardCost = GetDecimal(values, headers, "StandardCost"),
StandardCostCurrency = GetText(values, headers, "StandardCostCurrency"),
PurchaseOrderNumber = GetText(values, headers, "PurchaseOrderNumber"),
SalesPriceValue = GetDecimal(values, headers, "SalesPriceValue"),
SalesCurrency = GetText(values, headers, "SalesCurrency"),
DocumentCurrency = GetText(values, headers, "DocumentCurrency"),
DocumentTotalForeignCurrency = GetDecimal(values, headers, "DocumentTotalForeignCurrency"),
DocumentTotalLocalCurrency = GetDecimal(values, headers, "DocumentTotalLocalCurrency"),
VatSumForeignCurrency = GetDecimal(values, headers, "VatSumForeignCurrency"),
VatSumLocalCurrency = GetDecimal(values, headers, "VatSumLocalCurrency"),
DocumentRate = GetDecimal(values, headers, "DocumentRate"),
CompanyCurrency = GetText(values, headers, "CompanyCurrency"),
Incoterms2020 = GetText(values, headers, "Incoterms2020"),
SalesResponsibleEmployee = GetText(values, headers, "SalesResponsibleEmployee"),
PostingDate = GetDate(values, headers, "PostingDate"),
InvoiceDate = GetDate(values, headers, "InvoiceDate"),
OrderDate = GetDate(values, headers, "OrderDate"),
Land = GetText(values, headers, "Land"),
DocumentType = GetText(values, headers, "DocumentType")
});
}
return records;
}
private static string ResolveTscFromFileName(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
if (!name.StartsWith("Sales_", StringComparison.OrdinalIgnoreCase))
return name;
var withoutPrefix = name["Sales_".Length..];
var lastUnderscore = withoutPrefix.LastIndexOf('_');
return lastUnderscore <= 0 ? withoutPrefix : withoutPrefix[..lastUnderscore];
}
private static string SanitizeFileNamePart(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
return new string(chars);
}
private static string Escape(string? value)
{
var text = (value ?? string.Empty)
.Replace("\r\n", " ", StringComparison.Ordinal)
.Replace('\r', ' ')
.Replace('\n', ' ');
if (text.Contains(Delimiter) || text.Contains('"') || text.Contains('\r') || text.Contains('\n'))
return $"\"{text.Replace("\"", "\"\"")}\"";
return text;
}
private static List<string> ParseLine(string line)
{
var values = new List<string>();
var current = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (ch == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
current.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (ch == Delimiter && !inQuotes)
{
values.Add(current.ToString());
current.Clear();
continue;
}
current.Append(ch);
}
values.Add(current.ToString());
return values;
}
private static string NormalizeHeader(string value)
=> new(value.Where(char.IsLetterOrDigit).ToArray());
private static string GetText(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
=> headers.TryGetValue(NormalizeHeader(header), out var index) && index >= 0 && index < values.Count
? values[index].Trim()
: string.Empty;
private static int GetInt(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
=> int.TryParse(GetText(values, headers, header), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: 0;
private static decimal GetDecimal(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
{
var text = GetText(values, headers, header);
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var invariant))
return invariant;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out var swiss))
return swiss;
return 0m;
}
private static DateTime? GetDate(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
{
var text = GetText(values, headers, header);
if (string.IsNullOrWhiteSpace(text))
return null;
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var roundtrip))
return roundtrip;
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out var swiss))
return swiss;
return null;
}
private static string FormatInt(int value)
=> value.ToString(CultureInfo.InvariantCulture);
private static string FormatDecimal(decimal value)
=> value.ToString(CultureInfo.InvariantCulture);
private static string FormatDate(DateTime value)
=> value.ToString("O", CultureInfo.InvariantCulture);
private static string FormatNullableDate(DateTime? value)
=> value?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty;
}
@@ -12,10 +12,19 @@ public interface IFinanceReconciliationService
public sealed class FinanceReconciliationService : IFinanceReconciliationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
: this(dbFactory, null)
{
}
public FinanceReconciliationService(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesDataProvider? centralSalesDataProvider)
{
_dbFactory = dbFactory;
_centralSalesDataProvider = centralSalesDataProvider;
}
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
@@ -41,35 +50,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var centralRecords = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
var centralRecords = await LoadCentralRecordsAsync(db);
var centralRows = centralRecords
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
@@ -165,6 +146,42 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return result;
}
private async Task<List<SalesRecord>> LoadCentralRecordsAsync(AppDbContext db)
{
if (_centralSalesDataProvider is not null)
return await _centralSalesDataProvider.GetRecordsAsync();
return await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
}
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
{
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
@@ -9,16 +9,26 @@ public class ManagementCockpitService : IManagementCockpitService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICurrencyExchangeRateService _exchangeRateService;
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
: this(dbFactory, new CurrencyExchangeRateService(dbFactory))
: this(dbFactory, new CurrencyExchangeRateService(dbFactory), null)
{
}
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory, ICurrencyExchangeRateService exchangeRateService)
: this(dbFactory, exchangeRateService, null)
{
}
public ManagementCockpitService(
IDbContextFactory<AppDbContext> dbFactory,
ICurrencyExchangeRateService exchangeRateService,
ICentralSalesDataProvider? centralSalesDataProvider)
{
_dbFactory = dbFactory;
_exchangeRateService = exchangeRateService;
_centralSalesDataProvider = centralSalesDataProvider;
}
private static readonly List<ValueFieldDefinition> ValueFieldDefinitions =
@@ -166,12 +176,12 @@ public class ManagementCockpitService : IManagementCockpitService
public async Task<List<int>> GetAvailableCentralYearsAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var years = await db.CentralSalesRecords
var records = await LoadCentralRecordsAsync();
var years = records
.Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year)
.Distinct()
.OrderBy(x => x)
.ToListAsync();
.ToList();
return years;
}
@@ -186,7 +196,8 @@ public class ManagementCockpitService : IManagementCockpitService
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
var exchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
var baseRows = await db.CentralSalesRecords
var centralRecords = await LoadCentralRecordsAsync();
var baseRows = centralRecords
.Select(r => new CentralCockpitRow
{
SourceSystem = r.SourceSystem,
@@ -204,7 +215,7 @@ public class ManagementCockpitService : IManagementCockpitService
PeriodDate = r.InvoiceDate ?? r.ExtractionDate,
ExchangeRateDate = r.ExtractionDate
})
.ToListAsync();
.ToList();
foreach (var row in baseRows)
row.ExchangeRateDate = ResolveExchangeRateDate(exchangeRateDateField, row.PostingDate, row.InvoiceDate, row.ExtractionDate);
@@ -318,6 +329,7 @@ public class ManagementCockpitService : IManagementCockpitService
public async Task<ManagementFinanceSummaryResult> AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency)
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
var financeRules = await db.FinanceRules
.AsNoTracking()
.Where(rule => rule.IsActive)
@@ -332,40 +344,7 @@ public class ManagementCockpitService : IManagementCockpitService
.Where(rule => rule.IsActive)
.ToListAsync();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var records = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
SourceSystem = r.SourceSystem,
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
ProductGroup = r.ProductGroup,
ProductHierarchyCode = r.ProductHierarchyCode,
ProductHierarchyText = r.ProductHierarchyText,
ProductFamilyCode = r.ProductFamilyCode,
ProductFamilyText = r.ProductFamilyText,
ProductDivisionCode = r.ProductDivisionCode,
ProductDivisionText = r.ProductDivisionText,
ProductMappingAssigned = r.ProductMappingAssigned,
Quantity = r.Quantity,
SupplierCountry = r.SupplierCountry,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate
})
.ToListAsync();
var records = await LoadCentralRecordsAsync();
if (records.Count == 0)
throw new InvalidOperationException("Die zentrale Tabelle enthaelt noch keine Datensaetze.");
@@ -484,7 +463,7 @@ public class ManagementCockpitService : IManagementCockpitService
group => group.Select(reference => reference.CheckValue ?? reference.LocalCurrencyValue).FirstOrDefault(value => value.HasValue),
StringComparer.OrdinalIgnoreCase);
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db);
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db, records, settings.UseAuditCsvAsCentralSource);
var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey);
var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies);
@@ -536,24 +515,38 @@ public class ManagementCockpitService : IManagementCockpitService
};
}
private static async Task<List<ManagementFinanceDataStatusRow>> BuildFinanceDataStatusRowsAsync(AppDbContext db)
private static async Task<List<ManagementFinanceDataStatusRow>> BuildFinanceDataStatusRowsAsync(
AppDbContext db,
IReadOnlyCollection<SalesRecord> centralRecords,
bool useAuditCsv)
{
var sites = await db.Sites
.AsNoTracking()
.OrderBy(site => site.Land)
.ThenBy(site => site.TSC)
.ToListAsync();
var records = await db.CentralSalesRecords
.AsNoTracking()
.GroupBy(record => record.Tsc)
.Select(group => new
{
Tsc = group.Key,
RowCount = group.Count(),
LatestStoredAtUtc = group.Max(record => record.StoredAtUtc),
LatestExtractionDate = group.Max(record => record.ExtractionDate)
})
.ToListAsync();
var records = useAuditCsv
? centralRecords
.GroupBy(record => record.Tsc)
.Select(group => new
{
Tsc = group.Key,
RowCount = group.Count(),
LatestStoredAtUtc = (DateTime?)null,
LatestExtractionDate = group.Max(record => record.ExtractionDate)
})
.ToList()
: await db.CentralSalesRecords
.AsNoTracking()
.GroupBy(record => record.Tsc)
.Select(group => new
{
Tsc = group.Key,
RowCount = group.Count(),
LatestStoredAtUtc = (DateTime?)group.Max(record => record.StoredAtUtc),
LatestExtractionDate = group.Max(record => record.ExtractionDate)
})
.ToListAsync();
var logs = await db.ExportLogs
.AsNoTracking()
.GroupBy(log => log.TSC)
@@ -582,7 +575,7 @@ public class ManagementCockpitService : IManagementCockpitService
{
Land = site.Land,
Tsc = site.TSC,
SourceSystem = site.SourceSystem,
SourceSystem = useAuditCsv ? $"{site.SourceSystem} / Audit-CSV" : site.SourceSystem,
IsActive = site.IsActive,
RowCount = record?.RowCount ?? 0,
LatestStoredAtUtc = record?.LatestStoredAtUtc,
@@ -595,6 +588,63 @@ public class ManagementCockpitService : IManagementCockpitService
}).ToList();
}
private async Task<List<SalesRecord>> LoadCentralRecordsAsync()
{
if (_centralSalesDataProvider is not null)
return await _centralSalesDataProvider.GetRecordsAsync();
using var db = await _dbFactory.CreateDbContextAsync();
return await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
SourceSystem = r.SourceSystem,
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
ProductGroup = r.ProductGroup,
ProductHierarchyCode = r.ProductHierarchyCode,
ProductHierarchyText = r.ProductHierarchyText,
ProductFamilyCode = r.ProductFamilyCode,
ProductFamilyText = r.ProductFamilyText,
ProductDivisionCode = r.ProductDivisionCode,
ProductDivisionText = r.ProductDivisionText,
ProductMappingAssigned = r.ProductMappingAssigned,
Quantity = r.Quantity,
SupplierNumber = r.SupplierNumber,
SupplierName = r.SupplierName,
SupplierCountry = r.SupplierCountry,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
CustomerCountry = r.CustomerCountry,
CustomerIndustry = r.CustomerIndustry,
StandardCost = r.StandardCost,
StandardCostCurrency = r.StandardCostCurrency,
PurchaseOrderNumber = r.PurchaseOrderNumber,
SalesPriceValue = r.SalesPriceValue,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency,
DocumentRate = r.DocumentRate,
CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate,
ExtractionDate = r.ExtractionDate,
DocumentType = r.DocumentType
})
.ToListAsync();
}
private static List<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows(
IReadOnlyCollection<FinanceAggregationRow> rows,
IReadOnlyDictionary<string, decimal?> referenceByKey)
@@ -108,6 +108,9 @@ public sealed class SettingsPageService : ISettingsPageService
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
existing.AuditCsvEnabled = settings.AuditCsvEnabled;
existing.UseAuditCsvAsCentralSource = settings.UseAuditCsvAsCentralSource;
existing.LocalAuditCsvFolder = settings.LocalAuditCsvFolder;
existing.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
}
@@ -14,6 +14,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExportAuditCsvService _auditCsvService;
private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger;
@@ -24,6 +25,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
IExportAuditCsvService auditCsvService,
IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger)
{
@@ -33,6 +35,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService;
_transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_auditCsvService = auditCsvService;
_appEventLogService = appEventLogService;
_logger = logger;
}
@@ -76,6 +79,15 @@ public class SiteExportService : ISiteExportService
details: $"Records vor Transformation={records.Count}");
_transformationService.Apply(records, rules);
var auditCsvPath = await _auditCsvService.WriteSiteAuditCsvAsync(
site, settings, sourceSystem, outputDir, records);
if (!string.IsNullOrWhiteSpace(auditCsvPath))
{
await _appEventLogService.WriteAsync("Export", "Audit-CSV geschrieben",
siteId: site.Id, land: site.Land,
details: auditCsvPath);
}
var filePath = fetchResult.ReferenceFilePath;
if (string.IsNullOrWhiteSpace(filePath))
{