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 const string ProcessedMergeInputFilePrefix = "Sales_ProcessedMergeInput_"; private const string LegacyFilePrefix = "Sales_"; 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 = $"{ProcessedMergeInputFilePrefix}{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 = EnumerateAuditCsvFiles(directory) .Select(path => new { Path = path, Tsc = ResolveTscFromFileName(path) }) .Where(file => !string.IsNullOrWhiteSpace(file.Tsc)) .GroupBy(file => file.Tsc, StringComparer.OrdinalIgnoreCase) .Select(group => group .OrderByDescending(file => File.GetLastWriteTimeUtc(file.Path)) .ThenByDescending(file => IsProcessedMergeInputFile(file.Path)) .ThenByDescending(file => Path.GetFileName(file.Path), StringComparer.OrdinalIgnoreCase) .First() .Path) .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(fallbackOutputDirectory)) return fallbackOutputDirectory.Trim(); if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder)) return settings.LocalSiteExportFolder.Trim(); return Path.Combine(AppContext.BaseDirectory, "output"); } 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(ProcessedMergeInputFilePrefix, StringComparison.OrdinalIgnoreCase)) return ResolveTscFromSuffix(name[ProcessedMergeInputFilePrefix.Length..]); if (name.StartsWith(LegacyFilePrefix, StringComparison.OrdinalIgnoreCase)) return ResolveTscFromSuffix(name[LegacyFilePrefix.Length..]); return string.Empty; } private static string ResolveTscFromSuffix(string suffix) { var lastUnderscore = suffix.LastIndexOf('_'); return lastUnderscore <= 0 ? suffix : suffix[..lastUnderscore]; } private static IEnumerable EnumerateAuditCsvFiles(string directory) => Directory.EnumerateFiles(directory, $"{ProcessedMergeInputFilePrefix}*.csv", SearchOption.TopDirectoryOnly) .Concat(Directory.EnumerateFiles(directory, $"{LegacyFilePrefix}*.csv", SearchOption.TopDirectoryOnly) .Where(path => !IsProcessedMergeInputFile(path))); private static bool IsProcessedMergeInputFile(string path) => Path.GetFileName(path).StartsWith(ProcessedMergeInputFilePrefix, StringComparison.OrdinalIgnoreCase); 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; }