Add finance probe Spain reconciliation updates

This commit is contained in:
2026-05-07 14:08:54 +02:00
parent 7442d45d9c
commit 6717843f18
12 changed files with 1583 additions and 21 deletions
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Net;
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using Microsoft.VisualBasic.FileIO;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
@@ -19,7 +20,9 @@ app.MapGet("/finance", async (IFinanceReconciliationService finance) =>
{
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath());
return Results.Content(BuildPage(rows, databasePath, excelReferences), "text/html; charset=utf-8");
var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath());
var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath());
return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample), "text/html; charset=utf-8");
});
app.Run();
@@ -63,6 +66,58 @@ static string? ResolveCheckedExcelPath()
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<string, CheckedExcelReference> LoadCheckedExcelReferences(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
@@ -99,16 +154,199 @@ static decimal? ReadNullableDecimal(IXLCell cell)
return cell.TryGetValue<decimal>(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<string>(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<decimal>(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<DateTime>(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<string, (int Rows, decimal Sales)>(StringComparer.OrdinalIgnoreCase);
var bySeries = new Dictionary<string, (int Rows, decimal Sales)>(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<string, (int Rows, decimal Sales)> 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<NetSalesReferenceRow> rows,
string databasePath,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences)
IReadOnlyDictionary<string, CheckedExcelReference> 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 $$"""
<!doctype html>
@@ -156,6 +394,21 @@ static string BuildPage(
gap: 12px;
line-height: 1.5;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
nav a {
color: #1f4f7a;
text-decoration: none;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
background: #f8fafc;
font-weight: 600;
}
main { padding: 18px 24px 28px; }
.summary {
display: grid;
@@ -228,6 +481,46 @@ static string BuildPage(
position: static;
}
.small { color: var(--muted); font-size: 12px; }
.briefing {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
margin-bottom: 14px;
}
.briefing h2 {
margin: 0 0 6px;
font-size: 18px;
letter-spacing: 0;
}
.briefing-note {
color: var(--muted);
margin: 0 0 10px;
line-height: 1.45;
}
.ampel {
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
font-weight: 650;
}
.ampel::before {
content: "";
width: 12px;
height: 12px;
border-radius: 999px;
display: inline-block;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
}
.ampel-ok::before { background: #168a48; }
.ampel-check::before { background: #e6a100; }
.ampel-missing::before { background: #9aa4b2; }
.wrap {
min-width: 240px;
max-width: 420px;
line-height: 1.35;
}
@media (max-width: 900px) {
main, header { padding-left: 12px; padding-right: 12px; }
.summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
@@ -239,20 +532,27 @@ static string BuildPage(
<header>
<h1>Finance Probe - Net Sales Actuals 2025</h1>
<div class="meta">
<span>Vergleich gegen geprüfte Referenzwerte aus check.xlsx / Power BI Stand 29.04.2026</span>
<span>Vergleich gegen gepruefte Sollwerte aus check.xlsx Stand 29.04.2026</span>
<span>DB: {{Html(databasePath)}}</span>
<span>Excel-Referenzen gelesen: {{excelCount}}</span>
<span>Aktualisiert: {{Html(generatedAt)}}</span>
</div>
<nav aria-label="Finance Probe Navigation">
<a href="#briefing">Meeting Ampel</a>
<a href="#all-sites">Detail alle Laender</a>
<a href="#germany-sample">Germany Excel</a>
<a href="#spain-csv">Spain CSV</a>
</nav>
</header>
<main>
{{executiveBriefing}}
<section class="summary">
<div class="metric"><strong>{{rows.Count}}</strong><span>Standorte</span></div>
<div class="metric"><strong>{{okCount}}</strong><span>OK</span></div>
<div class="metric"><strong>{{checkCount}}</strong><span>Pruefen</span></div>
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
</section>
<div class="table-wrap">
<div id="all-sites" class="table-wrap">
<table>
<thead>
<tr>
@@ -265,26 +565,304 @@ static string BuildPage(
<th class="num">Referenz</th>
<th class="num">Excel LC</th>
<th class="num">Excel CHF</th>
<th class="num">Excel Power BI</th>
<th class="num">Excel Sollwert</th>
<th>Excel Status</th>
<th class="num">Differenz</th>
<th class="num">Ohne IC Diff.</th>
<th class="num">Ohne 2nd-party Diff.</th>
<th>Waehrung</th>
<th class="num">Zeilen</th>
<th>Varianten</th>
</tr>
</thead>
<tbody>
{{string.Join(Environment.NewLine, rows.Select(row => BuildRow(row, excelReferences)))}}
{{detailRows}}
</tbody>
</table>
</div>
{{germanySampleSection}}
{{spainCsvSection}}
</main>
</body>
</html>
""";
}
static string BuildDetailRows(
IReadOnlyList<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> 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<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> 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 $$"""
<section id="briefing" class="briefing">
<h2>Meeting Ampel 2025</h2>
<p class="briefing-note">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.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ampel</th>
<th>Land</th>
<th class="num">Ist</th>
<th class="num">Soll</th>
<th class="num">Differenz</th>
<th>Passender Wert</th>
<th>Waehrung / CHF</th>
<th>Warum / offen</th>
</tr>
</thead>
<tbody>{{tableRows}}</tbody>
</table>
</div>
</section>
""";
}
static string BuildMissingExecutiveRow(CheckedExcelReference reference)
{
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
var source = reference.PowerBiValue.HasValue ? "Sollwert" : "LC";
return $$"""
<tr>
<td><span class="ampel ampel-missing">Grau</span></td>
<td><strong>{{Html(reference.Label)}}</strong><div class="small">check.xlsx</div></td>
<td class="num">-</td>
<td class="num">{{Amount(referenceValue)}}</td>
<td class="num">-</td>
<td>Kein Ist-Import (check.xlsx {{Html(source)}})</td>
<td class="wrap">Waehrung aus Quelle noch nicht belegbar. CHF nur wenn check.xlsx-Spalte CHF verwendet wird.</td>
<td class="wrap">In check.xlsx vorhanden, aber im aktuellen Import/aktiven Standort nicht belastbar. Export oder Standortaktivierung pruefen.</td>
</tr>
""";
}
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 $$"""
<tr>
<td><span class="ampel {{ampelClass}}">{{ampelText}}</span></td>
<td><strong>{{Html(row.Label)}}</strong><div class="small">{{Html(row.Key)}}</div></td>
<td class="num">{{Amount(row.ActualValue)}}</td>
<td class="num">{{Amount(row.ReferenceValue)}}</td>
<td class="num">{{Amount(row.Difference)}}</td>
<td>{{Html(matchingValue)}}</td>
<td class="wrap">{{Html(BuildCurrencyNote(row))}}</td>
<td class="wrap">{{Html(BuildExecutiveReason(row, germanySample))}}</td>
</tr>
""";
}
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 $$"""
<tr>
<td><span class="ampel {{ampelClass}}">{{ampelText}}</span></td>
<td><strong>Trafag ES</strong><div class="small">ES / Sage Spain v2</div></td>
<td class="num">{{Amount(spainCsv.SalesPriceValue)}}</td>
<td class="num">{{Amount(spainCsv.ReferenceValue)}}</td>
<td class="num">{{Amount(spainCsv.Difference)}}</td>
<td>SalesPriceValue aus Spain_Sales_2025.csv</td>
<td class="wrap">EUR Hauswaehrung. CHF ueber Budgetkurs 2025.</td>
<td class="wrap">Export technisch lesbar, aber noch Differenz. Klaeren: Datumsabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften.</td>
</tr>
""";
}
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<string, CheckedExcelReference> excelReferences)
{
if (germanySample is null)
{
return """
<section id="germany-sample" class="metric" style="margin-top:14px;">
<strong>Germany Excel</strong>
<span>Keine DE_Beispiel_Export_Daten.xlsx im Repo gefunden.</span>
</section>
""";
}
excelReferences.TryGetValue("Trafag DE", out var reference);
var referenceValue = reference?.PowerBiValue ?? reference?.LocalCurrencyValue;
var difference = referenceValue.HasValue ? germanySample.SalesPriceValue - referenceValue.Value : (decimal?)null;
return $$"""
<section id="germany-sample" style="margin-top:18px;">
<h2 style="font-size:18px;margin:0 0 8px;">Germany Excel sample check</h2>
<div class="summary">
<div class="metric"><strong>{{germanySample.RowsWithAmount}}</strong><span>Betragszeilen</span></div>
<div class="metric"><strong>{{Amount(germanySample.SalesPriceValue)}}</strong><span>NettoPreisGesamtX {{Html(germanySample.Currencies)}}</span></div>
<div class="metric"><strong>{{Amount(referenceValue)}}</strong><span>check.xlsx DE Referenz</span></div>
<div class="metric"><strong>{{Amount(difference)}}</strong><span>Differenz nur Sample</span></div>
</div>
<div class="small">Datei: {{Html(germanySample.Path)}}</div>
<div class="small">Interpretation: Mapping funktioniert technisch. Diese Datei heisst Beispielfile und enthaelt nur {{germanySample.RowsWithAmount}} Betragszeilen; sie darf deshalb nicht als finale Deutschland-Jahreszahl verwendet werden.</div>
</section>
""";
}
static string BuildSpainCsvSection(SpainSalesCsvProbe? spainCsv)
{
if (spainCsv is null)
{
return """
<section id="spain-csv" class="metric" style="margin-top:14px;">
<strong>Spain CSV</strong>
<span>Keine Spain_Sales_2025.csv im Repo gefunden.</span>
</section>
""";
}
var documentRows = string.Join(Environment.NewLine, spainCsv.ByDocumentType.Select(group => $$"""
<tr><td>{{Html(group.Label)}}</td><td class="num">{{group.Rows}}</td><td class="num">{{Amount(group.Sales)}}</td></tr>
"""));
var seriesRows = string.Join(Environment.NewLine, spainCsv.BySeries.Select(group => $$"""
<tr><td>{{Html(group.Label)}}</td><td class="num">{{group.Rows}}</td><td class="num">{{Amount(group.Sales)}}</td></tr>
"""));
return $$"""
<section id="spain-csv" style="margin-top:18px;">
<h2 style="font-size:18px;margin:0 0 8px;">Spain CSV direct check</h2>
<div class="summary">
<div class="metric"><strong>{{spainCsv.Rows}}</strong><span>CSV-Zeilen</span></div>
<div class="metric"><strong>{{Amount(spainCsv.SalesPriceValue)}}</strong><span>SalesPriceValue EUR</span></div>
<div class="metric"><strong>{{Amount(spainCsv.ReferenceValue)}}</strong><span>check.xlsx ES</span></div>
<div class="metric"><strong>{{Amount(spainCsv.Difference)}}</strong><span>Differenz</span></div>
</div>
<div class="small">Datei: {{Html(spainCsv.Path)}}</div>
<div class="table-wrap" style="margin-top:10px;">
<table>
<thead><tr><th>DocumentType</th><th class="num">Zeilen</th><th class="num">Sales</th></tr></thead>
<tbody>{{documentRows}}</tbody>
</table>
</div>
<div class="table-wrap" style="margin-top:10px;">
<table>
<thead><tr><th>InvoiceSeries</th><th class="num">Zeilen</th><th class="num">Sales</th></tr></thead>
<tbody>{{seriesRows}}</tbody>
</table>
</div>
</section>
""";
}
static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary<string, CheckedExcelReference> excelReferences)
{
var statusClass = row.Status.Replace(" ", string.Empty);
@@ -312,6 +890,32 @@ static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary<string, Che
""";
}
static string BuildSpainDetailRow(SpainSalesCsvProbe spainCsv, CheckedExcelReference? excelReference)
{
var status = Math.Abs(spainCsv.Difference) <= 1m ? "OK" : "Pruefen";
return $$"""
<tr>
<td><span class="status {{status}}">{{status}}</span></td>
<td><strong>Trafag ES</strong><div class="small">ES / Sage Spain v2 CSV</div></td>
<td>SalesPriceValue CSV</td>
<td>EUR</td>
<td class="num">{{Amount(spainCsv.SalesPriceValue)}}</td>
<td>LC</td>
<td class="num">{{Amount(spainCsv.ReferenceValue)}}</td>
<td class="num">{{Amount(excelReference?.LocalCurrencyValue)}}</td>
<td class="num">{{Amount(excelReference?.ChfValue)}}</td>
<td class="num">{{Amount(excelReference?.PowerBiValue)}}</td>
<td>{{Html(excelReference?.Status)}}</td>
<td class="num">{{Amount(spainCsv.Difference)}}</td>
<td class="num">-</td>
<td>EUR</td>
<td class="num">{{spainCsv.Rows}}</td>
<td><a href="#spain-csv">CSV-Details anzeigen</a></td>
</tr>
""";
}
static string BuildCandidateDetails(NetSalesReferenceRow row)
{
if (row.Candidates.Count == 0)
@@ -338,8 +942,8 @@ static string BuildCandidateDetails(NetSalesReferenceRow row)
<th>Waehrung</th>
<th class="num">Wert</th>
<th class="num">Diff.</th>
<th class="num">IC</th>
<th class="num">Diff. ohne IC</th>
<th class="num">2nd-party/IC</th>
<th class="num">Diff. ohne 2nd-party</th>
</tr>
</thead>
<tbody>{{candidateRows}}</tbody>
@@ -362,3 +966,26 @@ sealed class CheckedExcelReference
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<SpainSalesCsvGroup> ByDocumentType { get; set; } = [];
public List<SpainSalesCsvGroup> 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;
}