diff --git a/TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx b/TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx new file mode 100644 index 0000000..6bf32dd Binary files /dev/null and b/TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx differ diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 1d6034a..17daff1 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -1,6 +1,123 @@ # TrafagSalesExporter Handoff -Stand: 2026-04-15 +Stand: 2026-05-05 + +## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich + +Der aktuelle Arbeitsstand fuer den naechsten Einstieg ist der lokale FinanceProbe: + +```text +http://localhost:55417/finance +``` + +Der FinanceProbe wurde als Meeting-Ansicht erweitert: + +- `Meeting Ampel 2025` +- `Detail alle Laender` +- `Germany Excel sample check` +- `Spain CSV direct check` + +Ampel-Bedeutung: + +- Gruen: Ist/Soll passt rechnerisch gegen Referenz. +- Gelb: technische Daten vorhanden, aber Differenz oder fachliche Abgrenzung offen. +- Grau: keine belastbaren Ist-Daten im aktuellen Import. + +Wichtige Waehrungsregel fuer Management-Aussage: + +- Wenn die Quelle CHF liefert, kann CHF direkt als CHF gezeigt werden. +- Wenn die Quelle EUR/USD/GBP/INR usw. liefert, ist es Mandanten- bzw. Originalwaehrung. +- CHF-Ausweis braucht dann eine separate FX-Regel oder einen offiziell bestaetigten Kurs. + +### Spanien + +Vorhandene finale Kandidatendatei: + +```text +sagespain/v2/Spain_Sales_2025.csv +``` + +FinanceProbe liest diese Datei direkt. + +Aktueller Stand: + +- Zeilen: `4'341` +- Ist `SalesPriceValue`: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen + +Technisch: + +- `ManualExcelImportService` kann jetzt semikolongetrennte CSV-Dateien lesen. +- Spanien-v2-CSV kann damit als `MANUAL_EXCEL` importiert werden. +- In der Detailtabelle wird Spanien nicht mehr als `Keine Daten` gezeigt, sondern als `Pruefen` mit dem v2-CSV-Wert. + +Offen fachlich: + +- Periodenlogik: `FechaFactura` vs. andere Datumsfelder +- Serien: `REG`, `LAT`, `PRO`, `REC` +- Behandlung von Gutschriften / `REC` +- offizielle Sage-Auswertung mit identischem Filter zur Sollzahl + +### Deutschland + +Vorhandenes Beispielfile: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Wichtig: + +- Das File ist ein Beispielfile, keine finale DE-Jahresdatei. +- Es darf nicht als finale Ist-Zahl gegen die Jahresreferenz verwendet werden. + +Technischer Check: + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe: `8'290.70` EUR +- Waehrung: `EUR` + +Interpretation: + +- Deutschland-Format ist technisch verstanden. +- Mapping funktioniert. +- Finale DE-Zahl fehlt noch. +- Fuer Abschluss/Meeting wird ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf benoetigt. + +### Geaenderte wichtige Dateien + +- `Tools/FinanceProbe/Program.cs` + - Management-Ampel + - Spanien-v2-CSV-Direktcheck + - Deutschland-Beispielfile-Check +- `Services/ManualExcelImportService.cs` + - CSV-Support fuer manuelle Quellen +- `Services/DatabaseSeedService.cs` + - deaktivierter Spanien-Standort als Seed/Fallback +- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs` + - Tests fuer CSV/Mapping +- `SAGE_SPAIN_EXPORT_2026-05-05.md` + - Spanien-Doku +- `lastchange.md` + - chronologischer Abschlussstand + +### Letzte Verifikation + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- FinanceProbe liefert `HTTP 200` ## Nachtrag 2026-04-29 Dashboard-Referenzcheck Net Sales 2025 diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md index 2b1c194..79f944c 100644 --- a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -1,6 +1,50 @@ # TrafagSalesExporter LLM System Guide -Stand: 2026-04-17 +Stand: 2026-05-05 + +## Aktueller Projektstand 2026-05-05 + +Fuer den aktuellen Finance-/Laenderabgleich zuerst diese Dateien lesen: + +- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md) +- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md) +- [lastchange.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/lastchange.md) +- [SAGE_SPAIN_EXPORT_2026-05-05.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md) + +Lokaler FinanceProbe: + +```text +http://localhost:55417/finance +``` + +Aktuelle FinanceProbe-Funktionen: + +- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx` +- `Detail alle Laender` +- `Spain CSV direct check` +- `Germany Excel sample check` + +Spanien: + +- Datei: `sagespain/v2/Spain_Sales_2025.csv` +- Ist: `3'082'320.18` EUR +- Soll: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen +- Technisch lesbar, fachliche Differenz noch offen + +Deutschland: + +- Datei: `DE_Beispiel_Export_Daten.xlsx` +- Sample-Summe `NettoPreisGesamtX`: `8'290.70` EUR +- Nur Beispielfile, keine finale Jahreszahl +- Mapping technisch verstanden, finaler DE-Jahresfile fehlt + +Letzte Verifikation: + +- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore` +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore` +- Ergebnis: Build OK, Tests `50/50`, FinanceProbe `HTTP 200` Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen. diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index c198420..8c96f4f 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -1,6 +1,77 @@ # Next Steps -Stand: 2026-04-15 +Stand: 2026-05-05 + +## Nachtrag 2026-05-05 Abschlussstand FinanceProbe / Spanien / Deutschland + +Aktueller lokaler Testpunkt: + +```text +http://localhost:55417/finance +``` + +FinanceProbe enthaelt jetzt: + +- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx` +- Ampel: + - Gruen: rechnerisch passend + - Gelb: Differenz oder fachliche Abgrenzung offen + - Grau: keine belastbaren Ist-Daten +- `Detail alle Laender` +- `Spain CSV direct check` +- `Germany Excel sample check` + +Spanien: + +- finale v2-Datei liegt unter `sagespain/v2/Spain_Sales_2025.csv` +- Zeilen: `4'341` +- Ist `SalesPriceValue`: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen +- Export technisch lesbar, Differenz fachlich mit Spanien/Finance klaeren + +Deutschland: + +- Beispielfile liegt im Projektordner: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe: `8'290.70` EUR +- das ist nur ein Sample, keine finale DE-Jahreszahl +- Deutschland bleibt fuer die finale Ampel offen/grau, bis ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf vorliegt + +Offen fuer das Finance-Meeting / danach: + +1. Spanien Differenz `-20'013.43` klaeren: + - Periodendatum + - Serien `REG`, `LAT`, `PRO`, `REC` + - Gutschriften / `REC` + - offizielle Sage-Auswertung mit identischem Filter +2. Deutschland finalen Jahresfile 2025 anfordern oder Importlauf mit finaler Datei ausfuehren. +3. Fuer Laender mit Grau pruefen, ob Exportdaten fehlen oder Standort deaktiviert/ohne Datei ist. +4. Fuer CHF-Aussage beachten: + - CHF nur direkt, wenn Quelle CHF liefert + - sonst Mandanten-/Originalwaehrung und separate FX-Regel noetig + +Letzte Verifikation: + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- Web UI `HTTP 200` ## Nachtrag 2026-04-29 Dashboard-Referenzcheck diff --git a/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md b/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md new file mode 100644 index 0000000..2d37161 --- /dev/null +++ b/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md @@ -0,0 +1,234 @@ +# Sage Spain Export + +Stand: 2026-05-05 + +## Aktueller Kurzstatus + +- Spanien-v2-Export ist technisch lauffaehig und im Testprogramm sichtbar. +- Datei: `sagespain/v2/Spain_Sales_2025.csv` +- Ist 2025: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status FinanceProbe: Gelb / Pruefen +- Finale Aussage: technisch importierbar, aber fachlich noch nicht abgestimmt. + +FinanceProbe lokal: + +```text +http://localhost:55417/finance +``` + +Relevante Abschnitte: + +- `Meeting Ampel 2025` +- `Detail alle Laender` +- `Spain CSV direct check` + +Wichtig: + +- Spanien wird in der Detailtabelle nicht mehr als `Keine Daten` gezeigt, wenn `Spain_Sales_2025.csv` vorhanden ist. +- Stattdessen wird der v2-CSV-Wert mit Status `Pruefen` angezeigt. +- Die CSV-Datei kann spaeter als `MANUAL_EXCEL`-Quelle importiert werden. + +## Ziel + +Spanien soll Verkaufsdaten aus `Sage 200c` liefern koennen, damit der Standort in `TrafagSalesExporter` wie die anderen Laender in die zentrale Auswertung und Finance-Abgrenzung aufgenommen werden kann. + +## Systemstand Spanien + +Ermittelt mit `scripts/Get-SageSqlEnvironment.ps1`. + +- Windows Server: `Microsoft Windows Server 2019 Standard`, Build `17763` +- Server: `WIN-4BJQJ9S1PVJ` +- Sage: `Sage 200c` +- Sage-Version: `2026.56.000` +- SQL Server: `Microsoft SQL Server 2019 Standard Edition (64-bit)` +- SQL Build: `15.0.2155.2` +- SQL Full Version: `Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)` +- SQL Instance: Default Instance `MSSQLSERVER`, erreichbar als `localhost` +- Datenbank: `Sage` +- Collation: `Latin1_General_CI_AI` + +## Discovery + +Ermittelt mit `scripts/Export-SageSqlCsv.ps1`. + +Relevante Kandidaten: + +- `dbo.CabeceraAlbaranCliente` +- `dbo.LineasAlbaranCliente` +- `dbo.EstadisVenta` +- `dbo.EstadisVentaTallas` +- `dbo.FacturasTB` +- `dbo.MovimientosFacturas` +- `dbo.Vis_RTDV_EfectosFactura` + +Beobachtung: + +- `CabeceraAlbaranCliente` ist der Verkaufs-/Albaran-Belegkopf. +- `LineasAlbaranCliente` enthaelt die Verkaufspositionen. +- `EstadisVenta` enthaelt Statistikdaten, aber im gelieferten Export keine 2025-Zeilen. +- `FacturasTB` und `MovimientosFacturas` wirken eher Finanz-/Steuer-/Buchungsdaten und enthalten gemischte Bewegungen. + +## Export v2 + +Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt. + +Script: + +- `scripts/Export-SageSpainSalesCsv.ps1` + +Output von Spanien: + +- `sagespain/v2/Spain_Sales_2025.csv` +- `sagespain/v2/Spain_Sales_2025_summary.txt` + +Quelle: + +- Header: `dbo.CabeceraAlbaranCliente` +- Lines: `dbo.LineasAlbaranCliente` +- Join: + - `CodigoEmpresa` + - `EjercicioAlbaran` + - `SerieAlbaran` + - `NumeroAlbaran` + +Filter: + +- `CabeceraAlbaranCliente.FechaFactura >= 2025-01-01` +- `CabeceraAlbaranCliente.FechaFactura < 2026-01-01` + +Export-Spalten sind bereits auf das Zielmodell der App ausgerichtet, u. a.: + +- `TSC` +- `Land` +- `InvoiceNumber` +- `PositionOnInvoice` +- `Material` +- `Name` +- `ProductGroup` +- `Quantity` +- `CustomerNumber` +- `CustomerName` +- `CustomerCountry` +- `StandardCost` +- `StandardCostCurrency` +- `PurchaseOrderNumber` +- `SalesPriceValue` +- `SalesCurrency` +- `DocumentCurrency` +- `CompanyCurrency` +- `InvoiceDate` +- `DocumentType` + +## Ergebnis Export v2 + +Aus `Spain_Sales_2025_summary.txt`: + +- Zeilen: `4'341` +- `SalesPriceValue` Summe: `3'082'320.18` +- `SalesPriceValue` = `LineasAlbaranCliente.ImporteNeto` +- Waehrung: `EUR` + +Aufteilung: + +- Invoices: `3'140'921.50` +- Credit Notes / REC: `-58'601.32` +- Total: `3'082'320.18` + +Nach Serie: + +- `REG`: `2'407'451.30` +- `LAT`: `480'199.20` +- `PRO`: `253'271.00` +- `REC`: `-58'601.32` + +## Abgleich gegen check.xlsx + +Sollwert fuer Spanien aus `check.xlsx`: + +- `3'102'333.61` + +Aktueller Export v2: + +- `3'082'320.18` + +Differenz: + +- `-20'013.43` + +Fruehere breite Positionssumme aus `LineasAlbaranCliente.ImporteNeto` ohne Join-/Rechnungsdatumsfilter lag bei: + +- `3'094'474.32` +- Differenz zur Sollzahl: `-7'859.29` + +## Offene fachliche Klaerung + +Spanien / Finance muss noch klaeren, woher die Differenz kommt. + +Zu pruefen: + +1. Ist `FechaFactura` das korrekte Periodendatum? +2. Oder muss `FechaAlbaran` bzw. `FechaRegistro` verwendet werden? +3. Muessen Zeilen ohne `EjercicioFactura = 2025` in die Sollzahl? +4. Sind alle Serien `REG`, `LAT`, `PRO`, `REC` enthalten? +5. Muessen `REC`-Abos negativ abgezogen werden? +6. Gibt es weitere Serien oder Dokumenttypen ausserhalb `CabeceraAlbaranCliente` / `LineasAlbaranCliente`? +7. Gibt es eine offizielle Sage-Auswertung, die `3'102'333.61` erzeugt und deren Filter genannt werden koennen? + +## Einbau ins Hauptprogramm + +Umgesetzt: + +- `ManualExcelImportService` kann jetzt neben `.xlsx` auch semikolongetrennte `.csv`-Dateien lesen. +- Der CSV-Reader unterstuetzt quotierte Felder und mehrzeilige Texte. +- Das Spanien-v2-CSV ist damit als `MANUAL_EXCEL`-Quelle importierbar. +- `Tools/FinanceProbe` hat einen direkten `Spain CSV direct check`. + - Die Probe sucht automatisch nach `Spain_Sales_2025.csv`, bevorzugt unter `sagespain/v2`. + - Angezeigt werden Zeilen, `SalesPriceValue`, Sollwert `3'102'333.61`, Differenz, Aufteilung nach `DocumentType` und `InvoiceSeries`. + - Spanien wird in der FinanceProbe-Detailtabelle mit dem v2-CSV-Wert angezeigt, nicht mehr als `Keine Daten`. + - In der Management-Ampel bleibt Spanien gelb, bis die Differenz fachlich geklaert ist. +- `DatabaseSeedService` stellt einen deaktivierten Spanien-Standort bereit, falls noch kein Spanien-Standort existiert: + - `TSC = TRES` + - `Land = Spanien` + - `SourceSystem = MANUAL_EXCEL` + - `IsActive = false` + +Wichtig: + +- Das Programm setzt den Dateipfad nicht automatisch, weil der Pfad pro Umgebung unterschiedlich ist. +- In der UI muss beim Standort Spanien die Datei `Spain_Sales_2025.csv` hinterlegt werden. +- Danach kann Spanien wie ein manueller Standort exportiert werden; die Daten landen in `CentralSalesRecords`. + +## Naechster Schritt + +1. App starten. +2. `Standorte` oeffnen. +3. Spanien pruefen bzw. aktivieren. +4. `SourceSystem = MANUAL_EXCEL`. +5. `Spain_Sales_2025.csv` als manuelle Datei hinterlegen. +6. Standort Spanien exportieren. +7. Finance-Probe / Dashboard erneut pruefen. +8. Differenz zu `check.xlsx` fachlich mit Spanien/Finance klaeren. + +## Abgrenzung Deutschland + +Am selben Tag wurde auch ein Deutschland-Beispielfile gefunden: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Dieses File ist nicht Teil des Spanien-Exports, aber im FinanceProbe als separater `Germany Excel sample check` sichtbar. + +Deutschland-Sample: + +- relevante Spalte: `NettoPreisGesamtX` +- Summe: `8'290.70` EUR +- Betragszeilen: `2` +- Bewertung: technisch lesbar, aber kein finaler DE-Jahresfile + +Fuer die Gesamtampel heisst das: + +- Spanien: technische v2-Datei vorhanden, Differenz offen +- Deutschland: Format verstanden, aber finale Jahresdatei fehlt diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index 5fbbbc3..e10158d 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -33,6 +33,19 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService new("US", "Traga US", 3896728m, 3749865m) ]; + private static readonly IReadOnlyDictionary BudgetRatesToChf = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CHF"] = 1m, + ["USD"] = 0.85m, + ["EUR"] = 0.95m, + ["GBP"] = 1.13m, + ["CNY"] = 1m / 8.50m, + ["INR"] = 1m / 90.91m, + ["CZK"] = 1m / 25.64m, + ["PLN"] = 0.22m, + ["JPY"] = 1m / 156.25m + }; + public FinanceReconciliationService(IDbContextFactory dbFactory) { _dbFactory = dbFactory; @@ -84,11 +97,10 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService { groupedActuals.TryGetValue(reference.Key, out var actual); var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; - var selected = actual is null || !referenceValue.HasValue - ? actual?.Candidates.FirstOrDefault() - : actual.Candidates - .OrderBy(candidate => Math.Abs(candidate.Value - referenceValue.Value)) - .FirstOrDefault(); + var selected = actual?.Candidates + .OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency") + .ThenByDescending(candidate => candidate.Key == "SalesPriceValue") + .FirstOrDefault(); var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value; var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue ? (decimal?)null @@ -108,8 +120,8 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService Currencies = actual?.Currencies ?? string.Empty, ValueField = selected?.Label ?? string.Empty, ActualCurrency = selected?.Currency ?? string.Empty, - ReferenceSource = reference.PowerBiValue.HasValue ? "Power BI" : "LC", - ReferenceCurrency = reference.PowerBiValue.HasValue ? "Power BI Original" : "LC", + ReferenceSource = "check.xlsx Soll", + ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC", Status = BuildReferenceStatus(difference), Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow { @@ -161,15 +173,24 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService if (netDocumentLocalCurrency != 0m) candidates.Add(new( "NetDocumentLocalCurrency", - "DocTotal - VatSum", + "Nettofakturawert Hauswaehrung", ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)), netDocumentLocalCurrency, documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))); + var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)); + if (budgetChf != 0m) + candidates.Add(new( + "NetDocumentLocalCurrencyBudgetChf", + "Nettofakturawert Hauswaehrung -> CHF Budget 2025", + "CHF", + budgetChf, + documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)))); + return new NetSalesActual { RowCount = rowList.Count, - Currencies = string.Join(", ", rowList.Select(row => row.SalesCurrency) + Currencies = string.Join(", ", rowList.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)), @@ -177,6 +198,14 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService }; } + private static decimal ConvertHouseCurrencyNetToBudgetChf(NetSalesActualSourceRow row, decimal value) + { + var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant(); + return BudgetRatesToChf.TryGetValue(currency, out var rate) + ? value * rate + : 0m; + } + private static string ResolveCurrencyLabel(IEnumerable currencies) { var distinct = currencies @@ -202,6 +231,21 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName)) return false; + var normalizedCustomerName = customerName + .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) + .ToUpperInvariant(); + + if (normalizedCustomerName.Contains("TRAFAG", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("MAGNETS SENSE", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("GESELLSCHAFT FUER SENSORIK", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("GESELLSCHAFT FUR SENSORIK", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase)) { return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) || diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index 4087497..058e0b3 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; @@ -91,6 +92,9 @@ public class ManualExcelImportService : IManualExcelImportService private static List ReadSalesRecords(string filePath, Site site, IReadOnlyList mappings) { + if (string.Equals(Path.GetExtension(filePath), ".csv", StringComparison.OrdinalIgnoreCase)) + return ReadCsvSalesRecords(filePath, site, mappings); + using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheets.FirstOrDefault() ?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt."); @@ -109,6 +113,141 @@ public class ManualExcelImportService : IManualExcelImportService : ReadDefaultRows(usedRange, headerRow, site); } + private static List ReadCsvSalesRecords(string filePath, Site site, IReadOnlyList mappings) + { + using var parser = new TextFieldParser(filePath) + { + TextFieldType = FieldType.Delimited, + HasFieldsEnclosedInQuotes = true, + TrimWhiteSpace = false + }; + parser.SetDelimiters(";"); + + var header = parser.ReadFields() + ?? throw new InvalidOperationException("Die CSV-Datei enthaelt keine Kopfzeile."); + + var activeMappings = mappings + .Where(m => m.IsActive && !string.IsNullOrWhiteSpace(m.TargetField) && !string.IsNullOrWhiteSpace(m.SourceHeader)) + .OrderBy(m => m.SortOrder) + .ThenBy(m => m.Id) + .ToList(); + + return activeMappings.Count > 0 + ? ReadMappedCsvRows(parser, header, site, activeMappings) + : ReadDefaultCsvRows(parser, header, site); + } + + private static List ReadDefaultCsvRows(TextFieldParser parser, string[] header, Site site) + { + var headerIndexes = BuildHeaderIndexMap(header); + var rows = new List(); + + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || IsCsvRowEmpty(fields)) + continue; + + rows.Add(new SalesRecord + { + ExtractionDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, + Tsc = ReadString(headerIndexes, fields, nameof(SalesRecord.Tsc), site.TSC), + DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentEntry))), + InvoiceNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.InvoiceNumber)), + PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.PositionOnInvoice))), + Material = ReadString(headerIndexes, fields, nameof(SalesRecord.Material)), + Name = ReadString(headerIndexes, fields, nameof(SalesRecord.Name)), + ProductGroup = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductGroup)), + Quantity = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.Quantity)), + SupplierNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierNumber)), + SupplierName = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierName)), + SupplierCountry = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierCountry)), + CustomerNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerNumber)), + CustomerName = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerName)), + CustomerCountry = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerCountry)), + CustomerIndustry = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerIndustry)), + StandardCost = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.StandardCost)), + StandardCostCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.StandardCostCurrency)), + PurchaseOrderNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.PurchaseOrderNumber)), + SalesPriceValue = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.SalesPriceValue)), + SalesCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesCurrency)), + DocumentCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.DocumentCurrency)), + DocumentTotalForeignCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentTotalForeignCurrency)), + DocumentTotalLocalCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentTotalLocalCurrency)), + VatSumForeignCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.VatSumForeignCurrency)), + VatSumLocalCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.VatSumLocalCurrency)), + DocumentRate = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentRate)), + CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)), + Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)), + SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)), + InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)), + OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)), + Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land), + DocumentType = ReadString(headerIndexes, fields, nameof(SalesRecord.DocumentType)) + }); + } + + return rows; + } + + private static List ReadMappedCsvRows( + TextFieldParser parser, + string[] header, + Site site, + IReadOnlyList mappings) + { + var headerIndexes = BuildRawHeaderIndexMap(header); + foreach (var mapping in mappings.Where(m => m.IsRequired)) + { + if (mapping.SourceHeader.Trim().StartsWith('=')) + continue; + + if (!TryResolveHeaderIndex(headerIndexes, mapping.SourceHeader, out _)) + throw new InvalidOperationException($"Pflichtspalte '{mapping.SourceHeader}' fuer Zielfeld '{mapping.TargetField}' fehlt."); + } + + var rows = new List(); + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || IsCsvRowEmpty(fields)) + continue; + + var record = new SalesRecord + { + ExtractionDate = DateTime.UtcNow, + Tsc = site.TSC, + Land = site.Land, + DocumentType = "Manual Excel" + }; + + foreach (var mapping in mappings) + { + if (!SalesRecordProperties.TryGetValue(mapping.TargetField, out var property)) + continue; + + var value = ReadMappedValue(headerIndexes, fields, mapping.SourceHeader); + SetPropertyValue(record, property, value); + } + + if (record.ExtractionDate == default) + record.ExtractionDate = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(record.Tsc)) + record.Tsc = site.TSC; + if (string.IsNullOrWhiteSpace(record.Land)) + record.Land = site.Land; + if (string.IsNullOrWhiteSpace(record.DocumentType)) + record.DocumentType = "Manual Excel"; + + if (!IsMeaningfulMappedRecord(record)) + continue; + + rows.Add(record); + } + + return rows; + } + private static List ReadDefaultRows(IXLRange usedRange, IXLRangeRow headerRow, Site site) { var headerIndexes = BuildHeaderIndexMap(headerRow); @@ -238,6 +377,26 @@ public class ManualExcelImportService : IManualExcelImportService return result; } + private static Dictionary BuildHeaderIndexMap(string[] header) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < header.Length; i++) + { + var normalizedHeader = NormalizeHeader(header[i]); + if (string.IsNullOrWhiteSpace(normalizedHeader)) + continue; + + if (HeaderMap.TryGetValue(normalizedHeader, out var targetField)) + result[targetField] = i; + } + + if (!result.ContainsKey(nameof(SalesRecord.InvoiceNumber))) + throw new InvalidOperationException("Die CSV-Datei hat nicht das erwartete Exportformat. Spalte 'Invoice Number' fehlt."); + + return result; + } + private static Dictionary BuildRawHeaderIndexMap(IXLRangeRow headerRow) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -255,6 +414,23 @@ public class ManualExcelImportService : IManualExcelImportService return result; } + private static Dictionary BuildRawHeaderIndexMap(string[] header) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < header.Length; i++) + { + var value = header[i].Trim(); + if (string.IsNullOrWhiteSpace(value)) + continue; + + result[value] = i; + result[NormalizeHeader(value)] = i; + } + + return result; + } + private static bool TryResolveHeaderIndex(Dictionary headerIndexes, string sourceHeader, out int index) { var trimmed = sourceHeader.Trim(); @@ -273,9 +449,23 @@ public class ManualExcelImportService : IManualExcelImportService : null; } + private static object? ReadMappedValue(Dictionary headerIndexes, string[] fields, string sourceHeader) + { + var trimmed = sourceHeader.Trim(); + if (trimmed.StartsWith('=')) + return trimmed[1..]; + + return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) && index < fields.Length + ? fields[index].Trim() + : null; + } + private static bool IsRowEmpty(IXLRangeRow row) => row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())); + private static bool IsCsvRowEmpty(string[] fields) + => fields.All(string.IsNullOrWhiteSpace); + private static string ReadString(Dictionary headerIndexes, IXLRangeRow row, string fieldName, string fallback = "") { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -285,6 +475,15 @@ public class ManualExcelImportService : IManualExcelImportService return string.IsNullOrWhiteSpace(value) ? fallback : value; } + private static string ReadString(Dictionary headerIndexes, string[] fields, string fieldName, string fallback = "") + { + if (!headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length) + return fallback; + + var value = fields[index].Trim(); + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + private static decimal ReadDecimal(Dictionary headerIndexes, IXLRangeRow row, string fieldName) { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -299,6 +498,13 @@ public class ManualExcelImportService : IManualExcelImportService return ParseDecimal(cell.GetFormattedString().Trim()); } + private static decimal ReadDecimal(Dictionary headerIndexes, string[] fields, string fieldName) + { + return !headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length + ? 0m + : ParseDecimal(fields[index].Trim()); + } + private static DateTime? ReadDate(Dictionary headerIndexes, IXLRangeRow row, string fieldName) { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -311,6 +517,13 @@ public class ManualExcelImportService : IManualExcelImportService return ParseDate(cell.GetFormattedString().Trim()); } + private static DateTime? ReadDate(Dictionary headerIndexes, string[] fields, string fieldName) + { + return !headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length + ? null + : ParseDate(fields[index].Trim()); + } + private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value) { try diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs index 506b4fb..18618f1 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs +++ b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs @@ -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 LoadCheckedExcelReferences(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) @@ -99,16 +154,199 @@ static decimal? ReadNullableDecimal(IXLCell cell) 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) + 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 $$""" @@ -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(

Finance Probe - Net Sales Actuals 2025

- Vergleich gegen geprüfte Referenzwerte aus check.xlsx / Power BI Stand 29.04.2026 + 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
-
+
@@ -265,26 +565,304 @@ static string BuildPage( - + - + - {{string.Join(Environment.NewLine, rows.Select(row => BuildRow(row, excelReferences)))}} + {{detailRows}}
Referenz Excel LC Excel CHFExcel Power BIExcel Sollwert Excel Status DifferenzOhne IC Diff.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}} +
AmpelLandIstSollDifferenzPassender WertWaehrung / CHFWarum / 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); @@ -312,6 +890,32 @@ static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary + {{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) @@ -338,8 +942,8 @@ static string BuildCandidateDetails(NetSalesReferenceRow row) Waehrung Wert Diff. - IC - Diff. ohne IC + 2nd-party/IC + Diff. ohne 2nd-party {{candidateRows}} @@ -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 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; +} diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json b/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json new file mode 100644 index 0000000..86457bd --- /dev/null +++ b/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FinanceProbe": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59120;http://localhost:59121" + } + } +} \ No newline at end of file diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs index a457079..7af5eab 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs @@ -297,6 +297,48 @@ public class ManualExcelImportServiceTests } } + [Fact] + public async Task ReadSalesRecordsAsync_Reads_Sage_Spain_Csv_Format() + { + var site = new Site + { + TSC = "TRES", + Land = "Spanien" + }; + var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); + var csv = string.Join(Environment.NewLine, + "\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"", + "\"TRES\";\"Spanien\";\"20241332\";\"20\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"265.000000\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\""); + await File.WriteAllTextAsync(filePath, csv); + + try + { + var service = new ManualExcelImportService(); + + var rows = await service.ReadSalesRecordsAsync(filePath, site); + + var row = Assert.Single(rows); + Assert.Equal("TRES", row.Tsc); + Assert.Equal("Spanien", row.Land); + Assert.Equal("20241332", row.InvoiceNumber); + Assert.Equal(20, row.PositionOnInvoice); + Assert.Equal("52871", row.Material); + Assert.Equal("TRANS", row.ProductGroup); + Assert.Equal(1m, row.Quantity); + Assert.Equal(160.760000m, row.StandardCost); + Assert.Equal(265.000000m, row.SalesPriceValue); + Assert.Equal("EUR", row.SalesCurrency); + Assert.Equal("EUR", row.DocumentCurrency); + Assert.Equal("EUR", row.CompanyCurrency); + Assert.Equal(new DateTime(2025, 1, 2), row.InvoiceDate); + Assert.Equal("Invoice", row.DocumentType); + } + finally + { + File.Delete(filePath); + } + } + private static string CreateWorkbook(Action fillWorkbook) { var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); diff --git a/TrafagSalesExporter/check.xlsx b/TrafagSalesExporter/check.xlsx new file mode 100644 index 0000000..382b039 Binary files /dev/null and b/TrafagSalesExporter/check.xlsx differ diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 90c6f87..0fbc245 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,40 @@ # Last Change 2026-05-04 +## Finance-Abgrenzung: Antworten Andreas 2026-05-07 + +Fachliche Vorgabe nach Rueckmeldung: + +- Net Sales Actuals werden in Hauswaehrung gerechnet. +- Massgebend ist der Nettofakturawert. +- Umrechnung nach CHF erfolgt mit Budgetkursen, nicht mit Tageskursen. +- Umrechnung/Summierung soll pro Artikel bzw. Belegposition erfolgen. +- Indien wird in INR betrachtet. +- Italien wird in Hauswaehrung betrachtet; Intercompany-/2nd-party-Abgrenzung wird separat angeschaut. +- UK wird in GBP betrachtet. +- Gutschriften haben eigene Rechnungsnummern/Rechnungspositionen und sollen ueber Artikelnummern/Positionen behandelt werden. +- Intercompany soll im zweiten Schritt als 2nd-party/3rd-party-Klassifikation pflegbar werden. +- Genannte 2nd-party/Intercompany-Indikatoren: Trafag, Magnetic Sense/Magnets Sense, Gesellschaft fuer Sensorik; Nummern/Uebersetzungen koennen je Land abweichen. + +Budgetkurse 2025 fuer CHF-Ausweis: + +```text +USD/CHF = 0.85 +EUR/CHF = 0.95 +GBP/CHF = 1.13 +CHF/INR = 90.91 +CHF/CZK = 25.64 +PLN/CHF = 0.22 +CHF/JPY = 156.25 +``` + +Umsetzung in der FinanceProbe: + +- Auswahl der Ist-Variante bevorzugt nun `Nettofakturawert Hauswaehrung` (`DocTotal - VatSum`). +- `Sales Price/Value` bleibt als Vergleichsvariante sichtbar. +- Zusaetzlicher Kandidat `Nettofakturawert Hauswaehrung -> CHF Budget 2025`. +- Referenz in der Oberflaeche wird als `check.xlsx Sollwert` bezeichnet, nicht mehr als fuehrende Power-BI-Referenz. +- Intercompany-Anzeige wurde fachlich als `2nd-party/IC` beschriftet; dauerhafte Pflege als eigenes Auswahlfeld ist noch offen. + ## Finance Probe / Sales-Abgrenzung Ziel der heutigen Arbeit: @@ -635,3 +670,126 @@ Hinweis: - nicht statischen Code bauen - neues Mapping pro Standort pflegen 6. Klaeren, ob DE fachlich `NettoPreisGesamtX` in EUR als Ist-Wert verwenden soll oder ob CHF-Umrechnung noetig ist. + +--- + +## Nachtrag 2026-05-05: FinanceProbe Ampel, Spanien v2 und Deutschland-Beispielfile + +### FinanceProbe Management-Ansicht + +Das Testprogramm `Tools/FinanceProbe` wurde fuer das Finance-Meeting erweitert. + +URL lokal: + +```text +http://localhost:55417/finance +``` + +Neue Ansicht: + +- `Meeting Ampel 2025` +- Ampel pro Land: + - Gruen: Zahl passt rechnerisch gegen Referenz + - Gelb: Differenz oder fachliche Abgrenzung offen + - Grau: keine belastbaren Importdaten +- Anzeige pro Land: + - Ist + - Soll / Referenz + - Differenz + - passender technischer Wert + - Waehrung / CHF-Hinweis + - kurze fachliche Begruendung + +Wichtig zur Waehrung: + +- Wenn Quelle `CHF` liefert, kann CHF direkt gezeigt werden. +- Wenn Quelle `EUR`, `USD`, `GBP`, `INR` usw. liefert, ist es Mandanten-/Originalwaehrung. +- CHF-Ausweis braucht dann eine separate FX-Regel bzw. offiziellen Umrechnungskurs. + +### Spanien v2 im Testprogramm + +Spanien wird im FinanceProbe nicht mehr nur als normaler Zentralimport betrachtet. + +Direkter CSV-Check: + +```text +sagespain/v2/Spain_Sales_2025.csv +``` + +Gelesene Werte: + +- Zeilen: `4'341` +- Ist 2025 / `SalesPriceValue`: `3'082'320.18` +- Waehrung: `EUR` +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` + +Status: + +- Ampel: Gelb / Pruefen +- Grund: Export technisch lesbar, aber Differenz zu `check.xlsx` offen. + +Offen fuer Spanien: + +- korrekte Datumsabgrenzung (`FechaFactura` vs. Alternativen) +- Serien `REG`, `LAT`, `PRO`, `REC` +- Behandlung von Gutschriften / `REC` +- offizielle Sage-Auswertung mit identischem Filter zur Sollzahl + +### Deutschland-Beispielfile + +Neues File im Projektordner: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Hinweis: + +- Der Benutzer hatte zuerst `.xls` genannt, vorhanden ist `.xlsx`. +- Das File ist als Beispielfile zu behandeln, nicht als finale Jahresdatei. + +Technischer Check: + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe `NettoPreisGesamtX`: `8'290.70` +- Waehrung: `EUR` + +Einbau im FinanceProbe: + +- eigener Abschnitt `Germany Excel sample check` +- zeigt Datei, Zeilenzahl, Summe und Referenz aus `check.xlsx` +- markiert explizit, dass die Differenz nur Sample-Charakter hat +- in der Management-Ampel wird Deutschland weiter nicht als OK gewertet, solange kein finaler DE-Jahresexport/import vorliegt + +Fachliche Interpretation fuer Deutschland: + +- Das Mapping funktioniert technisch. +- `NettoPreisGesamtX` kann als Kandidat fuer `SalesPriceValue` gelesen werden. +- Das Beispielfile darf nicht gegen die Jahresreferenz `3'635'922.91` als finale Ist-Zahl verwendet werden. +- Fuer das Meeting ist die Aussage: + - Deutschland-Format ist technisch verstanden. + - Finale DE-Zahl fehlt noch. + - Benoetigt wird ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf. + +### Verifikation 2026-05-05 + +Ausgefuehrt: + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- Web UI liefert `HTTP 200` +- FinanceProbe enthaelt: + - `Meeting Ampel 2025` + - `Spain CSV direct check` + - `Germany Excel sample check`