using System.Globalization; using System.Net; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Services; var builder = WebApplication.CreateBuilder(args); var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]); builder.Services.AddDbContextFactory(options => options.UseSqlite($"Data Source={databasePath};Default Timeout=60")); builder.Services.AddSingleton(); var app = builder.Build(); app.MapGet("/", () => Results.Redirect("/finance")); app.MapGet("/finance", async (IFinanceReconciliationService finance) => { var rows = await finance.BuildNetSalesReferenceRowsAsync(2025); var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath()); var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath()); var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath()); return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample), "text/html; charset=utf-8"); }); app.Run(); 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 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) { 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 executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample); var detailRows = BuildDetailRows(rows, excelReferences, spainCsv); 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)}}
{{executiveBriefing}}
{{rows.Count}}Standorte
{{okCount}}OK
{{checkCount}}Pruefen
{{missingCount}}Keine Daten
{{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
{{germanySampleSection}} {{spainCsvSection}}
"""; } 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 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 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; }