Apply confirmed Italy finance method

This commit is contained in:
2026-05-20 08:10:02 +02:00
parent b2aa7b046f
commit 6f8528ac54
8 changed files with 274 additions and 7 deletions
@@ -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:
@@ -91,6 +91,7 @@ public class ExcelExportService : IExcelExportService
}
var row = 2;
var italyBlankSupplierCountryRows = new HashSet<string>(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<string> 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<IReadOnlyDictionary<string, object?>> rows)
{
using var workbook = new XLWorkbook();
@@ -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<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> 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<NetSalesActualSourceRow> ApplyCountryFinanceRules(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows)
{
if (!referenceKey.Equals("IT", StringComparison.OrdinalIgnoreCase))
return rows;
var seenBlankSupplierCountryRows = new HashSet<string>(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,
@@ -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,
@@ -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
@@ -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
@@ -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
+32
View File
@@ -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: