Add finance probe Spain reconciliation updates
This commit is contained in:
Binary file not shown.
@@ -1,6 +1,123 @@
|
|||||||
# TrafagSalesExporter Handoff
|
# 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
|
## Nachtrag 2026-04-29 Dashboard-Referenzcheck Net Sales 2025
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,50 @@
|
|||||||
# TrafagSalesExporter LLM System Guide
|
# 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.
|
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,77 @@
|
|||||||
# Next Steps
|
# 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
|
## Nachtrag 2026-04-29 Dashboard-Referenzcheck
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,6 +33,19 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
new("US", "Traga US", 3896728m, 3749865m)
|
new("US", "Traga US", 3896728m, 3749865m)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, decimal> BudgetRatesToChf = new Dictionary<string, decimal>(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<AppDbContext> dbFactory)
|
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -84,10 +97,9 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
{
|
{
|
||||||
groupedActuals.TryGetValue(reference.Key, out var actual);
|
groupedActuals.TryGetValue(reference.Key, out var actual);
|
||||||
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
|
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
|
||||||
var selected = actual is null || !referenceValue.HasValue
|
var selected = actual?.Candidates
|
||||||
? actual?.Candidates.FirstOrDefault()
|
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
|
||||||
: actual.Candidates
|
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
|
||||||
.OrderBy(candidate => Math.Abs(candidate.Value - referenceValue.Value))
|
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
|
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
|
||||||
var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue
|
var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue
|
||||||
@@ -108,8 +120,8 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
Currencies = actual?.Currencies ?? string.Empty,
|
Currencies = actual?.Currencies ?? string.Empty,
|
||||||
ValueField = selected?.Label ?? string.Empty,
|
ValueField = selected?.Label ?? string.Empty,
|
||||||
ActualCurrency = selected?.Currency ?? string.Empty,
|
ActualCurrency = selected?.Currency ?? string.Empty,
|
||||||
ReferenceSource = reference.PowerBiValue.HasValue ? "Power BI" : "LC",
|
ReferenceSource = "check.xlsx Soll",
|
||||||
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Power BI Original" : "LC",
|
ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC",
|
||||||
Status = BuildReferenceStatus(difference),
|
Status = BuildReferenceStatus(difference),
|
||||||
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow
|
||||||
{
|
{
|
||||||
@@ -161,15 +173,24 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
if (netDocumentLocalCurrency != 0m)
|
if (netDocumentLocalCurrency != 0m)
|
||||||
candidates.Add(new(
|
candidates.Add(new(
|
||||||
"NetDocumentLocalCurrency",
|
"NetDocumentLocalCurrency",
|
||||||
"DocTotal - VatSum",
|
"Nettofakturawert Hauswaehrung",
|
||||||
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
|
||||||
netDocumentLocalCurrency,
|
netDocumentLocalCurrency,
|
||||||
documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)));
|
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
|
return new NetSalesActual
|
||||||
{
|
{
|
||||||
RowCount = rowList.Count,
|
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))
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(x => x, 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<string> currencies)
|
private static string ResolveCurrencyLabel(IEnumerable<string> currencies)
|
||||||
{
|
{
|
||||||
var distinct = currencies
|
var distinct = currencies
|
||||||
@@ -202,6 +231,21 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
|
if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName))
|
||||||
return false;
|
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))
|
if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) ||
|
return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.VisualBasic.FileIO;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
|
|
||||||
private static List<SalesRecord> ReadSalesRecords(string filePath, Site site, IReadOnlyList<ManualExcelColumnMapping> mappings)
|
private static List<SalesRecord> ReadSalesRecords(string filePath, Site site, IReadOnlyList<ManualExcelColumnMapping> mappings)
|
||||||
{
|
{
|
||||||
|
if (string.Equals(Path.GetExtension(filePath), ".csv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ReadCsvSalesRecords(filePath, site, mappings);
|
||||||
|
|
||||||
using var workbook = new XLWorkbook(filePath);
|
using var workbook = new XLWorkbook(filePath);
|
||||||
var worksheet = workbook.Worksheets.FirstOrDefault()
|
var worksheet = workbook.Worksheets.FirstOrDefault()
|
||||||
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt.");
|
||||||
@@ -109,6 +113,141 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
: ReadDefaultRows(usedRange, headerRow, site);
|
: ReadDefaultRows(usedRange, headerRow, site);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<SalesRecord> ReadCsvSalesRecords(string filePath, Site site, IReadOnlyList<ManualExcelColumnMapping> 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<SalesRecord> ReadDefaultCsvRows(TextFieldParser parser, string[] header, Site site)
|
||||||
|
{
|
||||||
|
var headerIndexes = BuildHeaderIndexMap(header);
|
||||||
|
var rows = new List<SalesRecord>();
|
||||||
|
|
||||||
|
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<SalesRecord> ReadMappedCsvRows(
|
||||||
|
TextFieldParser parser,
|
||||||
|
string[] header,
|
||||||
|
Site site,
|
||||||
|
IReadOnlyList<ManualExcelColumnMapping> 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<SalesRecord>();
|
||||||
|
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<SalesRecord> ReadDefaultRows(IXLRange usedRange, IXLRangeRow headerRow, Site site)
|
private static List<SalesRecord> ReadDefaultRows(IXLRange usedRange, IXLRangeRow headerRow, Site site)
|
||||||
{
|
{
|
||||||
var headerIndexes = BuildHeaderIndexMap(headerRow);
|
var headerIndexes = BuildHeaderIndexMap(headerRow);
|
||||||
@@ -238,6 +377,26 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildHeaderIndexMap(string[] header)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, int>(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<string, int> BuildRawHeaderIndexMap(IXLRangeRow headerRow)
|
private static Dictionary<string, int> BuildRawHeaderIndexMap(IXLRangeRow headerRow)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -255,6 +414,23 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildRawHeaderIndexMap(string[] header)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, int>(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<string, int> headerIndexes, string sourceHeader, out int index)
|
private static bool TryResolveHeaderIndex(Dictionary<string, int> headerIndexes, string sourceHeader, out int index)
|
||||||
{
|
{
|
||||||
var trimmed = sourceHeader.Trim();
|
var trimmed = sourceHeader.Trim();
|
||||||
@@ -273,9 +449,23 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object? ReadMappedValue(Dictionary<string, int> 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)
|
private static bool IsRowEmpty(IXLRangeRow row)
|
||||||
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
|
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
|
||||||
|
|
||||||
|
private static bool IsCsvRowEmpty(string[] fields)
|
||||||
|
=> fields.All(string.IsNullOrWhiteSpace);
|
||||||
|
|
||||||
private static string ReadString(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName, string fallback = "")
|
private static string ReadString(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName, string fallback = "")
|
||||||
{
|
{
|
||||||
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
||||||
@@ -285,6 +475,15 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ReadString(Dictionary<string, int> 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<string, int> headerIndexes, IXLRangeRow row, string fieldName)
|
private static decimal ReadDecimal(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName)
|
||||||
{
|
{
|
||||||
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
||||||
@@ -299,6 +498,13 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
return ParseDecimal(cell.GetFormattedString().Trim());
|
return ParseDecimal(cell.GetFormattedString().Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static decimal ReadDecimal(Dictionary<string, int> 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<string, int> headerIndexes, IXLRangeRow row, string fieldName)
|
private static DateTime? ReadDate(Dictionary<string, int> headerIndexes, IXLRangeRow row, string fieldName)
|
||||||
{
|
{
|
||||||
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
if (!headerIndexes.TryGetValue(fieldName, out var index))
|
||||||
@@ -311,6 +517,13 @@ public class ManualExcelImportService : IManualExcelImportService
|
|||||||
return ParseDate(cell.GetFormattedString().Trim());
|
return ParseDate(cell.GetFormattedString().Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateTime? ReadDate(Dictionary<string, int> 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)
|
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.VisualBasic.FileIO;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Services;
|
using TrafagSalesExporter.Services;
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ app.MapGet("/finance", async (IFinanceReconciliationService finance) =>
|
|||||||
{
|
{
|
||||||
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
|
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
|
||||||
var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath());
|
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();
|
app.Run();
|
||||||
@@ -63,6 +66,58 @@ static string? ResolveCheckedExcelPath()
|
|||||||
return null;
|
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)
|
static Dictionary<string, CheckedExcelReference> LoadCheckedExcelReferences(string? path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(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;
|
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(
|
static string BuildPage(
|
||||||
IReadOnlyList<NetSalesReferenceRow> rows,
|
IReadOnlyList<NetSalesReferenceRow> rows,
|
||||||
string databasePath,
|
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 generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH"));
|
||||||
var okCount = rows.Count(r => r.Status == "OK");
|
var okCount = rows.Count(r => r.Status == "OK");
|
||||||
var checkCount = rows.Count(r => r.Status == "Pruefen");
|
var checkCount = rows.Count(r => r.Status == "Pruefen");
|
||||||
var missingCount = rows.Count(r => r.Status == "Keine Daten");
|
var missingCount = rows.Count(r => r.Status == "Keine Daten");
|
||||||
var excelCount = excelReferences.Count;
|
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 $$"""
|
return $$"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -156,6 +394,21 @@ static string BuildPage(
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
line-height: 1.5;
|
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; }
|
main { padding: 18px 24px 28px; }
|
||||||
.summary {
|
.summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -228,6 +481,46 @@ static string BuildPage(
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
.small { color: var(--muted); font-size: 12px; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
main, header { padding-left: 12px; padding-right: 12px; }
|
main, header { padding-left: 12px; padding-right: 12px; }
|
||||||
.summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
.summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
||||||
@@ -239,20 +532,27 @@ static string BuildPage(
|
|||||||
<header>
|
<header>
|
||||||
<h1>Finance Probe - Net Sales Actuals 2025</h1>
|
<h1>Finance Probe - Net Sales Actuals 2025</h1>
|
||||||
<div class="meta">
|
<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>DB: {{Html(databasePath)}}</span>
|
||||||
<span>Excel-Referenzen gelesen: {{excelCount}}</span>
|
<span>Excel-Referenzen gelesen: {{excelCount}}</span>
|
||||||
<span>Aktualisiert: {{Html(generatedAt)}}</span>
|
<span>Aktualisiert: {{Html(generatedAt)}}</span>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{{executiveBriefing}}
|
||||||
<section class="summary">
|
<section class="summary">
|
||||||
<div class="metric"><strong>{{rows.Count}}</strong><span>Standorte</span></div>
|
<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>{{okCount}}</strong><span>OK</span></div>
|
||||||
<div class="metric"><strong>{{checkCount}}</strong><span>Pruefen</span></div>
|
<div class="metric"><strong>{{checkCount}}</strong><span>Pruefen</span></div>
|
||||||
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
|
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="table-wrap">
|
<div id="all-sites" class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -265,26 +565,304 @@ static string BuildPage(
|
|||||||
<th class="num">Referenz</th>
|
<th class="num">Referenz</th>
|
||||||
<th class="num">Excel LC</th>
|
<th class="num">Excel LC</th>
|
||||||
<th class="num">Excel CHF</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>Excel Status</th>
|
||||||
<th class="num">Differenz</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>Waehrung</th>
|
||||||
<th class="num">Zeilen</th>
|
<th class="num">Zeilen</th>
|
||||||
<th>Varianten</th>
|
<th>Varianten</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{string.Join(Environment.NewLine, rows.Select(row => BuildRow(row, excelReferences)))}}
|
{{detailRows}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{germanySampleSection}}
|
||||||
|
{{spainCsvSection}}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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)
|
static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary<string, CheckedExcelReference> excelReferences)
|
||||||
{
|
{
|
||||||
var statusClass = row.Status.Replace(" ", string.Empty);
|
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)
|
static string BuildCandidateDetails(NetSalesReferenceRow row)
|
||||||
{
|
{
|
||||||
if (row.Candidates.Count == 0)
|
if (row.Candidates.Count == 0)
|
||||||
@@ -338,8 +942,8 @@ static string BuildCandidateDetails(NetSalesReferenceRow row)
|
|||||||
<th>Waehrung</th>
|
<th>Waehrung</th>
|
||||||
<th class="num">Wert</th>
|
<th class="num">Wert</th>
|
||||||
<th class="num">Diff.</th>
|
<th class="num">Diff.</th>
|
||||||
<th class="num">IC</th>
|
<th class="num">2nd-party/IC</th>
|
||||||
<th class="num">Diff. ohne IC</th>
|
<th class="num">Diff. ohne 2nd-party</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{{candidateRows}}</tbody>
|
<tbody>{{candidateRows}}</tbody>
|
||||||
@@ -362,3 +966,26 @@ sealed class CheckedExcelReference
|
|||||||
public decimal? PowerBiValue { get; set; }
|
public decimal? PowerBiValue { get; set; }
|
||||||
public string Status { get; set; } = string.Empty;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"FinanceProbe": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:59120;http://localhost:59121"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<XLWorkbook> fillWorkbook)
|
private static string CreateWorkbook(Action<XLWorkbook> fillWorkbook)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx");
|
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx");
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,40 @@
|
|||||||
# Last Change 2026-05-04
|
# 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
|
## Finance Probe / Sales-Abgrenzung
|
||||||
|
|
||||||
Ziel der heutigen Arbeit:
|
Ziel der heutigen Arbeit:
|
||||||
@@ -635,3 +670,126 @@ Hinweis:
|
|||||||
- nicht statischen Code bauen
|
- nicht statischen Code bauen
|
||||||
- neues Mapping pro Standort pflegen
|
- neues Mapping pro Standort pflegen
|
||||||
6. Klaeren, ob DE fachlich `NettoPreisGesamtX` in EUR als Ist-Wert verwenden soll oder ob CHF-Umrechnung noetig ist.
|
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`
|
||||||
|
|||||||
Reference in New Issue
Block a user