From 6f8528ac54712cf32e1f2faf40bd8e85e4ff6a77 Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 20 May 2026 08:10:02 +0200 Subject: [PATCH] Apply confirmed Italy finance method --- TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 19 +++++ .../Services/ExcelExportService.cs | 70 +++++++++++++++++-- .../Services/FinanceReconciliationService.cs | 56 ++++++++++++++- .../FinanceReconciliationServiceTests.cs | 56 ++++++++++++++- ...E_BERECHNUNGSFORMELN_LAENDER_2026-05-19.md | 11 ++- .../docs/FINANCE_HANDOFF_2026-05-18.md | 6 ++ .../docs/FINANCE_IT_VORGEHEN_2026-05-18.md | 31 ++++++++ TrafagSalesExporter/lastchange.md | 32 +++++++++ 8 files changed, 274 insertions(+), 7 deletions(-) diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index d82bf1e..85164a7 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,25 @@ Stand: 2026-05-19 +## Nachtrag 2026-05-20 IT Finance-Methode + +Erledigt: + +- IT-Methode gemaess Finance-Leiter umgesetzt. +- `CustomerName` enthaelt `Trafag Italia` wird fuer IT ausgeschlossen. +- Doppelte IT-Zeilen mit leerem `Supplier country` werden nur einmal gezaehlt. +- Regel greift im Finance-Vergleich/Testprogramm und in den Finance-Spalten der zentralen Excel. + +Bewusster Entscheid: + +- Die alte 2025-Kombination ist naeher am Soll, aber fachlich nicht zukunftssicher. +- Fuer 2026+ gilt die neue Methode, auch wenn sie 2025 in der aktuellen DB weiter vom Sollwert abweicht. + +Naechster Check: + +- Nach neuem IT-Export pruefen, ob die vollstaendige `Trafag Italia`-Summe aus den neuen Rohdaten sichtbar wird. +- Zentrale Excel fuer `Finance | Country Key = IT`, `Finance | Include = TRUE` filtern und gegen Finance-Vergleich kontrollieren. + ## Nachtrag 2026-05-19 IIS Deployment / 500 Fehler Vollstaendige Doku: diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs index ffc8441..789b6e3 100644 --- a/TrafagSalesExporter/Services/ExcelExportService.cs +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -91,6 +91,7 @@ public class ExcelExportService : IExcelExportService } var row = 2; + var italyBlankSupplierCountryRows = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var record in records) { ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); @@ -129,13 +130,17 @@ public class ExcelExportService : IExcelExportService ws.Cell(row, 34).Value = record.Land; ws.Cell(row, 35).Value = record.DocumentType; var financeDate = ResolveFinanceDate(record); + var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc); + var financeInclude = ResolveFinanceInclude(record, financeCountryKey, italyBlankSupplierCountryRows); ws.Cell(row, 36).Value = financeDate.Year; - ws.Cell(row, 37).Value = ResolveFinanceCountryKey(record.Land, record.Tsc); + ws.Cell(row, 37).Value = financeCountryKey; ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy"); - ws.Cell(row, 39).Value = record.SalesPriceValue; + ws.Cell(row, 39).Value = financeInclude ? record.SalesPriceValue : 0m; ws.Cell(row, 40).Value = ResolveFinanceCurrency(record); - ws.Cell(row, 41).Value = record.SalesPriceValue != 0m ? "TRUE" : "FALSE"; - ws.Cell(row, 42).Value = "Sales Price/Value"; + ws.Cell(row, 41).Value = financeInclude && record.SalesPriceValue != 0m ? "TRUE" : "FALSE"; + ws.Cell(row, 42).Value = financeInclude + ? "Sales Price/Value" + : ResolveFinanceExclusionReason(record, financeCountryKey); row++; } @@ -163,6 +168,7 @@ public class ExcelExportService : IExcelExportService ("Waehrung", "Finance | Currency zeigt die fuer den Finance-Abgleich fuehrende Hauswaehrung."), ("Datum", "Finance | Date verwendet PostingDate, danach InvoiceDate, danach ExtractionDate."), ("Wertquelle", "Finance | Source Value Field zeigt, aus welchem Rohfeld der Finance-Wert kommt."), + ("IT-Sonderregel", "Fuer IT wird Trafag Italia im Finance-Wert ausgeschlossen; doppelte IT-Zeilen ohne Supplier country werden nur einmal gezaehlt."), ("Nicht verwenden", "Nicht Land, TSC, Document Total LC oder andere Betragsspalten fuer den CFO-Abgleich erraten."), ("Hinweis", "Offene fachliche Differenzen bleiben sichtbar; diese Excel-Sicht soll die gleiche Ist-Summe wie das Testprogramm reproduzieren.") }; @@ -235,6 +241,62 @@ public class ExcelExportService : IExcelExportService return normalizedTsc.Replace("TR", string.Empty); } + private static bool ResolveFinanceInclude(SalesRecord record, string financeCountryKey, HashSet italyBlankSupplierCountryRows) + { + if (!financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase)) + return true; + + if (IsExcludedItalyCustomer(record)) + return false; + + if (!string.IsNullOrWhiteSpace(record.SupplierCountry)) + return true; + + return italyBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(record)); + } + + private static string ResolveFinanceExclusionReason(SalesRecord record, string financeCountryKey) + { + if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && IsExcludedItalyCustomer(record)) + return "Excluded IT customer: Trafag Italia"; + + if (financeCountryKey.Equals("IT", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(record.SupplierCountry)) + return "Excluded IT duplicate without Supplier country"; + + return "Excluded"; + } + + private static bool IsExcludedItalyCustomer(SalesRecord record) + => NormalizeFinanceText(record.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase); + + private static string BuildItalyBlankSupplierCountryDeduplicationKey(SalesRecord record) + => string.Join("|", + record.Tsc, + record.DocumentType, + record.DocumentEntry, + record.InvoiceNumber, + record.PositionOnInvoice, + record.Material, + record.Name, + record.Quantity, + record.CustomerNumber, + record.CustomerName, + record.SalesPriceValue, + record.DocumentTotalForeignCurrency, + record.DocumentTotalLocalCurrency, + record.VatSumForeignCurrency, + record.VatSumLocalCurrency, + record.PostingDate?.ToString("O") ?? string.Empty, + record.InvoiceDate?.ToString("O") ?? string.Empty); + + private static string NormalizeFinanceText(string value) + => (value ?? string.Empty) + .Replace("\u00e4", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("\u00f6", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("\u00fc", "ue", StringComparison.OrdinalIgnoreCase) + .Trim() + .ToUpperInvariant(); + private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList> rows) { using var workbook = new XLWorkbook(); diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index 980b046..dbbc4fd 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -40,12 +40,17 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService r.Tsc, r.DocumentEntry, r.InvoiceNumber, + r.PositionOnInvoice, + r.Material, + r.Name, + r.Quantity, r.DocumentType, r.PostingDate, r.InvoiceDate, r.ExtractionDate, r.CustomerNumber, r.CustomerName, + r.SupplierCountry, r.SalesCurrency, r.DocumentCurrency, r.CompanyCurrency, @@ -150,7 +155,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService IReadOnlyDictionary budgetRatesToChf, IReadOnlyList intercompanyRules) { - var rowList = rows.ToList(); + var rowList = ApplyCountryFinanceRules(referenceKey, rows).ToList(); var houseCurrency = ResolveHouseCurrency(referenceKey, rowList); var documentRows = rowList .GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase) @@ -239,6 +244,50 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m; } + private static IEnumerable ApplyCountryFinanceRules( + string referenceKey, + IEnumerable rows) + { + if (!referenceKey.Equals("IT", StringComparison.OrdinalIgnoreCase)) + return rows; + + var seenBlankSupplierCountryRows = new HashSet(StringComparer.OrdinalIgnoreCase); + return rows.Where(row => + { + if (IsExcludedItalyCustomer(row)) + return false; + + if (!string.IsNullOrWhiteSpace(row.SupplierCountry)) + return true; + + return seenBlankSupplierCountryRows.Add(BuildItalyBlankSupplierCountryDeduplicationKey(row)); + }); + } + + private static bool IsExcludedItalyCustomer(NetSalesActualSourceRow row) + => ResolveReferenceKey(row.Land, row.Tsc).Equals("IT", StringComparison.OrdinalIgnoreCase) && + NormalizeRuleText(row.CustomerName).Contains("TRAFAG ITALIA", StringComparison.OrdinalIgnoreCase); + + private static string BuildItalyBlankSupplierCountryDeduplicationKey(NetSalesActualSourceRow row) + => string.Join("|", + row.Tsc, + row.DocumentType, + row.DocumentEntry, + row.InvoiceNumber, + row.PositionOnInvoice, + row.Material, + row.Name, + row.Quantity, + row.CustomerNumber, + row.CustomerName, + row.SalesPriceValue, + row.DocumentTotalForeignCurrency, + row.DocumentTotalLocalCurrency, + row.VatSumForeignCurrency, + row.VatSumLocalCurrency, + row.PostingDate?.ToString("O") ?? string.Empty, + row.InvoiceDate?.ToString("O") ?? string.Empty); + private static decimal ConvertHouseCurrencyNetToBudgetChf( string houseCurrency, NetSalesActualSourceRow row, @@ -398,12 +447,17 @@ internal sealed record NetSalesActualSourceRow( string Tsc, int DocumentEntry, string InvoiceNumber, + int PositionOnInvoice, + string Material, + string Name, + decimal Quantity, string DocumentType, DateTime? PostingDate, DateTime? InvoiceDate, DateTime ExtractionDate, string CustomerNumber, string CustomerName, + string SupplierCountry, string SalesCurrency, string DocumentCurrency, string CompanyCurrency, diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs index 6a65a5e..b50636a 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs @@ -79,6 +79,53 @@ public class FinanceReconciliationServiceTests : IDisposable Assert.Contains(row.Candidates, c => c.Key == "SalesPriceValue" && c.Value == 90m && c.IsPreferred); } + [Fact] + public async Task BuildNetSalesReferenceRowsAsync_Excludes_Trafag_Italia_For_Italy() + { + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.Sites.Add(BuildSite()); + db.FinanceReferences.Add(new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, CheckValue = 100m, IsActive = true }); + db.CentralSalesRecords.AddRange( + BuildCentralRecord("TRIT", "Italien", 30, 1, 100m, new DateTime(2025, 4, 1), new DateTime(2025, 4, 1), salesPriceValue: 100m, customerName: "External Customer S.R.L.", supplierCountry: "IT"), + BuildCentralRecord("TRIT", "Italien", 31, 1, 400m, new DateTime(2025, 4, 2), new DateTime(2025, 4, 2), salesPriceValue: 400m, customerName: "TRAFAG ITALIA S.R.L.", supplierCountry: "IT")); + await db.SaveChangesAsync(); + } + + var service = new FinanceReconciliationService(_dbFactory); + + var rows = await service.BuildNetSalesReferenceRowsAsync(2025); + + var row = Assert.Single(rows); + Assert.Equal(100m, row.ActualValue); + Assert.Equal(1, row.RowCount); + Assert.Equal("OK", row.Status); + } + + [Fact] + public async Task BuildNetSalesReferenceRowsAsync_Deduplicates_Italy_Rows_With_Blank_Supplier_Country() + { + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.Sites.Add(BuildSite()); + db.FinanceReferences.Add(new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, CheckValue = 150m, IsActive = true }); + db.CentralSalesRecords.AddRange( + BuildCentralRecord("TRIT", "Italien", 40, 1, 100m, new DateTime(2025, 5, 1), new DateTime(2025, 5, 1), salesPriceValue: 100m, customerName: "External Customer S.R.L.", supplierCountry: ""), + BuildCentralRecord("TRIT", "Italien", 40, 1, 100m, new DateTime(2025, 5, 1), new DateTime(2025, 5, 1), salesPriceValue: 100m, customerName: "External Customer S.R.L.", supplierCountry: ""), + BuildCentralRecord("TRIT", "Italien", 41, 1, 50m, new DateTime(2025, 5, 2), new DateTime(2025, 5, 2), salesPriceValue: 50m, customerName: "External Customer S.R.L.", supplierCountry: "IT")); + await db.SaveChangesAsync(); + } + + var service = new FinanceReconciliationService(_dbFactory); + + var rows = await service.BuildNetSalesReferenceRowsAsync(2025); + + var row = Assert.Single(rows); + Assert.Equal(150m, row.ActualValue); + Assert.Equal(2, row.RowCount); + Assert.Equal("OK", row.Status); + } + [Fact] public async Task BuildNetSalesReferenceRowsAsync_Reports_India_As_Inr_House_Currency() { @@ -113,7 +160,9 @@ public class FinanceReconciliationServiceTests : IDisposable DateTime invoiceDate, decimal vatLocal = 0m, decimal? salesPriceValue = null, - string salesCurrency = "EUR") + string salesCurrency = "EUR", + string customerName = "", + string supplierCountry = "IT") => new() { StoredAtUtc = DateTime.UtcNow, @@ -124,6 +173,11 @@ public class FinanceReconciliationServiceTests : IDisposable DocumentEntry = documentEntry, InvoiceNumber = documentEntry.ToString(), PositionOnInvoice = position, + Material = "MAT", + Name = "Item", + Quantity = 1m, + CustomerName = customerName, + SupplierCountry = supplierCountry, SalesPriceValue = salesPriceValue ?? documentTotalLocal - vatLocal, SalesCurrency = salesCurrency, DocumentCurrency = salesCurrency, diff --git a/TrafagSalesExporter/docs/FINANCE_BERECHNUNGSFORMELN_LAENDER_2026-05-19.md b/TrafagSalesExporter/docs/FINANCE_BERECHNUNGSFORMELN_LAENDER_2026-05-19.md index 04dc293..fffe629 100644 --- a/TrafagSalesExporter/docs/FINANCE_BERECHNUNGSFORMELN_LAENDER_2026-05-19.md +++ b/TrafagSalesExporter/docs/FINANCE_BERECHNUNGSFORMELN_LAENDER_2026-05-19.md @@ -357,10 +357,19 @@ AND h."CardCode" NOT IN ( Formel im Vergleich: ```text -Ist IT = Sum(SalesPriceValue) nach obigem B1-Filter +Ist IT = Sum(SalesPriceValue) nach obigem B1-Filter und IT-Finance-Abgrenzung Soll IT = 7'669'840 EUR ``` +Zusaetzliche IT-Finance-Abgrenzung, Stand 2026-05-20: + +```text +1. CustomerName enthaelt "Trafag Italia" => aus IT-Finance-Ist ausschliessen. +2. IT-Zeilen mit leerem Supplier country => identische Zeile nur einmal zaehlen. +``` + +Diese Methode ist gemaess Finance-Leiter fachlich korrekt. Die alte Kundenausschluss-Kombination traf 2025 zufaellig naeher, ist aber nicht die zukunftssichere Methode. + Bekannter Stand: ```text diff --git a/TrafagSalesExporter/docs/FINANCE_HANDOFF_2026-05-18.md b/TrafagSalesExporter/docs/FINANCE_HANDOFF_2026-05-18.md index fe6fd3f..8895684 100644 --- a/TrafagSalesExporter/docs/FINANCE_HANDOFF_2026-05-18.md +++ b/TrafagSalesExporter/docs/FINANCE_HANDOFF_2026-05-18.md @@ -185,6 +185,12 @@ Die sechs provisorisch ausgeschlossenen Kunden: Wichtig: Dieser IT-Filter ist ein Arbeits-/Prueffilter, noch nicht fachlich final bestaetigt. +Nachtrag 2026-05-20: + +- Finance-Leiter bestaetigt als fachliche Methode: `Trafag Italia` aus dem externen IT-Finance-Ist ausschliessen. +- Identische IT-Zeilen mit leerem `Supplier country` nur einmal zaehlen. +- Die alte Kundenausschluss-Kombination bleibt als 2025-Analyse dokumentiert, ist aber nicht die fuehrende Methode fuer Folgejahre. + Detaildokument: ```text diff --git a/TrafagSalesExporter/docs/FINANCE_IT_VORGEHEN_2026-05-18.md b/TrafagSalesExporter/docs/FINANCE_IT_VORGEHEN_2026-05-18.md index a11db5c..d1b8941 100644 --- a/TrafagSalesExporter/docs/FINANCE_IT_VORGEHEN_2026-05-18.md +++ b/TrafagSalesExporter/docs/FINANCE_IT_VORGEHEN_2026-05-18.md @@ -204,6 +204,37 @@ Noch zu klaeren: - Ist der echte Filter eine Kundengruppe, Branche, Sales-Channel, Projekt-/OEM-Abgrenzung oder ein anderes B1-Feld? - Soll der Filter in der App spaeter als pflegbare Finance-Regel statt als harter Code umgesetzt werden? +## Nachtrag 2026-05-20 fachliche IT-Methode + +Finance-Leiter bestaetigt: + +- `CustomerName` mit `Trafag Italia` gehoert nicht in den externen IT-Finance-Istwert. +- Doppelte Einzelpositionen mit leerem `Supplier country` sollen nur einmal gezaehlt werden. + +Bewertung: + +- Die fruehere Kundenausschluss-Kombination passt fuer 2025 rechnerisch naeher. +- Sie ist aber keine belastbare Methode fuer Folgejahre. +- Deshalb wird die fachlich bestaetigte Methode umgesetzt, auch wenn die aktuelle 2025-DB danach weiter vom Sollwert abweicht. + +Test gegen aktuelle DB: + +```text +Bisherige IT-Summe: 7'669'641.47 +Trafag Italia Abzug in DB: 6'495.71 +Dubletten-Abzug SupplierCountry leer: 0.00 +Neue fachliche Methode: 7'663'145.76 +Soll IT: 7'669'840.00 +Neue Differenz: -6'694.24 +``` + +Technische Stellen: + +```text +Services/FinanceReconciliationService.cs +Services/ExcelExportService.cs +``` + Naechster Test: ```text diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index cf4a66f..bea38c7 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,37 @@ # Last Change 2026-05-04 +## IT Finance-Methode fachlich bestaetigt 2026-05-20 + +Entscheid: + +- Fuer Italien gilt die vom Finance-Leiter bestaetigte Methode. +- `CustomerName` enthaelt `Trafag Italia` wird aus dem IT-Finance-Ist ausgeschlossen. +- Doppelte IT-Zeilen mit leerem `Supplier country` werden nur einmal gezaehlt. +- Diese Regel gilt nur fuer IT. + +Wichtig: + +- Die bisherige Kundenausschluss-Kombination passte 2025 numerisch naeher an den Sollwert, ist aber nicht die belastbare Methode fuer Folgejahre. +- Der 2025-Zufallstreffer wird deshalb nicht als fachliche Regel weiterverwendet. + +Gegen aktuelle DB getestet: + +```text +Soll IT: 7'669'840.00 +Bisherige IT-Summe: 7'669'641.47 +Bisherige Differenz: -198.53 +Trafag Italia Abzug in DB: 6'495.71 +Dubletten-Abzug SupplierCountry leer: 0.00 +Neue fachliche Methode: 7'663'145.76 +Neue Differenz: -6'694.24 +``` + +Umsetzung: + +- `Services/FinanceReconciliationService.cs` +- `Services/ExcelExportService.cs` +- Tests in `TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs` + ## IIS Deployment Handoff 2026-05-19 Aktueller Deployment-/IIS-Stand wurde hier dokumentiert: