using System.Globalization; using System.Net; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; using TrafagSalesExporter.Services; using TrafagSalesExporter.Services.DataSources; var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]); builder.Services.AddDbContextFactory(options => options.UseSqlite($"Data Source={databasePath};Default Timeout=60")); builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); 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(); 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(); 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(); var app = builder.Build(); app.MapGet("/", () => Results.Redirect("/finance")); app.MapGet("/finance", async (IFinanceReconciliationService finance, IDbContextFactory dbFactory) => { var rows = await finance.BuildNetSalesReferenceRowsAsync(2025); var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath()); var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath()); var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath()); var coverage = await LoadSiteCoverageAsync(dbFactory, 2025); return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample, coverage), "text/html; charset=utf-8"); }); app.MapGet("/run/export-all", async (ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory dbFactory) => { var startedAt = DateTime.Now; await exports.ExportAllAsync(); var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, "Alle aktiven Standorte exportiert und zentrale Datei erzeugt."); return Results.Content(summary, "text/html; charset=utf-8"); }); app.MapGet("/run/consolidated", async (ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory dbFactory) => { var startedAt = DateTime.Now; var path = await exports.ExportConsolidatedOnlyAsync(); var message = string.IsNullOrWhiteSpace(path) ? "Zentrale Datei wurde nicht erzeugt; vermutlich keine CentralSalesRecords vorhanden oder Export lief bereits." : $"Zentrale Datei erzeugt: {path}"; var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message); return Results.Content(summary, "text/html; charset=utf-8"); }); app.MapGet("/run/export/{siteKey}", async (string siteKey, int? year, ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory dbFactory) => { var startedAt = DateTime.Now; var site = await ResolveSiteAsync(dbFactory, siteKey); if (site is null) return Results.NotFound($"Standort nicht gefunden: {siteKey}"); var importYear = year ?? 2025; var result = await exports.ExportSiteByIdAsync(site.Id, importYear); var message = result is null ? $"Export wurde nicht gestartet: {site.Land} / {site.TSC}" : $"Export {result.Log.Status}: {site.Land} / {site.TSC}, Jahr={importYear}, Zeilen={result.Log.RowCount}, Datei={result.FilePath ?? "-"}, Fehler={result.Log.ErrorMessage}"; var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message); return Results.Content(summary, "text/html; charset=utf-8"); }); app.Run(); static async Task ResolveSiteAsync(IDbContextFactory dbFactory, string siteKey) { await using var db = await dbFactory.CreateDbContextAsync(); var normalized = siteKey.Trim(); if (int.TryParse(normalized, out var siteId)) return await db.Sites.AsNoTracking().FirstOrDefaultAsync(s => s.Id == siteId); var sites = await db.Sites .AsNoTracking() .OrderBy(s => s.Id) .ToListAsync(); return sites.FirstOrDefault(s => s.TSC.Equals(normalized, StringComparison.OrdinalIgnoreCase) || s.Land.Equals(normalized, StringComparison.OrdinalIgnoreCase)); } static async Task BuildRunSummaryAsync( IFinanceReconciliationService finance, IDbContextFactory dbFactory, DateTime startedAt, string message) { var rows = await finance.BuildNetSalesReferenceRowsAsync(2025); var coverage = await LoadSiteCoverageAsync(dbFactory, 2025); var recentLogs = await LoadRecentExportLogsAsync(dbFactory, startedAt); var okCount = rows.Count(r => r.Status == "OK"); var checkCount = rows.Count(r => r.Status == "Pruefen"); var missingCount = rows.Count(r => r.Status == "Keine Daten"); var financeRows = string.Join(Environment.NewLine, rows.Select(row => $$""" {{Html(row.Status)}} {{Html(row.Key)}} {{Html(row.Label)}} {{Amount(row.ActualValue)}} {{Amount(row.ReferenceValue)}} {{Amount(row.Difference)}} {{Html(row.ValueField)}} {{row.RowCount}} """)); var coverageRows = string.Join(Environment.NewLine, coverage.Select(row => $$""" {{Html(row.Land)}}
{{Html(row.Tsc)}}
{{Html(row.SourceSystem)}} {{row.RowCount}} {{Amount(row.SalesPriceValue)}} {{Html(row.Currencies)}} {{Html(row.LastExportStatus)}} {{Html(row.LastExportError)}} """)); var logRows = string.Join(Environment.NewLine, recentLogs.Select(log => $$""" {{Html(log.Timestamp.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} {{Html(log.Land)}} {{Html(log.TSC)}} {{Html(log.Status)}} {{log.RowCount}} {{Html(log.FileName)}} {{Html(log.ErrorMessage)}} """)); return $$""" FinanceProbe Run Summary

FinanceProbe Run Summary

{{Html(message)}}

Start: {{Html(startedAt.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} | Ergebnis: OK={{okCount}}, Pruefen={{checkCount}}, Keine Daten={{missingCount}}

Zur Finance-Auswertung | Zentrale Datei erzeugen | Alle exportieren

Neue Exportlogs seit Start

{{logRows}}
ZeitLandTSCStatusZeilenDateiFehler

Finance-Abgleich

{{financeRows}}
StatusKeyLabelIstSollDiffFeldZeilen

Datenabdeckung

{{coverageRows}}
StandortSystemZeilenSalesWaehrungLetzter StatusFehler
"""; } static async Task> LoadRecentExportLogsAsync(IDbContextFactory dbFactory, DateTime startedAt) { await using var db = await dbFactory.CreateDbContextAsync(); return await db.ExportLogs .AsNoTracking() .Where(log => log.Timestamp >= startedAt.AddSeconds(-2)) .OrderByDescending(log => log.Id) .Take(40) .ToListAsync(); } static string ResolveDatabasePath(string? configuredPath) { if (!string.IsNullOrWhiteSpace(configuredPath)) return Path.GetFullPath(configuredPath); foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { var directory = new DirectoryInfo(start); while (directory is not null) { var candidate = Path.Combine(directory.FullName, "trafag_exporter.db"); if (File.Exists(candidate)) return candidate; directory = directory.Parent; } } return Path.Combine(Directory.GetCurrentDirectory(), "trafag_exporter.db"); } static string? ResolveCheckedExcelPath() { foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { var directory = new DirectoryInfo(start); while (directory is not null) { var candidate = Path.Combine(directory.FullName, "check.xlsx"); if (File.Exists(candidate)) return candidate; directory = directory.Parent; } } return null; } static string? ResolveSpainSalesCsvPath() { foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { var directory = new DirectoryInfo(start); while (directory is not null) { var directCandidate = Path.Combine(directory.FullName, "sagespain", "v2", "Spain_Sales_2025.csv"); if (File.Exists(directCandidate)) return directCandidate; var recursiveCandidate = Directory .EnumerateFiles(directory.FullName, "Spain_Sales_2025.csv", System.IO.SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(recursiveCandidate)) return recursiveCandidate; directory = directory.Parent; } } return null; } static string? ResolveGermanySamplePath() { foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) { var directory = new DirectoryInfo(start); while (directory is not null) { var directCandidate = Path.Combine(directory.FullName, "DE_Beispiel_Export_Daten.xlsx"); if (File.Exists(directCandidate)) return directCandidate; var recursiveCandidate = Directory .EnumerateFiles(directory.FullName, "DE_Beispiel_Export_Daten.xlsx", System.IO.SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(recursiveCandidate)) return recursiveCandidate; directory = directory.Parent; } } return null; } static Dictionary LoadCheckedExcelReferences(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return []; using var workbook = new XLWorkbook(path); var worksheet = workbook.Worksheets.First(); var references = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var row in worksheet.RowsUsed().Skip(1)) { var label = row.Cell(1).GetString().Trim(); if (string.IsNullOrWhiteSpace(label) || label.Equals("Total TR Gruppe", StringComparison.OrdinalIgnoreCase)) continue; references[label] = new CheckedExcelReference { Label = label, LocalCurrencyValue = ReadNullableDecimal(row.Cell(2)), ChfValue = ReadNullableDecimal(row.Cell(3)), PowerBiValue = ReadNullableDecimal(row.Cell(5)), Status = row.Cell(6).GetString().Trim() }; } return references; } static decimal? ReadNullableDecimal(IXLCell cell) { if (cell.IsEmpty()) return null; return cell.TryGetValue(out var value) ? value : null; } static GermanyExcelProbe? LoadGermanyExcelProbe(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; using var workbook = new XLWorkbook(path); var worksheet = workbook.Worksheets.FirstOrDefault(); var usedRange = worksheet?.RangeUsed(); if (worksheet is null || usedRange is null) return null; var headerRow = usedRange.FirstRow(); var headers = headerRow.CellsUsed() .ToDictionary(cell => cell.GetString().Trim(), cell => cell.Address.ColumnNumber, StringComparer.OrdinalIgnoreCase); if (!headers.TryGetValue("NettoPreisGesamtX", out var amountColumn)) return null; headers.TryGetValue("Währung", out var currencyColumn); headers.TryGetValue("Belegdatum-Rechnung", out var invoiceDateColumn); var total = 0m; var rowsWithAmount = 0; var rowsIn2025 = 0; var totalIn2025 = 0m; var currencies = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var row in usedRange.RowsUsed().Skip(1)) { var value = ReadProbeDecimal(row.Cell(amountColumn)); if (value == 0m) continue; total += value; rowsWithAmount++; if (currencyColumn > 0) { var currency = row.Cell(currencyColumn).GetString().Trim(); if (!string.IsNullOrWhiteSpace(currency)) currencies.Add(currency); } if (invoiceDateColumn > 0 && TryReadProbeDate(row.Cell(invoiceDateColumn), out var invoiceDate) && invoiceDate.Year == 2025) { totalIn2025 += value; rowsIn2025++; } } return new GermanyExcelProbe { Path = path, RowsWithAmount = rowsWithAmount, SalesPriceValue = total, RowsIn2025 = rowsIn2025, SalesPriceValueIn2025 = totalIn2025, Currencies = string.Join(", ", currencies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) }; } static async Task> LoadSiteCoverageAsync(IDbContextFactory dbFactory, int year) { await using var db = await dbFactory.CreateDbContextAsync(); var sites = await db.Sites .AsNoTracking() .OrderBy(s => s.Land) .ThenBy(s => s.TSC) .Select(s => new { s.Id, s.Land, s.TSC, s.SourceSystem, s.ManualImportFilePath, s.IsActive }) .ToListAsync(); var sourceSystems = await db.SourceSystemDefinitions .AsNoTracking() .ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase); var centralBaseRows = await db.CentralSalesRecords .AsNoTracking() .Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year) .Select(r => new { r.SiteId, r.SalesPriceValue, Date = r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate, Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency }) .ToListAsync(); var centralRows = centralBaseRows .GroupBy(r => r.SiteId) .ToDictionary(g => g.Key, g => new { Rows = g.Count(), Sales = g.Sum(r => r.SalesPriceValue), MinDate = g.Min(r => r.Date), MaxDate = g.Max(r => r.Date), Currencies = g.Select(r => r.Currency).Distinct(StringComparer.OrdinalIgnoreCase).ToList() }); var latestLogs = await db.ExportLogs .AsNoTracking() .GroupBy(l => l.SiteId) .Select(g => g.OrderByDescending(l => l.Id).First()) .ToDictionaryAsync(l => l.SiteId); return sites.Select(site => { sourceSystems.TryGetValue(site.SourceSystem, out var sourceSystem); centralRows.TryGetValue(site.Id, out var central); latestLogs.TryGetValue(site.Id, out var latestLog); return new SiteCoverageRow { Land = site.Land, Tsc = site.TSC, SourceSystem = site.SourceSystem, SourceDisplayName = sourceSystem?.DisplayName ?? site.SourceSystem, ConnectionKind = sourceSystem?.ConnectionKind ?? string.Empty, IsActive = site.IsActive, ManualImportPath = site.ManualImportFilePath, RowCount = central?.Rows ?? 0, SalesPriceValue = central?.Sales, MinDate = central?.MinDate, MaxDate = central?.MaxDate, Currencies = central is null ? string.Empty : string.Join(", ", central.Currencies.Where(x => !string.IsNullOrWhiteSpace(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)), LastExportStatus = latestLog?.Status ?? string.Empty, LastExportAt = latestLog?.Timestamp, LastExportError = latestLog?.ErrorMessage ?? string.Empty }; }).ToList(); } static decimal ReadProbeDecimal(IXLCell cell) { if (cell.TryGetValue(out var decimalValue)) return decimalValue; var text = cell.GetString().Trim(); if (string.IsNullOrWhiteSpace(text)) return 0m; text = text .Replace("'", string.Empty) .Replace("’", string.Empty) .Replace(" ", string.Empty) .Replace(",", "."); return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0m; } static bool TryReadProbeDate(IXLCell cell, out DateTime value) { if (cell.TryGetValue(out value)) return true; return DateTime.TryParse(cell.GetString(), CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.None, out value) || DateTime.TryParse(cell.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out value); } static SpainSalesCsvProbe? LoadSpainSalesCsvProbe(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; using var parser = new TextFieldParser(path) { TextFieldType = FieldType.Delimited, HasFieldsEnclosedInQuotes = true, TrimWhiteSpace = false }; parser.SetDelimiters(";"); var header = parser.ReadFields(); if (header is null) return null; var headerMap = header .Select((name, index) => new { Name = name.Trim(), Index = index }) .ToDictionary(x => x.Name, x => x.Index, StringComparer.OrdinalIgnoreCase); if (!headerMap.TryGetValue("SalesPriceValue", out var salesIndex)) return null; headerMap.TryGetValue("DocumentType", out var documentTypeIndex); headerMap.TryGetValue("InvoiceSeries", out var invoiceSeriesIndex); var rows = 0; var total = 0m; var byDocumentType = new Dictionary(StringComparer.OrdinalIgnoreCase); var bySeries = new Dictionary(StringComparer.OrdinalIgnoreCase); while (!parser.EndOfData) { var fields = parser.ReadFields(); if (fields is null || fields.All(string.IsNullOrWhiteSpace)) continue; var sales = salesIndex < fields.Length ? ParseProbeDecimal(fields[salesIndex]) : 0m; var documentType = documentTypeIndex < fields.Length && !string.IsNullOrWhiteSpace(fields[documentTypeIndex]) ? fields[documentTypeIndex] : "-"; var series = invoiceSeriesIndex < fields.Length && !string.IsNullOrWhiteSpace(fields[invoiceSeriesIndex]) ? fields[invoiceSeriesIndex] : "-"; rows++; total += sales; AddGroupValue(byDocumentType, documentType, sales); AddGroupValue(bySeries, series, sales); } const decimal reference = 3102333.61m; return new SpainSalesCsvProbe { Path = path, Rows = rows, SalesPriceValue = total, ReferenceValue = reference, Difference = total - reference, ByDocumentType = byDocumentType .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Select(x => new SpainSalesCsvGroup(x.Key, x.Value.Rows, x.Value.Sales)) .ToList(), BySeries = bySeries .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Select(x => new SpainSalesCsvGroup(x.Key, x.Value.Rows, x.Value.Sales)) .ToList() }; } static void AddGroupValue(Dictionary groups, string key, decimal sales) { groups.TryGetValue(key, out var current); groups[key] = (current.Rows + 1, current.Sales + sales); } static decimal ParseProbeDecimal(string text) { if (string.IsNullOrWhiteSpace(text)) return 0m; return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : 0m; } static string BuildPage( IReadOnlyList rows, string databasePath, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv, GermanyExcelProbe? germanySample, IReadOnlyList coverage) { var generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")); var okCount = rows.Count(r => r.Status == "OK"); var checkCount = rows.Count(r => r.Status == "Pruefen"); var missingCount = rows.Count(r => r.Status == "Keine Daten"); var excelCount = excelReferences.Count; var financeChiefOverview = BuildFinanceChiefOverview(rows, excelReferences, spainCsv, germanySample); var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample); var detailRows = BuildDetailRows(rows, excelReferences, spainCsv); var coverageRows = BuildCoverageRows(coverage); var spainCsvSection = BuildSpainCsvSection(spainCsv); var germanySampleSection = BuildGermanySampleSection(germanySample, excelReferences); return $$""" Finance Probe

Finance Probe - Net Sales Actuals 2025

Vergleich gegen gepruefte Sollwerte aus check.xlsx Stand 29.04.2026 DB: {{Html(databasePath)}} Excel-Referenzen gelesen: {{excelCount}} Aktualisiert: {{Html(generatedAt)}}
{{financeChiefOverview}} {{executiveBriefing}}
{{rows.Count}}Standorte
{{okCount}}OK
{{checkCount}}Pruefen
{{missingCount}}Keine Daten
{{coverage.Count}}Konfigurierte Standorte
{{detailRows}}
Status Firma Gewaehlte Abgrenzung Ist-Waehrung Ist 2025 Referenz-Waehrung Referenz Excel LC Excel CHF Excel Sollwert Excel Status Differenz Ohne 2nd-party Diff. Waehrung Zeilen Varianten
{{coverageRows}} {{germanySampleSection}} {{spainCsvSection}}
"""; } static string BuildCoverageRows(IReadOnlyList coverage) { if (coverage.Count == 0) return string.Empty; var rows = string.Join(Environment.NewLine, coverage.Select(row => { var sourceDetail = row.ConnectionKind switch { "MANUAL_EXCEL" when !string.IsNullOrWhiteSpace(row.ManualImportPath) => row.ManualImportPath, "MANUAL_EXCEL" => "Kein Manual-Dateipfad hinterlegt", _ => row.SourceDisplayName }; var period = row.RowCount == 0 ? "-" : $"{row.MinDate:dd.MM.yyyy} - {row.MaxDate:dd.MM.yyyy}"; var lastExport = row.LastExportAt.HasValue ? $"{row.LastExportAt:dd.MM.yyyy HH:mm} / {row.LastExportStatus}" : "-"; var issue = BuildCoverageIssue(row); return $$""" {{Html(row.Land)}}
{{Html(row.Tsc)}}
{{Html(row.SourceSystem)}}
{{Html(row.ConnectionKind)}}
{{Html(sourceDetail)}} {{(row.IsActive ? "Ja" : "Nein")}} {{row.RowCount}} {{Amount(row.SalesPriceValue)}} {{Html(row.Currencies)}} {{Html(period)}} {{Html(lastExport)}} {{Html(issue)}} """; })); return $$"""

Datenabdeckung je Standort

Diese Tabelle zeigt, welche Standorte in der App konfiguriert sind, welche Quelle sie nutzen und ob fuer 2025 bereits Daten in `CentralSalesRecords` liegen.

{{rows}}
Standort System Quelle / Pfad Aktiv Zeilen 2025 SalesPriceValue Waehrung Periode Letzter Export Hinweis
"""; } static string BuildCoverageIssue(SiteCoverageRow row) { if (!row.IsActive) return "Standort ist deaktiviert."; if (row.ConnectionKind == "MANUAL_EXCEL" && string.IsNullOrWhiteSpace(row.ManualImportPath)) return "Manual Excel/CSV-Pfad fehlt."; if (!string.IsNullOrWhiteSpace(row.LastExportError)) return row.LastExportError; if (row.RowCount == 0) return "Keine 2025-Daten in CentralSalesRecords. Export pruefen."; if (!string.IsNullOrWhiteSpace(row.LastExportStatus) && !row.LastExportStatus.Equals("OK", StringComparison.OrdinalIgnoreCase)) return $"Letzter Exportstatus: {row.LastExportStatus}."; return "Daten vorhanden."; } static string BuildDetailRows( IReadOnlyList rows, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv) { var detailRows = rows .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) .Select(row => (Label: row.Label, Html: BuildRow(row, excelReferences))) .ToList(); if (spainCsv is not null) { excelReferences.TryGetValue("Trafag ES", out var excelReference); detailRows.Add(("Trafag ES", BuildSpainDetailRow(spainCsv, excelReference))); } return string.Join( Environment.NewLine, detailRows .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) .Select(row => row.Html)); } static string BuildFinanceChiefOverview( IReadOnlyList rows, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv, GermanyExcelProbe? germanySample) { var issues = BuildFinanceChiefIssues(rows, excelReferences, spainCsv, germanySample).ToList(); var openIssues = issues .Where(issue => issue.Status != "OK") .OrderByDescending(issue => issue.SortValue) .ThenBy(issue => issue.Label, StringComparer.OrdinalIgnoreCase) .ToList(); var missingCount = openIssues.Count(issue => issue.Status == "Keine Daten"); var checkCount = openIssues.Count(issue => issue.Status == "Pruefen"); var largestDifference = openIssues .Where(issue => issue.Difference.HasValue) .Select(issue => Math.Abs(issue.Difference!.Value)) .DefaultIfEmpty(0m) .Max(); var tableRows = openIssues.Count == 0 ? """ Keine offenen Abweichungen. Alle vorhandenen Laender passen rechnerisch gegen den Sollwert. """ : string.Join(Environment.NewLine, openIssues.Select(BuildFinanceChiefIssueRow)); return $$"""

Finanzchef Übersicht

Kompakte Sicht nur auf offene Soll/Ist-Themen. Detailtabellen bleiben unten fuer Analyse und Nachvollzug.

{{openIssues.Count}}Offen
{{checkCount}}Abweichungen
{{missingCount}}Keine Daten
Groesste absolute Abweichung: {{Amount(largestDifference)}}
{{tableRows}}
Status Land Waehrung Ist Soll Abweichung Was ist zu pruefen
"""; } static IEnumerable BuildFinanceChiefIssues( IReadOnlyList rows, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv, GermanyExcelProbe? germanySample) { var issues = rows .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) .Select(row => new FinanceChiefIssue( row.Status, row.Label, row.Key, BuildFinanceChiefCurrency(row), row.ActualValue, row.ReferenceValue, row.Difference, BuildFinanceChiefReason(row, germanySample), row.Difference.HasValue ? Math.Abs(row.Difference.Value) : decimal.MaxValue)) .ToList(); if (spainCsv is not null) { var status = Math.Abs(spainCsv.Difference) <= 1m ? "OK" : "Pruefen"; issues.Add(new FinanceChiefIssue( status, "Trafag ES", "ES", "EUR", spainCsv.SalesPriceValue, spainCsv.ReferenceValue, spainCsv.Difference, status == "OK" ? "Spain CSV passt rechnerisch gegen check.xlsx." : "Spain CSV hat Differenz. Periodenabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften pruefen.", Math.Abs(spainCsv.Difference))); } var existingLabels = issues .Select(issue => issue.Label) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var reference in excelReferences.Values) { if (existingLabels.Contains(reference.Label)) continue; var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; issues.Add(new FinanceChiefIssue( "Keine Daten", reference.Label, "check.xlsx", reference.PowerBiValue.HasValue ? "Sollwert" : "LC", null, referenceValue, null, "Sollwert ist in check.xlsx vorhanden, aber es gibt keinen belastbaren Ist-Import. Standort, Export oder Aktivierung pruefen.", decimal.MaxValue)); } return issues; } static string BuildFinanceChiefIssueRow(FinanceChiefIssue issue) { var statusClass = issue.Status.Replace(" ", string.Empty); return $$""" {{Html(issue.Status)}} {{Html(issue.Label)}}
{{Html(issue.Key)}}
{{Html(issue.Currency)}} {{Amount(issue.ActualValue)}} {{Amount(issue.ReferenceValue)}} {{Amount(issue.Difference)}} {{Html(issue.Reason)}} """; } static string BuildFinanceChiefCurrency(NetSalesReferenceRow row) { var actualCurrency = string.IsNullOrWhiteSpace(row.ActualCurrency) ? row.Currencies : row.ActualCurrency; var referenceCurrency = row.ReferenceCurrency; if (string.IsNullOrWhiteSpace(actualCurrency) && string.IsNullOrWhiteSpace(referenceCurrency)) return "-"; if (string.IsNullOrWhiteSpace(referenceCurrency) || referenceCurrency.Equals("Sollwert", StringComparison.OrdinalIgnoreCase) || actualCurrency.Equals(referenceCurrency, StringComparison.OrdinalIgnoreCase)) return actualCurrency; if (string.IsNullOrWhiteSpace(actualCurrency)) return referenceCurrency; return $"{actualCurrency} / Soll {referenceCurrency}"; } static string BuildFinanceChiefReason(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) { if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase) && germanySample is not null) return "DE-Beispielfile ist lesbar, aber nur Sample. Finalen Jahresexport/Abgrenzung pruefen."; if (row.Status == "OK") return "Passt rechnerisch gegen check.xlsx."; if (row.Status == "Keine Daten") return "Kein belastbarer Ist-Import. Standort, Export, Mapping oder Aktivierung pruefen."; if (row.DifferenceExcludingIntercompany.HasValue && Math.Abs(row.DifferenceExcludingIntercompany.Value) <= 1m) return "Abweichung ist nach 2nd-party/Intercompany-Abzug erklaerbar. IC-Regel fachlich bestaetigen."; if (row.Candidates.Count > 1) return "Abweichung offen. Gewaehlter Wert folgt Hauswaehrung/Nettofakturawert; alternative Summen sind in Details sichtbar."; return "Abweichung offen. Quelle, Periodenabgrenzung, Gutschriften und 2nd-party/3rd-party-Abgrenzung pruefen."; } static string BuildExecutiveBriefing( IReadOnlyList rows, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv, GermanyExcelProbe? germanySample) { var briefingRows = rows .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) .Select(row => (Label: row.Label, Html: BuildExecutiveRow(row, germanySample))) .ToList(); if (spainCsv is not null) briefingRows.Add(("Trafag ES", BuildSpainExecutiveRow(spainCsv))); var existingLabels = briefingRows .Select(row => row.Label) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var reference in excelReferences.Values) { if (existingLabels.Contains(reference.Label)) continue; briefingRows.Add((reference.Label, BuildMissingExecutiveRow(reference))); } var tableRows = string.Join( Environment.NewLine, briefingRows .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) .Select(row => row.Html)); return $$"""

Meeting Ampel 2025

Gruen = Zahl passt rechnerisch. Gelb = Differenz oder fachliche Abgrenzung offen. Grau = keine belastbaren Importdaten. Fachliche Regel: Net Sales Actuals werden in Hauswaehrung aus dem Nettofakturawert abgegrenzt; CHF-Ausweis nutzt Budgetkurse 2025 und wird pro Belegposition gerechnet, sobald die Positionswerte in Hauswaehrung verfuegbar sind.

{{tableRows}}
Ampel Land Ist Soll Differenz Passender Wert Waehrung / CHF Warum / offen
"""; } static string BuildMissingExecutiveRow(CheckedExcelReference reference) { var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; var source = reference.PowerBiValue.HasValue ? "Sollwert" : "LC"; return $$""" Grau {{Html(reference.Label)}}
check.xlsx
- {{Amount(referenceValue)}} - Kein Ist-Import (check.xlsx {{Html(source)}}) Waehrung aus Quelle noch nicht belegbar. CHF nur wenn check.xlsx-Spalte CHF verwendet wird. In check.xlsx vorhanden, aber im aktuellen Import/aktiven Standort nicht belastbar. Export oder Standortaktivierung pruefen. """; } static string BuildExecutiveRow(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) { var ampelClass = row.Status switch { "OK" => "ampel-ok", "Pruefen" => "ampel-check", _ => "ampel-missing" }; var ampelText = row.Status switch { "OK" => "Gruen", "Pruefen" => "Gelb", _ => "Grau" }; var matchingValue = string.IsNullOrWhiteSpace(row.ValueField) ? "Noch kein Wert gewaehlt" : $"{row.ValueField} ({row.ReferenceSource})"; return $$""" {{ampelText}} {{Html(row.Label)}}
{{Html(row.Key)}}
{{Amount(row.ActualValue)}} {{Amount(row.ReferenceValue)}} {{Amount(row.Difference)}} {{Html(matchingValue)}} {{Html(BuildCurrencyNote(row))}} {{Html(BuildExecutiveReason(row, germanySample))}} """; } static string BuildSpainExecutiveRow(SpainSalesCsvProbe spainCsv) { var ampelClass = Math.Abs(spainCsv.Difference) <= 1m ? "ampel-ok" : "ampel-check"; var ampelText = Math.Abs(spainCsv.Difference) <= 1m ? "Gruen" : "Gelb"; return $$""" {{ampelText}} Trafag ES
ES / Sage Spain v2
{{Amount(spainCsv.SalesPriceValue)}} {{Amount(spainCsv.ReferenceValue)}} {{Amount(spainCsv.Difference)}} SalesPriceValue aus Spain_Sales_2025.csv EUR Hauswaehrung. CHF ueber Budgetkurs 2025. Export technisch lesbar, aber noch Differenz. Klaeren: Datumsabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften. """; } static string BuildCurrencyNote(NetSalesReferenceRow row) { var actualCurrency = row.ActualCurrency.Trim(); var currencies = row.Currencies.Trim(); if (string.IsNullOrWhiteSpace(actualCurrency) && string.IsNullOrWhiteSpace(currencies)) return "Waehrung noch nicht belegt."; if (actualCurrency.Contains("CHF", StringComparison.OrdinalIgnoreCase) && !actualCurrency.Contains(',', StringComparison.Ordinal)) { return "CHF direkt aus Quelle."; } if (actualCurrency.Contains(',', StringComparison.Ordinal) || currencies.Contains(',', StringComparison.Ordinal)) return $"Gemischte Quellwaehrungen ({PreferNonBlank(actualCurrency, currencies)}). Fachlich ist Hauswaehrung fuehrend; Mapping/Quelle pruefen."; return $"{PreferNonBlank(actualCurrency, currencies)} Hauswaehrung. CHF ueber Budgetkurs 2025."; } static string BuildExecutiveReason(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) { if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase) && germanySample is not null) { return $"DE-Beispielfile gefunden und lesbar: {germanySample.RowsWithAmount} Betragszeilen, Summe {Amount(germanySample.SalesPriceValue)} {germanySample.Currencies}. Das ist ein Sample, kein finaler Jahresexport."; } if (row.Status == "OK") return "Passt rechnerisch gegen check.xlsx. Hauswaehrung ist fachlich fuehrend."; if (row.Status == "Keine Daten") return "Keine belastbaren Daten im Import. Standort/Export/Mapping pruefen."; if (row.DifferenceExcludingIntercompany.HasValue && Math.Abs(row.DifferenceExcludingIntercompany.Value) <= 1m) { return "Differenz ist nach 2nd-party/Intercompany-Abzug rechnerisch erklaerbar. IC-Kunden sollen spaeter als eigenes Feld gepflegt werden."; } if (row.Candidates.Count > 1) return "Mehrere technische Summen sichtbar. Gewaehlter Wert folgt der Fachregel: Hauswaehrung / Nettofakturawert."; return "Differenz offen. Quelle, Periodenabgrenzung, Gutschriften und 2nd-party/3rd-party-Abgrenzung pruefen."; } static string PreferNonBlank(string first, string second) => !string.IsNullOrWhiteSpace(first) ? first : second; static string BuildGermanySampleSection( GermanyExcelProbe? germanySample, IReadOnlyDictionary excelReferences) { if (germanySample is null) { return """
Germany Excel Keine DE_Beispiel_Export_Daten.xlsx im Repo gefunden.
"""; } excelReferences.TryGetValue("Trafag DE", out var reference); var referenceValue = reference?.PowerBiValue ?? reference?.LocalCurrencyValue; var difference = referenceValue.HasValue ? germanySample.SalesPriceValue - referenceValue.Value : (decimal?)null; return $$"""

Germany Excel sample check

{{germanySample.RowsWithAmount}}Betragszeilen
{{Amount(germanySample.SalesPriceValue)}}NettoPreisGesamtX {{Html(germanySample.Currencies)}}
{{Amount(referenceValue)}}check.xlsx DE Referenz
{{Amount(difference)}}Differenz nur Sample
Datei: {{Html(germanySample.Path)}}
Interpretation: Mapping funktioniert technisch. Diese Datei heisst Beispielfile und enthaelt nur {{germanySample.RowsWithAmount}} Betragszeilen; sie darf deshalb nicht als finale Deutschland-Jahreszahl verwendet werden.
"""; } static string BuildSpainCsvSection(SpainSalesCsvProbe? spainCsv) { if (spainCsv is null) { return """
Spain CSV Keine Spain_Sales_2025.csv im Repo gefunden.
"""; } var documentRows = string.Join(Environment.NewLine, spainCsv.ByDocumentType.Select(group => $$""" {{Html(group.Label)}}{{group.Rows}}{{Amount(group.Sales)}} """)); var seriesRows = string.Join(Environment.NewLine, spainCsv.BySeries.Select(group => $$""" {{Html(group.Label)}}{{group.Rows}}{{Amount(group.Sales)}} """)); return $$"""

Spain CSV direct check

{{spainCsv.Rows}}CSV-Zeilen
{{Amount(spainCsv.SalesPriceValue)}}SalesPriceValue EUR
{{Amount(spainCsv.ReferenceValue)}}check.xlsx ES
{{Amount(spainCsv.Difference)}}Differenz
Datei: {{Html(spainCsv.Path)}}
{{documentRows}}
DocumentTypeZeilenSales
{{seriesRows}}
InvoiceSeriesZeilenSales
"""; } static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary excelReferences) { var statusClass = row.Status.Replace(" ", string.Empty); excelReferences.TryGetValue(row.Label, out var excelReference); return $$""" {{Html(row.Status)}} {{Html(row.Label)}}
{{Html(row.Key)}} / {{Html(row.ReferenceSource)}}
{{Html(row.ValueField)}} {{Html(row.ActualCurrency)}} {{Amount(row.ActualValue)}} {{Html(row.ReferenceCurrency)}} {{Amount(row.ReferenceValue)}} {{Amount(excelReference?.LocalCurrencyValue)}} {{Amount(excelReference?.ChfValue)}} {{Amount(excelReference?.PowerBiValue)}} {{Html(excelReference?.Status)}} {{Amount(row.Difference)}} {{Amount(row.DifferenceExcludingIntercompany)}} {{Html(row.Currencies)}} {{row.RowCount}} {{BuildCandidateDetails(row)}} """; } static string BuildSpainDetailRow(SpainSalesCsvProbe spainCsv, CheckedExcelReference? excelReference) { var status = Math.Abs(spainCsv.Difference) <= 1m ? "OK" : "Pruefen"; return $$""" {{status}} Trafag ES
ES / Sage Spain v2 CSV
SalesPriceValue CSV EUR {{Amount(spainCsv.SalesPriceValue)}} LC {{Amount(spainCsv.ReferenceValue)}} {{Amount(excelReference?.LocalCurrencyValue)}} {{Amount(excelReference?.ChfValue)}} {{Amount(excelReference?.PowerBiValue)}} {{Html(excelReference?.Status)}} {{Amount(spainCsv.Difference)}} - EUR {{spainCsv.Rows}} CSV-Details anzeigen """; } static string BuildCandidateDetails(NetSalesReferenceRow row) { if (row.Candidates.Count == 0) return "Keine Varianten"; var candidateRows = string.Join(Environment.NewLine, row.Candidates.Select(candidate => $$""" {{Html(candidate.Label)}} {{Html(candidate.Currency)}} {{Amount(candidate.Value)}} {{Amount(candidate.Difference)}} {{Amount(candidate.IntercompanyValue)}} {{Amount(candidate.DifferenceExcludingIntercompany)}} """)); return $$"""
{{row.Candidates.Count}} Varianten anzeigen {{candidateRows}}
Abgrenzung Waehrung Wert Diff. 2nd-party/IC Diff. ohne 2nd-party
"""; } static string Amount(decimal? value) => value.HasValue ? value.Value.ToString("#,##0.00", CultureInfo.GetCultureInfo("de-CH")) : "-"; static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); sealed record FinanceChiefIssue( string Status, string Label, string Key, string Currency, decimal? ActualValue, decimal? ReferenceValue, decimal? Difference, string Reason, decimal SortValue); sealed class CheckedExcelReference { public string Label { get; set; } = string.Empty; public decimal? LocalCurrencyValue { get; set; } public decimal? ChfValue { get; set; } public decimal? PowerBiValue { get; set; } public string Status { get; set; } = string.Empty; } sealed class SpainSalesCsvProbe { public string Path { get; set; } = string.Empty; public int Rows { get; set; } public decimal SalesPriceValue { get; set; } public decimal ReferenceValue { get; set; } public decimal Difference { get; set; } public List ByDocumentType { get; set; } = []; public List BySeries { get; set; } = []; } sealed record SpainSalesCsvGroup(string Label, int Rows, decimal Sales); sealed class GermanyExcelProbe { public string Path { get; set; } = string.Empty; public int RowsWithAmount { get; set; } public decimal SalesPriceValue { get; set; } public int RowsIn2025 { get; set; } public decimal SalesPriceValueIn2025 { get; set; } public string Currencies { get; set; } = string.Empty; } sealed class SiteCoverageRow { public string Land { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty; public string SourceSystem { get; set; } = string.Empty; public string SourceDisplayName { get; set; } = string.Empty; public string ConnectionKind { get; set; } = string.Empty; public bool IsActive { get; set; } public string ManualImportPath { get; set; } = string.Empty; public int RowCount { get; set; } public decimal? SalesPriceValue { get; set; } public DateTime? MinDate { get; set; } public DateTime? MaxDate { get; set; } public string Currencies { get; set; } = string.Empty; public string LastExportStatus { get; set; } = string.Empty; public DateTime? LastExportAt { get; set; } public string LastExportError { get; set; } = string.Empty; }