Add finance probe Spain reconciliation updates

This commit is contained in:
2026-05-07 14:08:54 +02:00
parent 7442d45d9c
commit 6717843f18
12 changed files with 1583 additions and 21 deletions
Binary file not shown.
+118 -1
View File
@@ -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
+45 -1
View File
@@ -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.
+72 -1
View File
@@ -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,11 +97,10 @@ 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
? (decimal?)null ? (decimal?)null
@@ -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.
+158
View File
@@ -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`