From dcd845d33705fe6779c6b45012d1f8476d862344 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 11 Jun 2026 08:57:18 +0200 Subject: [PATCH] Add audit CSV central source option --- .../Components/Pages/FinanceComparison.razor | 2 +- .../Components/Pages/ManagementCockpit.razor | 2 +- .../Components/Pages/Settings.razor | 16 + .../Models/ConfigTransferPackage.cs | 3 + TrafagSalesExporter/Models/ExportSettings.cs | 3 + TrafagSalesExporter/Program.cs | 2 + .../Services/CentralSalesDataProvider.cs | 59 +++ .../Services/CentralSalesRecordService.cs | 1 + .../Services/ConfigTransferService.cs | 6 + .../Services/ConsolidatedExportService.cs | 8 +- ...DatabaseInitializationService.SchemaSql.cs | 3 + .../DatabaseSchemaMaintenanceService.cs | 3 + .../Services/ExportAuditCsvService.cs | 376 ++++++++++++++++++ .../Services/FinanceReconciliationService.cs | 75 ++-- .../Services/ManagementCockpitService.cs | 158 +++++--- .../Services/SettingsPageService.cs | 3 + .../Services/SiteExportService.cs | 12 + .../ExportAuditCsvServiceTests.cs | 175 ++++++++ .../FINANCE_DATENFLUSS_ANDREAS_2026-06-08.md | 1 + TrafagSalesExporter/docs/rag/FINANCE.md | 1 + TrafagSalesExporter/docs/rag/PROJECT.md | 1 + TrafagSalesExporter/lastchange.md | 1 + 22 files changed, 822 insertions(+), 89 deletions(-) create mode 100644 TrafagSalesExporter/Services/CentralSalesDataProvider.cs create mode 100644 TrafagSalesExporter/Services/ExportAuditCsvService.cs create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/ExportAuditCsvServiceTests.cs diff --git a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor index fa9cc99..8868fb7 100644 --- a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor +++ b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor @@ -13,7 +13,7 @@
@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference") - @T("Verbindliche Finance-Sicht aus CentralSalesRecords", "Authoritative finance view from CentralSalesRecords") + @T("Verbindliche Finance-Sicht aus der aktuellen zentralen Datenquelle", "Authoritative finance view from the current central data source")
@T("Zentrale Roh-Auswertung", "Central raw analysis") - @T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.") + @T("Diese Sicht arbeitet auf der aktuell konfigurierten zentralen Datenquelle (DB oder Audit-CSV). Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works on the currently configured central data source (DB or audit CSV). Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.") @T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.", diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor index fec4ca4..ecf2fd2 100644 --- a/TrafagSalesExporter/Components/Pages/Settings.razor +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -284,6 +284,18 @@ Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs. + + + + Schreibt nach Mapping und Transformation eine lesbare CSV-Datei je Standort. + + + + + + Zentrale Excel, Finance Summary und Management-Analyse lesen die neuesten Standort-CSV-Dateien statt CentralSalesRecords. + + @@ -292,6 +304,10 @@ + + + diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs index 8253f0f..b2473d4 100644 --- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -51,6 +51,9 @@ public class ConfigTransferExportSettings public bool DebugLoggingEnabled { get; set; } public string LocalSiteExportFolder { get; set; } = string.Empty; public string LocalConsolidatedExportFolder { get; set; } = string.Empty; + public bool AuditCsvEnabled { get; set; } = true; + public bool UseAuditCsvAsCentralSource { get; set; } + public string LocalAuditCsvFolder { get; set; } = string.Empty; public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate; } diff --git a/TrafagSalesExporter/Models/ExportSettings.cs b/TrafagSalesExporter/Models/ExportSettings.cs index 17eb490..0ed85fb 100644 --- a/TrafagSalesExporter/Models/ExportSettings.cs +++ b/TrafagSalesExporter/Models/ExportSettings.cs @@ -10,6 +10,9 @@ public class ExportSettings public bool DebugLoggingEnabled { get; set; } public string LocalSiteExportFolder { get; set; } = string.Empty; public string LocalConsolidatedExportFolder { get; set; } = string.Empty; + public bool AuditCsvEnabled { get; set; } = true; + public bool UseAuditCsvAsCentralSource { get; set; } + public string LocalAuditCsvFolder { get; set; } = string.Empty; public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate; } diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 510ada2..2d041f0 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -80,9 +80,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/CentralSalesDataProvider.cs b/TrafagSalesExporter/Services/CentralSalesDataProvider.cs new file mode 100644 index 0000000..dcfb23a --- /dev/null +++ b/TrafagSalesExporter/Services/CentralSalesDataProvider.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface ICentralSalesDataProvider +{ + Task> GetRecordsAsync(); + Task UsesAuditCsvAsync(); +} + +public sealed class CentralSalesDataProvider : ICentralSalesDataProvider +{ + private readonly IDbContextFactory _dbFactory; + private readonly ICentralSalesRecordService _centralSalesRecordService; + private readonly IExportAuditCsvService _auditCsvService; + + public CentralSalesDataProvider( + IDbContextFactory dbFactory, + ICentralSalesRecordService centralSalesRecordService, + IExportAuditCsvService auditCsvService) + { + _dbFactory = dbFactory; + _centralSalesRecordService = centralSalesRecordService; + _auditCsvService = auditCsvService; + } + + public async Task> 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 UsesAuditCsvAsync() + { + using var db = await _dbFactory.CreateDbContextAsync(); + var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings(); + return settings.UseAuditCsvAsCentralSource; + } +} diff --git a/TrafagSalesExporter/Services/CentralSalesRecordService.cs b/TrafagSalesExporter/Services/CentralSalesRecordService.cs index d22d1a7..ed549d9 100644 --- a/TrafagSalesExporter/Services/CentralSalesRecordService.cs +++ b/TrafagSalesExporter/Services/CentralSalesRecordService.cs @@ -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, diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index ceb0e85..87b0ae5 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -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) }); diff --git a/TrafagSalesExporter/Services/ConsolidatedExportService.cs b/TrafagSalesExporter/Services/ConsolidatedExportService.cs index f089e4b..1c9b88d 100644 --- a/TrafagSalesExporter/Services/ConsolidatedExportService.cs +++ b/TrafagSalesExporter/Services/ConsolidatedExportService.cs @@ -7,25 +7,25 @@ namespace TrafagSalesExporter.Services; public class ConsolidatedExportService : IConsolidatedExportService { private readonly IDbContextFactory _dbFactory; - private readonly ICentralSalesRecordService _centralSalesRecordService; + private readonly ICentralSalesDataProvider _centralSalesDataProvider; private readonly IExcelExportService _excelService; private readonly ISharePointUploadService _sharePointService; public ConsolidatedExportService( IDbContextFactory dbFactory, - ICentralSalesRecordService centralSalesRecordService, + ICentralSalesDataProvider centralSalesDataProvider, IExcelExportService excelService, ISharePointUploadService sharePointService) { _dbFactory = dbFactory; - _centralSalesRecordService = centralSalesRecordService; + _centralSalesDataProvider = centralSalesDataProvider; _excelService = excelService; _sharePointService = sharePointService; } public async Task ExportAsync() { - var consolidatedRecords = await _centralSalesRecordService.GetAllAsync(); + var consolidatedRecords = await _centralSalesDataProvider.GetRecordsAsync(); if (consolidatedRecords.Count == 0) return null; diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index 72040a4..efec169 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -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' );"; diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index 010ced0..b93d442 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -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 ''"); diff --git a/TrafagSalesExporter/Services/ExportAuditCsvService.cs b/TrafagSalesExporter/Services/ExportAuditCsvService.cs new file mode 100644 index 0000000..dd56a87 --- /dev/null +++ b/TrafagSalesExporter/Services/ExportAuditCsvService.cs @@ -0,0 +1,376 @@ +using System.Globalization; +using System.Text; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IExportAuditCsvService +{ + Task WriteSiteAuditCsvAsync( + Site site, + ExportSettings settings, + string sourceSystem, + string fallbackOutputDirectory, + IReadOnlyList records); + + Task> 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 WriteSiteAuditCsvAsync( + Site site, + ExportSettings settings, + string sourceSystem, + string fallbackOutputDirectory, + IReadOnlyList 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> 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(); + 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 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> 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(); + 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 ParseLine(string line) + { + var values = new List(); + 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 values, IReadOnlyDictionary 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 values, IReadOnlyDictionary headers, string header) + => int.TryParse(GetText(values, headers, header), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : 0; + + private static decimal GetDecimal(IReadOnlyList values, IReadOnlyDictionary 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 values, IReadOnlyDictionary 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; +} diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index da853b1..369c1c8 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -12,10 +12,19 @@ public interface IFinanceReconciliationService public sealed class FinanceReconciliationService : IFinanceReconciliationService { private readonly IDbContextFactory _dbFactory; + private readonly ICentralSalesDataProvider? _centralSalesDataProvider; public FinanceReconciliationService(IDbContextFactory dbFactory) + : this(dbFactory, null) + { + } + + public FinanceReconciliationService( + IDbContextFactory dbFactory, + ICentralSalesDataProvider? centralSalesDataProvider) { _dbFactory = dbFactory; + _centralSalesDataProvider = centralSalesDataProvider; } public async Task> 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> 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); diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index b502ef9..29208c6 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -9,16 +9,26 @@ public class ManagementCockpitService : IManagementCockpitService { private readonly IDbContextFactory _dbFactory; private readonly ICurrencyExchangeRateService _exchangeRateService; + private readonly ICentralSalesDataProvider? _centralSalesDataProvider; public ManagementCockpitService(IDbContextFactory dbFactory) - : this(dbFactory, new CurrencyExchangeRateService(dbFactory)) + : this(dbFactory, new CurrencyExchangeRateService(dbFactory), null) { } public ManagementCockpitService(IDbContextFactory dbFactory, ICurrencyExchangeRateService exchangeRateService) + : this(dbFactory, exchangeRateService, null) + { + } + + public ManagementCockpitService( + IDbContextFactory dbFactory, + ICurrencyExchangeRateService exchangeRateService, + ICentralSalesDataProvider? centralSalesDataProvider) { _dbFactory = dbFactory; _exchangeRateService = exchangeRateService; + _centralSalesDataProvider = centralSalesDataProvider; } private static readonly List ValueFieldDefinitions = @@ -166,12 +176,12 @@ public class ManagementCockpitService : IManagementCockpitService public async Task> 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 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> BuildFinanceDataStatusRowsAsync(AppDbContext db) + private static async Task> BuildFinanceDataStatusRowsAsync( + AppDbContext db, + IReadOnlyCollection 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> 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 BuildFinanceCountryStatusRows( IReadOnlyCollection rows, IReadOnlyDictionary referenceByKey) diff --git a/TrafagSalesExporter/Services/SettingsPageService.cs b/TrafagSalesExporter/Services/SettingsPageService.cs index f58bbee..94e7c8b 100644 --- a/TrafagSalesExporter/Services/SettingsPageService.cs +++ b/TrafagSalesExporter/Services/SettingsPageService.cs @@ -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); } diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 6169963..ace38c4 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -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 _logger; @@ -24,6 +25,7 @@ public class SiteExportService : ISiteExportService ISharePointUploadService sharePointService, IRecordTransformationService transformationService, ICentralSalesRecordService centralSalesRecordService, + IExportAuditCsvService auditCsvService, IAppEventLogService appEventLogService, ILogger 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)) { diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ExportAuditCsvServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ExportAuditCsvServiceTests.cs new file mode 100644 index 0000000..221953e --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ExportAuditCsvServiceTests.cs @@ -0,0 +1,175 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public sealed class ExportAuditCsvServiceTests : IDisposable +{ + private readonly string _tempDirectory; + + public ExportAuditCsvServiceTests() + { + _tempDirectory = Path.Combine("C:\\TMP", $"trafag-audit-csv-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + Directory.Delete(_tempDirectory, recursive: true); + } + + [Fact] + public async Task WriteSiteAuditCsvAsync_Roundtrips_Transformed_SalesRecord() + { + var service = new ExportAuditCsvService(); + var settings = new ExportSettings + { + AuditCsvEnabled = true, + LocalAuditCsvFolder = _tempDirectory + }; + var site = new Site { TSC = "TRCH", Land = "Schweiz" }; + var record = new SalesRecord + { + SourceSystem = "SAP", + ExtractionDate = new DateTime(2026, 6, 11, 8, 30, 0, DateTimeKind.Utc), + Tsc = "TRCH", + SourceLineId = "line-1", + DocumentEntry = 42, + InvoiceNumber = "INV-1", + PositionOnInvoice = 7, + Material = "MAT;1", + Name = "Artikel \"Audit\"", + ProductDivisionCode = "0001", + ProductDivisionText = "Pressure", + ProductMappingAssigned = "TRUE", + Quantity = 2.5m, + SalesPriceValue = 1234.56m, + SalesCurrency = "CHF", + DocumentCurrency = "EUR", + DocumentTotalForeignCurrency = 1300m, + DocumentTotalLocalCurrency = 1234.56m, + VatSumForeignCurrency = 0m, + VatSumLocalCurrency = 0m, + DocumentRate = 0.95m, + CompanyCurrency = "CHF", + PostingDate = new DateTime(2026, 6, 10), + InvoiceDate = new DateTime(2026, 6, 11), + Land = "Schweiz", + DocumentType = "INV" + }; + + var path = await service.WriteSiteAuditCsvAsync(site, settings, "SAP", _tempDirectory, [record]); + + Assert.True(File.Exists(path)); + var records = await service.ReadLatestSiteAuditCsvRecordsAsync(settings); + var roundtrip = Assert.Single(records); + Assert.Equal("SAP", roundtrip.SourceSystem); + Assert.Equal("TRCH", roundtrip.Tsc); + Assert.Equal("line-1", roundtrip.SourceLineId); + Assert.Equal("MAT;1", roundtrip.Material); + Assert.Equal("Artikel \"Audit\"", roundtrip.Name); + Assert.Equal(1234.56m, roundtrip.SalesPriceValue); + Assert.Equal("CHF", roundtrip.SalesCurrency); + Assert.Equal(new DateTime(2026, 6, 10), roundtrip.PostingDate); + Assert.Equal(new DateTime(2026, 6, 11), roundtrip.InvoiceDate); + } + + [Fact] + public async Task CentralSalesDataProvider_Uses_AuditCsv_When_Configured() + { + var csvService = new ExportAuditCsvService(); + await csvService.WriteSiteAuditCsvAsync( + new Site { TSC = "TRUK", Land = "England" }, + new ExportSettings { AuditCsvEnabled = true, LocalAuditCsvFolder = _tempDirectory }, + "MANUAL_EXCEL", + _tempDirectory, + [ + new SalesRecord + { + SourceSystem = "MANUAL_EXCEL", + ExtractionDate = new DateTime(2026, 6, 11), + Tsc = "TRUK", + Land = "England", + InvoiceNumber = "UK-1", + SalesPriceValue = 10m, + SalesCurrency = "GBP", + InvoiceDate = new DateTime(2026, 1, 1) + } + ]); + + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using (var db = new AppDbContext(options)) + { + await db.Database.EnsureCreatedAsync(); + db.ExportSettings.Add(new ExportSettings + { + UseAuditCsvAsCentralSource = true, + LocalAuditCsvFolder = _tempDirectory + }); + db.Sites.Add(new Site + { + Id = 1, + Schema = "DB", + TSC = "TRDB", + Land = "DB", + SourceSystem = "DB", + IsActive = true + }); + db.CentralSalesRecords.Add(new CentralSalesRecord + { + StoredAtUtc = DateTime.UtcNow, + SiteId = 1, + SourceSystem = "DB", + ExtractionDate = new DateTime(2026, 6, 11), + Tsc = "TRDB", + InvoiceNumber = "DB-1", + Land = "DB", + DocumentType = "INV" + }); + await db.SaveChangesAsync(); + } + + var dbFactory = new TestDbContextFactory(options); + var centralService = new CentralSalesRecordService(dbFactory, new NullAppEventLogService()); + var provider = new CentralSalesDataProvider(dbFactory, centralService, csvService); + + var records = await provider.GetRecordsAsync(); + + var record = Assert.Single(records); + Assert.Equal("TRUK", record.Tsc); + Assert.Equal("UK-1", record.InvoiceNumber); + Assert.Equal(10m, record.SalesPriceValue); + } + + private sealed class NullAppEventLogService : IAppEventLogService + { + public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + + public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/docs/FINANCE_DATENFLUSS_ANDREAS_2026-06-08.md b/TrafagSalesExporter/docs/FINANCE_DATENFLUSS_ANDREAS_2026-06-08.md index a9af210..a8f23dd 100644 --- a/TrafagSalesExporter/docs/FINANCE_DATENFLUSS_ANDREAS_2026-06-08.md +++ b/TrafagSalesExporter/docs/FINANCE_DATENFLUSS_ANDREAS_2026-06-08.md @@ -9,6 +9,7 @@ Fokus nur Wechselkurs/Kursanwendung: `docs/FINANCE_KURS_WORKFLOW_2026-06-09.md`. ## Kurzfazit - Finance Summary, Management Analyse und Spartenanalyse lesen nicht aus dem SharePoint-Excel, sondern direkt aus der App-Datenbank `CentralSalesRecords`. +- Nachtrag 2026-06-11: Fuer Finance/Revision gibt es lokal einen Audit-CSV-Modus. Standortexporte koennen nach Mapping und Transformation je Standort eine CSV schreiben; per Setting koennen zentrale Excel, Finance Summary und Management-Analyse aus den neuesten Audit-CSV statt aus `CentralSalesRecords` lesen. - Das SharePoint-Excel `Sales_All_*.xlsx` ist ein Export-/Ablageergebnis, nicht die Live-Quelle der Cockpit-Anzeige. - Jeder Standortexport ersetzt in `CentralSalesRecords` nur die Daten dieses Standorts. - Die zentrale Excel wird danach aus dem aktuellen Stand von `CentralSalesRecords` erzeugt. diff --git a/TrafagSalesExporter/docs/rag/FINANCE.md b/TrafagSalesExporter/docs/rag/FINANCE.md index 3d557af..28b0233 100644 --- a/TrafagSalesExporter/docs/rag/FINANCE.md +++ b/TrafagSalesExporter/docs/rag/FINANCE.md @@ -5,6 +5,7 @@ Stand: 2026-06-10 ## Kurzstand - Fuehrende Sicht: `Finance Summary`. +- Neu lokal: `Finance Summary`, zentrale Excel und Management-Analyse koennen wahlweise aus Audit-CSV statt direkt aus `CentralSalesRecords` lesen. Die Audit-CSV werden nach Mapping und Transformation geschrieben und dienen der Nachvollziehbarkeit fuer Finance/Revision. - `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel. - `Management Analyse` bleibt Diagnose-/Plausibilitaetssicht, nicht fuehrende Finance-Zahl. - Nach UX-Vereinfachung gibt es links eine schnellere Finance-Uebersicht; tiefe Diagnosefunktionen sind unter `Experten` gebuendelt. diff --git a/TrafagSalesExporter/docs/rag/PROJECT.md b/TrafagSalesExporter/docs/rag/PROJECT.md index fdfe321..2d2c1cf 100644 --- a/TrafagSalesExporter/docs/rag/PROJECT.md +++ b/TrafagSalesExporter/docs/rag/PROJECT.md @@ -5,6 +5,7 @@ Stand: 2026-06-10 ## Kurzstand - Fuehrende App: `TrafagSalesExporter`, publiziert als `BiDashboard`. +- Neu lokal: Audit-CSV-Modus fuer Finance/Revision. Standortexporte schreiben optional nach Mapping/Transformation je Standort eine lesbare CSV; zentrale Excel, Finance Summary und Management-Analyse koennen per Setting aus den neuesten Standort-CSV statt aus der internen DB lesen. - Letzter dokumentierter Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN SAGE-HANA-Fix und Spanien-SharePoint-Pfad bleiben abgesichert. - Validierung laut Doku: `87/87` Tests gruen fuer den Produktsparten-Fallback; fruehere UI-/Deploy-Schritte wurden einzeln umgesetzt und deployed. - Letzter dokumentierter Deploy: 2026-06-10 auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`. diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 2dce636..2f5f1df 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -8,6 +8,7 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert. - Fuehrender Kurzkontext: `docs/rag/PROJECT.md`. - Themenrouter: `docs/RAG_ROUTER.md`. +- Neu lokal umgesetzt: Standortexporte koennen nach Mapping und Transformation eine lesbare Audit-CSV je Standort schreiben; zentrale Excel, Finance Summary und Management-Analyse koennen per Setting wahlweise aus den neuesten Audit-CSV statt aus `CentralSalesRecords` lesen. - Letzter dokumentierter Code-Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN HANA-Route und Spanien-SharePoint-Pfad bleiben im Seed abgesichert. - Letzte dokumentierte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `87/87` Tests gruen. - Letzter dokumentierter Deploy: 2026-06-10 Produktsparten-Fallback nach `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.