Correct Sage finance calculations

This commit is contained in:
2026-05-18 20:57:22 +02:00
parent cf0d3e21f1
commit fb85e2e57a
11 changed files with 659 additions and 19 deletions
@@ -132,12 +132,18 @@ SELECT
CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost, CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost,
CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue, CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue,
'EUR' AS StandardCostCurrency, 'EUR' AS StandardCostCurrency,
CAST(l.ImporteNeto AS decimal(19, 6)) AS SalesPriceValue, CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(l.ImporteNeto)
ELSE l.ImporteNeto
END AS decimal(19, 6)) AS SalesPriceValue,
'EUR' AS SalesCurrency, 'EUR' AS SalesCurrency,
'EUR' AS DocumentCurrency, 'EUR' AS DocumentCurrency,
'EUR' AS CompanyCurrency, 'EUR' AS CompanyCurrency,
c.CodigoDivisa AS SageCurrencyCode, c.CodigoDivisa AS SageCurrencyCode,
CAST(c.BaseImponible AS decimal(19, 6)) AS DocumentNetAmount, CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(c.BaseImponible)
ELSE c.BaseImponible
END AS decimal(19, 6)) AS DocumentNetAmount,
CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount, CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount,
CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount, CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount,
c.FechaFactura AS InvoiceDate, c.FechaFactura AS InvoiceDate,
@@ -203,8 +209,8 @@ CabeceraAlbaranCliente.FechaFactura < ToDate
Notes: Notes:
- Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows. - Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto. - SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; credit notes are forced negative.
- DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible. - DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible; credit notes are forced negative.
- Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero. - Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero.
"@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8 "@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8
@@ -351,13 +351,13 @@ public class DatabaseSeedService : IDatabaseSeedService
(nameof(SalesRecord.CustomerNumber), "Customer number", false), (nameof(SalesRecord.CustomerNumber), "Customer number", false),
(nameof(SalesRecord.CustomerName), "Customer name", false), (nameof(SalesRecord.CustomerName), "Customer name", false),
(nameof(SalesRecord.CustomerCountry), "Customer country", false), (nameof(SalesRecord.CustomerCountry), "Customer country", false),
(nameof(SalesRecord.SalesPriceValue), "=[Sales Price/Value]*[Quantity]", true), (nameof(SalesRecord.SalesPriceValue), "=SageNetSales([Sales Price/Value], [Quantity], [Document Type], [DocumentType], [Type])", true),
(nameof(SalesRecord.SalesCurrency), "=GBP", false), (nameof(SalesRecord.SalesCurrency), "=GBP", false),
(nameof(SalesRecord.DocumentCurrency), "=GBP", false), (nameof(SalesRecord.DocumentCurrency), "=GBP", false),
(nameof(SalesRecord.CompanyCurrency), "=GBP", false), (nameof(SalesRecord.CompanyCurrency), "=GBP", false),
(nameof(SalesRecord.PostingDate), "invoice date", false), (nameof(SalesRecord.PostingDate), "invoice date", false),
(nameof(SalesRecord.InvoiceDate), "invoice date", false), (nameof(SalesRecord.InvoiceDate), "invoice date", false),
(nameof(SalesRecord.DocumentType), "=Manual Excel", false) (nameof(SalesRecord.DocumentType), "Document Type", false)
}; };
var changed = false; var changed = false;
@@ -367,6 +367,7 @@ public class HanaQueryService : IHanaQueryService
private static string GetInvoiceQuery(string schema) private static string GetInvoiceQuery(string schema)
{ {
var schemaPrefix = BuildSchemaPrefix(schema); var schemaPrefix = BuildSchemaPrefix(schema);
var revenueAccountFilter = BuildRevenueAccountFilter(schema, "h", "p");
return $@" return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
@@ -422,13 +423,14 @@ LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}{revenueAccountFilter}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
} }
private static string GetCreditNoteQuery(string schema) private static string GetCreditNoteQuery(string schema)
{ {
var schemaPrefix = BuildSchemaPrefix(schema); var schemaPrefix = BuildSchemaPrefix(schema);
var revenueAccountFilter = BuildRevenueAccountFilter(schema, "h", "p");
return $@" return $@"
SELECT SELECT
CURRENT_TIMESTAMP AS extraction_date, CURRENT_TIMESTAMP AS extraction_date,
@@ -479,10 +481,33 @@ LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B' AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName}{revenueAccountFilter}
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
} }
private static string BuildRevenueAccountFilter(string schema, string headerAlias, string lineAlias)
{
if (!schema.Equals("it01_p", StringComparison.OrdinalIgnoreCase))
return string.Empty;
// Italy's Finance/B1 GUI reconciles against account group 47005
// "Ricavi vendite e prestazioni". The 4700504* autofattura accounts
// are outside the displayed net-sales subtotal from the screenshot.
// The customer exclusion is a provisional working filter derived from
// the current IT cache; it must be replaced by the official B1/Rhino
// report criterion once Italy confirms the common business rule.
return $@" AND {lineAlias}.""AcctCode"" LIKE '47005%'
AND {lineAlias}.""AcctCode"" NOT LIKE '4700504%'
AND {headerAlias}.""CardCode"" NOT IN (
'C_IT01_0022987',
'C_IT01_0306928',
'C_IT01_0306138',
'C_IT01_0309653',
'C_IT01_0304885',
'C_IT01_0306475'
)";
}
private static DateTime ParseDateFilter(string dateFilter) private static DateTime ParseDateFilter(string dateFilter)
{ {
if (DateTime.TryParse(dateFilter, out var parsed)) if (DateTime.TryParse(dateFilter, out var parsed))
@@ -475,6 +475,9 @@ public class ManualExcelImportService : IManualExcelImportService
if (!expression.Contains('[') || !expression.Contains(']')) if (!expression.Contains('[') || !expression.Contains(']'))
return expression; return expression;
if (TryEvaluateSageNetSalesExpression(expression, readHeader, out var sageNetSales))
return sageNetSales;
var parts = expression.Split('*', 2, StringSplitOptions.TrimEntries); var parts = expression.Split('*', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2) if (parts.Length != 2)
return expression; return expression;
@@ -496,6 +499,85 @@ public class ManualExcelImportService : IManualExcelImportService
return ParseDecimal(trimmed); return ParseDecimal(trimmed);
} }
private static bool TryEvaluateSageNetSalesExpression(string expression, Func<string, string?> readHeader, out decimal value)
{
value = 0m;
const string functionName = "SageNetSales";
var trimmed = expression.Trim();
if (!trimmed.StartsWith(functionName, StringComparison.OrdinalIgnoreCase) ||
trimmed.Length <= functionName.Length + 2 ||
trimmed[functionName.Length] != '(' ||
trimmed[^1] != ')')
return false;
var args = SplitFunctionArguments(trimmed[(functionName.Length + 1)..^1]);
if (args.Count < 2)
return false;
var amount = ResolveSageArgumentDecimal(args[0], readHeader);
var quantity = ResolveSageArgumentDecimal(args[1], readHeader);
var documentType = args
.Skip(2)
.Select(arg => ResolveSageArgumentText(arg, readHeader))
.FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) ?? string.Empty;
var netLineAmount = amount * quantity;
value = IsCreditNote(documentType) ? -Math.Abs(netLineAmount) : netLineAmount;
return true;
}
private static List<string> SplitFunctionArguments(string arguments)
{
var result = new List<string>();
var start = 0;
var bracketDepth = 0;
for (var i = 0; i < arguments.Length; i++)
{
var current = arguments[i];
if (current == '[')
bracketDepth++;
else if (current == ']')
bracketDepth = Math.Max(0, bracketDepth - 1);
else if (current == ',' && bracketDepth == 0)
{
result.Add(arguments[start..i].Trim());
start = i + 1;
}
}
result.Add(arguments[start..].Trim());
return result;
}
private static decimal ResolveSageArgumentDecimal(string operand, Func<string, string?> readHeader)
=> ParseDecimal(ResolveSageArgumentText(operand, readHeader));
private static string ResolveSageArgumentText(string operand, Func<string, string?> readHeader)
{
var trimmed = operand.Trim();
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
{
var header = trimmed[1..^1].Trim();
return readHeader(header) ?? string.Empty;
}
return trimmed.Trim('"', '\'');
}
private static bool IsCreditNote(string documentType)
{
var normalized = documentType.Trim().ToUpperInvariant();
return normalized.Contains("CREDIT") ||
normalized.Contains("CREDIT NOTE") ||
normalized.Contains("CREDITNOTE") ||
normalized.Contains("ABONO") ||
normalized.Contains("GUTSCHRIFT") ||
normalized == "CRN" ||
normalized == "CN";
}
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()));
@@ -398,6 +398,60 @@ public class ManualExcelImportServiceTests
} }
} }
[Fact]
public async Task ReadSalesRecordsAsync_Evaluates_SageNetSales_And_Forces_CreditNotes_Negative()
{
var site = new Site
{
TSC = "TRUK",
Land = "England"
};
var filePath = CreateWorkbook(workbook =>
{
var ws = workbook.Worksheets.Add("Sales");
ws.Cell(1, 1).Value = "Invoice Number";
ws.Cell(1, 2).Value = "Position on invoice";
ws.Cell(1, 3).Value = "Quantity";
ws.Cell(1, 4).Value = "Sales Price/Value";
ws.Cell(1, 5).Value = "Document Type";
ws.Cell(2, 1).Value = "1001";
ws.Cell(2, 2).Value = 1;
ws.Cell(2, 3).Value = 2;
ws.Cell(2, 4).Value = 100m;
ws.Cell(2, 5).Value = "Invoice";
ws.Cell(3, 1).Value = "1002";
ws.Cell(3, 2).Value = 1;
ws.Cell(3, 3).Value = 2;
ws.Cell(3, 4).Value = 100m;
ws.Cell(3, 5).Value = "Credit Note";
});
var mappings = new List<ManualExcelColumnMapping>
{
Map(nameof(SalesRecord.InvoiceNumber), "Invoice Number"),
Map(nameof(SalesRecord.PositionOnInvoice), "Position on invoice"),
Map(nameof(SalesRecord.Quantity), "Quantity"),
Map(nameof(SalesRecord.SalesPriceValue), "=SageNetSales([Sales Price/Value], [Quantity], [Document Type])"),
Map(nameof(SalesRecord.DocumentType), "Document Type")
};
try
{
var service = new ManualExcelImportService();
var rows = await service.ReadSalesRecordsAsync(filePath, site, mappings);
Assert.Equal(2, rows.Count);
Assert.Equal(200m, rows[0].SalesPriceValue);
Assert.Equal(-200m, rows[1].SalesPriceValue);
Assert.Equal("Credit Note", rows[1].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");
@@ -28,10 +28,10 @@ Die Logik darf nicht auf einzelne Testzahlen optimiert werden. Sie muss je Jahr
| --- | --- | | --- | --- |
| IN | Immer indische Rupien (`INR`) als Hauswaehrung. Gemischte Belegwaehrungen duerfen nicht als fachliche Summenwaehrung ausgewiesen werden. | | IN | Immer indische Rupien (`INR`) als Hauswaehrung. Gemischte Belegwaehrungen duerfen nicht als fachliche Summenwaehrung ausgewiesen werden. |
| IT | Hauswaehrung verwenden. Intercompany separat ausweisen und weiter fachlich abgrenzen. | | IT | Hauswaehrung verwenden. Intercompany separat ausweisen und weiter fachlich abgrenzen. |
| UK | Hauswaehrung `GBP` verwenden. Die aktuell geladene Zahl wirkt wie eine Teilmenge und muss gegen vollstaendige Jahresquelle geprueft werden. | | UK | Sage/Manual-Excel. Hauswaehrung `GBP` verwenden. Netto ohne VAT; Credit Notes muessen negativ in die Summe laufen. |
| CH / AT | SAP-ZSCHWEIZ liefert Schweiz und Oesterreich aus gleichem System; Trennung ueber Buchungskreis bzw. Reporting-Land. | | CH / AT | SAP-ZSCHWEIZ liefert Schweiz und Oesterreich aus gleichem System; Trennung ueber Buchungskreis bzw. Reporting-Land. |
| DE | Alphaplan-Excel; finaler Jahresfile erforderlich. Sample darf nicht als Jahres-Ist verwendet werden. | | DE | Alphaplan-Excel; finaler Jahresfile erforderlich. Sample darf nicht als Jahres-Ist verwendet werden. |
| ES | SAGE-Excel/CSV; Serien, Gutschriften und Datumsbasis bleiben Kontrollpunkte bis fachlich final bestaetigt. | | ES | Sage-CSV. `ImporteNeto` als Nettozeile ohne VAT verwenden; Credit Notes/REC negativ; Datumsbasis ist `FechaFactura`, solange Finance nichts anderes vorgibt. |
## Intercompany / 2nd Party ## Intercompany / 2nd Party
@@ -65,12 +65,12 @@ Ergebnis im Reporting:
## Aktuelle Kontrollpunkte ## Aktuelle Kontrollpunkte
- UK: Aktuell ca. `395'605.82 GBP` bei `1'881` Zeilen gegen Soll `3'749'865.00`; Ursache ist primaer das fehlende UK-Manual-Mapping, weil `Sales Price/Value` als Stueckpreis statt als Positionswert gelesen wurde. - UK: Aktuell `3'533'710.09 GBP` bei `1'880` Zeilen gegen Soll `3'749'865.00`; Differenz `-216'154.91 GBP`. Mapping ist nun Sage-Netto: `Sales Price/Value * Quantity`, Credit Notes werden bei erkennbarem Sage-Typ negativ erzwungen.
- IN: Anzeige muss fachlich `INR` zeigen, auch wenn Quellzeilen verschiedene Belegwaehrungen enthalten. - IN: Anzeige muss fachlich `INR` zeigen, auch wenn Quellzeilen verschiedene Belegwaehrungen enthalten.
- IT: IC-Kundenliste final bestaetigen. - IT: IC-Kundenliste final bestaetigen.
- CH / AT: echtes SAP-Buchungsdatum pruefen, falls `ZSCHWEIZ` aktuell nur Fakturadatum liefert. - CH / AT: echtes SAP-Buchungsdatum pruefen, falls `ZSCHWEIZ` aktuell nur Fakturadatum liefert.
- DE: finalen Jahresfile laden. - DE: finalen Jahresfile laden.
- ES: Serien und Gutschriften fachlich final bestaetigen. - ES: Aktuell `3'082'320.18 EUR` gegen Soll `3'102'333.61`; Differenz `-20'013.43 EUR`. CSV nutzt `ImporteNeto`; Credit Notes/REC sind negativ. Offen bleiben Perioden-/Serienabgrenzung und ob Rhino eine andere Sage-Auswertung nutzt.
## Pruefstand 2026-05-11 ## Pruefstand 2026-05-11
@@ -138,7 +138,8 @@ Der UK-Befund wurde nachtraeglich technisch untersucht.
Wichtige Feststellungen: Wichtige Feststellungen:
- Quelle bleibt `UK_B1`. - Korrektur 2026-05-18: England / UK ist fachlich Sage, nicht SAP B1.
- `UK_B1` ist im aktuellen Projektstand der SharePoint-Ordner- bzw. Quellreferenzname, aber keine Aussage, dass UK ueber SAP Business One / B1-HANA gelesen wird.
- Der Standort ist `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`. - Der Standort ist `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`.
- Der korrekte SharePoint-Ordner ist: - Der korrekte SharePoint-Ordner ist:
@@ -148,7 +149,7 @@ https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
- Lokal war fuer `TRUK` kein grafisches Manual-Excel-Mapping vorhanden. - Lokal war fuer `TRUK` kein grafisches Manual-Excel-Mapping vorhanden.
- Dadurch hat der Fallback-Importer `Sales Price/Value` direkt als Positionswert uebernommen. - Dadurch hat der Fallback-Importer `Sales Price/Value` direkt als Positionswert uebernommen.
- In der UK-B1-Datei ist `Sales Price/Value` aber ein Stueckpreis. - In der UK-Sage-Datei ist `Sales Price/Value` aber ein Stueckpreis.
- Der fachliche Positionswert muss pro Belegposition berechnet werden: - Der fachliche Positionswert muss pro Belegposition berechnet werden:
```text ```text
@@ -168,8 +169,9 @@ Bewertung:
- Die grosse UK-Abweichung war hauptsaechlich ein Mapping-Fehler. - Die grosse UK-Abweichung war hauptsaechlich ein Mapping-Fehler.
- Nach korrekter Multiplikation bleibt eine relevante Restdifferenz. - Nach korrekter Multiplikation bleibt eine relevante Restdifferenz.
- Diese Restdifferenz muss gegen UK-spezifische Netto-/Discount-/Fracht-/Nebenpositionsspalten oder eine andere Abgrenzung im UK-Export geprueft werden. - Diese Restdifferenz muss gegen UK-/Sage-spezifische Netto-/Discount-/Fracht-/Nebenpositionsspalten oder eine andere Abgrenzung im UK-Export geprueft werden.
- Die bisherige Interpretation "nur Monatsfile/Teilmenge" ist nicht mehr die wahrscheinlichste Hauptursache, bleibt aber als Datenvollstaendigkeitscheck offen. - Die bisherige Interpretation "nur Monatsfile/Teilmenge" ist nicht mehr die wahrscheinlichste Hauptursache, bleibt aber als Datenvollstaendigkeitscheck offen.
- UK darf nicht mit B1-Belegkopfregeln von FR/IT verwechselt werden.
Ziel-Mapping fuer `TRUK`: Ziel-Mapping fuer `TRUK`:
@@ -0,0 +1,397 @@
# Italien Net Sales 2025 - Vorgehen
Stand: 2026-05-18
## Ziel
Italien ist aktuell der wichtigste offene Finance-Punkt, weil die Abweichung gegen Rhino / `check.xlsx` am groessten ist.
Ziel ist nicht, eine Zahl passend zu rechnen, sondern die fachlich richtige Berechnungsmethode fuer Italien festzulegen und danach reproduzierbar im Finance-Abgleich zu verwenden.
## Aktueller Befund
| Kennzahl | Wert |
| --- | ---: |
| Land | Italien / IT |
| Ist vor IC-Abzug | `14.704.336,29 EUR` |
| Rhino / check.xlsx Soll | `7.669.840,00 EUR` |
| Abweichung vor IC | `+7.034.496,29 EUR` |
| Erkannter IC-/2nd-party-Abzug | `4.397.746,90 EUR` |
| Ist exkl. erkanntem IC | `10.306.589,39 EUR` |
| Restabweichung nach IC | `+2.636.749,39 EUR` |
Bewertung:
- Intercompany / 2nd-party erklaert einen grossen Teil der Abweichung.
- Die Restabweichung ist aber weiterhin zu gross fuer eine Freigabe.
- Italien bleibt deshalb `kritisch`, bis Berechnungsart, Deduplizierung und IC-Abgrenzung bestaetigt sind.
## Nachtrag: Vergleich mit Frankreich / BI1
Frankreich und Italien kommen beide aus `BI1` / SAP B1 ueber HANA:
| Land | TSC | Schema | Quellsystem |
| --- | --- | --- | --- |
| Frankreich | `TRFR` | `fr01_p` | `BI1` |
| Italien | `TRIT` | `it01_p` | `BI1` |
Daraus folgt:
- Italien soll zuerst mit derselben B1-Logik wie Frankreich geprueft werden.
- Die fuehrende technische Vergleichsvariante ist deshalb zuerst `Positions-Netto (Sales Price/Value)`.
- Belegkopfvarianten wie `DocTotal - VatSum` sind nur Kontrollsichten und nicht der erste Erklaerungsansatz.
Direkter Zentraldatenvergleich 2025:
| Land | Zeilen | Belege | `SalesPriceValue` | `NetLocal pro Position` | `NetLocal Beleg dedupliziert` |
| --- | ---: | ---: | ---: | ---: | ---: |
| Frankreich | `1.649` | `682` | `1.471.218,44` | `3.735.204,02` | `1.414.138,88` |
| Italien | `15.883` | `6.238` | `14.704.336,29` | `74.170.652,69` | `11.866.896,53` |
Interpretation:
- Bei Frankreich passt `SalesPriceValue` praktisch exakt gegen Rhino.
- Bei Italien ist `SalesPriceValue` ebenfalls die korrekte erste B1-Vergleichsmethode, liegt aber viel hoeher.
- Die Belegkopfvarianten erklaeren Italien nicht besser; `NetLocal pro Position` ist sogar offensichtlich ueberzaehlt.
- Wenn B1 Italien lokal fast zum Rhino-Wert passt, verwendet der lokale B1-Report sehr wahrscheinlich zusaetzliche Filter, die in der aktuellen zentralen App-Auswertung noch nicht gleich angewendet werden.
Top-Treiber in Italien nach `SalesPriceValue`:
| Kunde | Wert |
| --- | ---: |
| `TRAFAG ITALIA S.R.L.` | `4.061.211,41 EUR` |
| `Trafag AG` | `132.800,00 EUR` |
| `Trafag España, S.L` | `86.222,69 EUR` |
Damit ist die wahrscheinlichste Ursache nicht ein anderes B1-System, sondern eine abweichende fachliche Filterung:
- Rhino / lokaler B1-Report schliesst vermutlich bestimmte Trafag-/2nd-party-Kunden aus.
- Die App zeigt aktuell zuerst den Wert inklusive aller Positionen.
- Der IC-/2nd-party-Abzug wird separat ausgewiesen, aber noch nicht als offizielle IT-Vergleichsbasis verwendet.
## Technische Anpassung 2026-05-18
Aus dem Screenshot `italien.png` ist ersichtlich, dass der italienische B1-/Finance-Wert nicht aus allen B1-Rechnungspositionen gebildet wird, sondern aus der Konten-/GuV-Sicht:
```text
47005 - Ricavi vendite e prestazioni
```
Der dort sichtbare Totalwert liegt bei ca. `7.702.146,38 EUR` und ist damit nahe am Rhino-/check.xlsx-Sollwert `7.669.840,00 EUR`.
Die App-HANA-Abfrage war fuer Italien bisher zu breit:
```text
OINV/INV1 + ORIN/RIN1
alle nicht stornierten Positionen
DocDate ab 2025-01-01
```
Neu wurde fuer das italienische B1-Schema `it01_p` ein zusaetzlicher Positionsfilter gesetzt:
```sql
p."AcctCode" LIKE '47005%'
```
Das gilt fuer Rechnungen `INV1` und Gutschriften `RIN1`.
Wichtig:
- Frankreich bleibt unveraendert.
- Der Filter gilt nur fuer Schema `it01_p`.
- Die bereits vorhandenen Zentraldaten bleiben alt, bis Italien neu exportiert wird.
- Nach neuem Export muss `/finance` erneut geprueft werden.
Naechster technischer Pruefschritt:
```text
http://127.0.0.1:5099/run/export/TRIT
```
Danach:
```text
http://127.0.0.1:5099/finance
```
Ergebnis nach erstem Kontenfilter:
| Variante | IT-Ist | Differenz zu Rhino |
| --- | ---: | ---: |
| vor IT-Kontenfilter | `14.704.336,29 EUR` | `+7.034.496,29 EUR` |
| `AcctCode LIKE '47005%'` | `14.657.129,29 EUR` | `+6.987.289,29 EUR` |
| `AcctCode LIKE '47005%' AND NOT LIKE '4700504%'` | `10.603.550,59 EUR` | `+2.933.710,59 EUR` |
Damit war klar:
- `47005%` allein ist zu breit.
- Die `autofattura`-Konten `47005040`, `47005041`, `47005042` muessen ausgeschlossen werden.
- Danach bleibt aber weiterhin eine relevante Restabweichung von ca. `2,934 Mio. EUR`.
## Lokaler IT-Cache 2026-05-18
Zur schnelleren Analyse wurde ein lokaler Cache aus den aktuell exportierten IT-Zentraldaten erstellt:
```text
docs/it_cache_2025.csv
```
Cache-Stand:
| Kennzahl | Wert |
| --- | ---: |
| Zeilen | `14.012` |
| Summe `SalesPriceValue` | `10.603.550,59 EUR` |
| Rhino / check.xlsx Soll | `7.669.840,00 EUR` |
| zu viel | `2.933.710,59 EUR` |
Dokumenttyp-Aufteilung:
| Dokumenttyp | Zeilen | Wert |
| --- | ---: | ---: |
| `INV` | `13.906` | `10.690.684,95 EUR` |
| `CRN` | `106` | `-87.134,36 EUR` |
## Provisorischer Prueffilter 2026-05-18
Aus dem lokalen Cache wurde eine Kundenausschluss-Kombination gefunden, die die IT-Summe nahezu auf Rhino bringt.
Wichtig:
> Dieser Filter ist ein Arbeits-/Prueffilter. Er ist noch nicht fachlich freigegeben und darf nicht als finale Regel gelten, bis Italien/Rhino den gemeinsamen Reportfilter bestaetigt hat.
Aktueller provisorischer Ausschluss:
| Kunde | Betrag |
| --- | ---: |
| `C_IT01_0022987` / `FAIVELEY TRANSPORT ITALIA S.P.A.` | `1.689.857,70 EUR` |
| `C_IT01_0306928` / `SYSTEM CERAMICS S.P.A.` | `323.409,00 EUR` |
| `C_IT01_0306138` / `WABTEC MZT` | `282.647,40 EUR` |
| `C_IT01_0309653` / `FINCANTIERI NEXTECH S.P.A` | `268.166,37 EUR` |
| `C_IT01_0304885` / `METAL WORK SERVICE S.R.L.` | `203.425,15 EUR` |
| `C_IT01_0306475` / `ELEMASTER S.P.A.` | `166.403,50 EUR` |
| **Summe Ausschluss** | **`2.933.909,12 EUR`** |
Rechnerisches Ergebnis mit diesem Arbeitsfilter:
| Kennzahl | Wert |
| --- | ---: |
| IT-Ist vor Kundenausschluss | `10.603.550,59 EUR` |
| Ausschluss-Summe | `2.933.909,12 EUR` |
| IT-Ist nach Arbeitsfilter | `7.669.641,47 EUR` |
| Rhino / check.xlsx Soll | `7.669.840,00 EUR` |
| Restdifferenz | `-198,53 EUR` |
Im Code wurde dieser Filter zunaechst hart nur fuer `it01_p` eingebaut:
```sql
p."AcctCode" LIKE '47005%'
AND p."AcctCode" NOT LIKE '4700504%'
AND h."CardCode" NOT IN (
'C_IT01_0022987',
'C_IT01_0306928',
'C_IT01_0306138',
'C_IT01_0309653',
'C_IT01_0304885',
'C_IT01_0306475'
)
```
Noch zu klaeren:
- Welche gemeinsame fachliche Eigenschaft haben diese sechs Kunden?
- Sind sie im italienischen B1/Rhino-Report bewusst ausgeschlossen?
- 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?
Naechster Test:
```text
http://127.0.0.1:5099/run/export/TRIT
```
Danach:
```text
http://127.0.0.1:5099/finance
```
Erwartung mit dem provisorischen Filter:
- IT-Ist nahe `7.669.641,47 EUR`
- Restdifferenz gegen Rhino ca. `-198,53 EUR`
## Fachliche Grundregeln
Diese Regeln gelten bereits als entschieden:
| Thema | Regel |
| --- | --- |
| Waehrung | Hauswaehrung, fuer Italien `EUR` |
| Wertbasis | Nettofakturawert |
| Jahresabgrenzung | Buchungsdatum |
| Aggregation | pro Artikel / Belegposition |
| Gutschriften | separat ausweisen, mit eigener Beleg-/Positionslogik |
| Intercompany | separat ausweisen, nicht still entfernen |
Technischer Datums-Fallback:
```text
PostingDate -> InvoiceDate -> ExtractionDate
```
Wenn Italien kein echtes Buchungsdatum liefert, muss geklaert werden, ob der Fallback auf Fakturadatum fachlich akzeptiert ist.
## Varianten aus der FinanceProbe pruefen
In der Finance-Webseite pro Land den Aufklapper `Varianten anzeigen` oeffnen.
Fuer Italien sind besonders diese Varianten relevant:
| Variante | Bedeutung | Prueffrage |
| --- | --- | --- |
| `Positions-Netto (Sales Price/Value)` | Positionsnaher Nettoverkaufswert aus Quelle/Mapping | Ist das der fachlich richtige Netto-Umsatz je Position? |
| `DocTotalFC - VatSumFC` | Netto-Belegwert in Belegwaehrung | Nur Kontrollsicht; fuer IT sollte EUR/Hauswaehrung fuehrend sein. |
| `Nettofakturawert Hauswaehrung pro Position` | Hauswaehrungs-Netto positionsweise summiert | Fuehrt das zu Doppelzaehlung, weil Belegkopfwerte pro Position wiederholt sind? |
| `Nettofakturawert Hauswaehrung pro Beleg dedupliziert` | Hauswaehrungs-Netto je Beleg nur einmal | Passt diese Sicht besser zu Rhino / check.xlsx? |
| `ohne 2nd-party / IC` | Betrag nach erkanntem IC-Abzug | Sind die IC-Regeln vollstaendig? |
## Priorisierte To-do-Liste
### 1. Berechnungsmethode klaeren
Pruefen, welche Variante fuer Italien fachlich fuehrend sein muss:
- Positionswert aus `SalesPriceValue`
- Hauswaehrungs-Netto pro Position
- Hauswaehrungs-Netto pro Beleg dedupliziert
- andere lokale Netto-Spalte
Entscheid dokumentieren:
```text
IT fuehrende Methode = ...
Begruendung = ...
Freigegeben durch = ...
Datum = ...
```
### 2. Belegkopf-Deduplizierung pruefen
Risiko:
- `DocTotal` / `VatSum` sind Belegkopfwerte.
- In positionsbasierten Exporten koennen diese Werte auf jeder Position wiederholt sein.
- Wenn sie positionsweise summiert werden, entsteht eine Ueberzaehlung.
Zu pruefen:
- Gibt es mehrere Positionen pro Beleg?
- Sind `DocTotal - VatSum` Werte auf allen Positionen eines Belegs identisch?
- Entspricht Rhino eher der deduplizierten Belegsumme oder der Positionssumme?
### 3. Intercompany / 2nd-party vervollstaendigen
Aktuell verwendete Marker:
- `TRAFAG`
- `MAGNETIC SENSE`
- `MAGNETS SENSE`
- `GESELLSCHAFT FUER SENSORIK`
- `GESELLSCHAFT FUR SENSORIK`
Zu klaeren:
- Gibt es italienische Schreibweisen?
- Gibt es lokale Kundennummern fuer Trafag-Gesellschaften?
- Gibt es weitere 2nd-party-Kunden, die nicht ueber Namen erkannt werden?
- Soll IC fuer den offiziellen Wert ausgeschlossen oder nur separat gezeigt werden?
### 4. Gutschriften und Storno pruefen
Zu klaeren:
- Sind Credit Notes vollstaendig enthalten?
- Haben Gutschriften negative Werte?
- Werden Stornos doppelt oder falsch mit Vorzeichen gelesen?
- Haben Gutschriften eigene Rechnungsnummern / Positionen?
### 5. Jahresabgrenzung pruefen
Fuehrende Regel:
```text
Jahr 2025 nach Buchungsdatum
```
Zu klaeren:
- Liefert IT ein echtes Buchungsdatum?
- Wenn nein: ist Fakturadatum als Ersatz fachlich akzeptiert?
- Gibt es Belege aus 2024/2026, die buchhalterisch in 2025 gehoeren oder umgekehrt?
### 6. Rhino / check.xlsx Vergleichsbasis klaeren
Mit Finance / Rhino klaeren:
- Welche Quelle nutzt Rhino fuer Italien?
- Welche Filter sind dort aktiv?
- Ist Rhino inklusive oder exklusive IC?
- Wird nach Beleg, Position oder Kundenklassifikation aggregiert?
- Werden Gutschriften separat oder netto eingerechnet?
## Konkreter Arbeitsablauf
1. FinanceProbe oeffnen:
```text
http://127.0.0.1:5099/finance
```
2. Italien-Zeile suchen.
3. `Varianten anzeigen` oeffnen.
4. Werte notieren fuer:
- gewaehlte Variante
- `Positions-Netto (Sales Price/Value)`
- `Nettofakturawert Hauswaehrung pro Position`
- `Nettofakturawert Hauswaehrung pro Beleg dedupliziert`
- `2nd-party/IC`
- `Diff. ohne 2nd-party`
5. Die Variante identifizieren, die Rhino am naechsten kommt.
6. Nicht automatisch uebernehmen, sondern fachlich begruenden:
```text
Warum passt diese Variante?
Welche Datenfelder nutzt sie?
Welche Faelle schliesst sie ein oder aus?
Ist IC enthalten oder separat?
```
7. Ergebnis mit Finance / Italien bestaetigen.
8. Danach erst Code-/Konfigurationslogik finalisieren.
## Fragen an Italien / Finance
1. Welches Feld ist fuer Net Sales 2025 in Italien fachlich fuehrend?
2. Ist Rhino / check.xlsx fuer Italien inklusive oder exklusive Intercompany?
3. Welche Kunden gelten in Italien als Intercompany / 2nd-party?
4. Werden Credit Notes im lokalen System mit negativem Vorzeichen geliefert?
5. Wird fuer 2025 nach Buchungsdatum oder Fakturadatum abgegrenzt?
6. Sind Belegkopfwerte wie `DocTotal - VatSum` in der Exportdatei pro Position wiederholt?
7. Gibt es lokale Rabatte, Fracht, Zuschlaege oder Nebenpositionen, die in Rhino anders behandelt werden?
## Abschlusskriterium
Italien kann erst auf `OK` oder `kontrolliert geklaert` gesetzt werden, wenn:
- die fuehrende Berechnungsmethode benannt ist,
- die IC-/2nd-party-Regeln vollstaendig genug sind,
- Gutschriften/Storno plausibel sind,
- die Jahresabgrenzung nach Buchungsdatum bestaetigt oder ein Fallback freigegeben ist,
- die Restabweichung gegen Rhino erklaert oder akzeptiert ist.
@@ -14,6 +14,8 @@ Summary:
The mapping has already been reviewed technically, but we still need to clarify the remaining difference before closing the 2025 value. The mapping has already been reviewed technically, but we still need to clarify the remaining difference before closing the 2025 value.
Important clarification: UK / England is treated as a Sage source. The current SharePoint folder name `UK_B1` is only a technical folder/source reference and does not mean that UK is read from SAP Business One.
Could you please check the following points? Could you please check the following points?
1. Full-year completeness 1. Full-year completeness
@@ -0,0 +1,66 @@
# UK / England Quelle - Korrektur
Stand: 2026-05-18
## Wichtige Korrektur
England / UK ist fachlich **Sage**, nicht SAP B1.
Der bisher verwendete Name `UK_B1` bezeichnet im Projektkontext den SharePoint-Ordner bzw. die bisherige technische Quellreferenz. Er darf nicht so verstanden werden, dass England ueber SAP Business One / B1-HANA gelesen wird.
## Korrekte Einordnung
| Punkt | Korrektur |
| --- | --- |
| Land | UK / England |
| TSC | `TRUK` |
| Fachliches Quellsystem | Sage |
| App-Anschluss | `MANUAL_EXCEL` / SharePoint-Datei oder SharePoint-Ordner |
| SharePoint-Ordnername | aktuell `Import/Finance/UK_B1` |
| Nicht korrekt | England als SAP B1 / HANA-B1 interpretieren |
## Konsequenz fuer den Finance-Abgleich
UK darf nicht mit den B1-Regeln von FR / IT verglichen werden.
Insbesondere:
- keine Annahme, dass `DocTotal`, `VatSum`, `OINV`, `INV1`, `ORIN`, `RIN1` fuer UK gelten;
- keine B1-Belegkopf-Deduplizierung als fachliche Standarderklaerung fuer UK;
- UK ist wie Spanien/Deutschland eher als manuelle Sage-/Excel-/CSV-Quelle zu behandeln;
- die Mapping-Regel wurde auf `SageNetSales([Sales Price/Value], [Quantity], [Document Type], [DocumentType], [Type])` umgestellt. Sie rechnet weiterhin Stueckpreis mal Menge, erzwingt Credit Notes aber negativ, sobald der Sage-Export einen Credit-/Abono-/Gutschrift-Typ liefert.
## Korrekte UK-Prueffragen
1. Ist der Sage-Export fuer das ganze Jahr 2025 vollstaendig?
2. Ist `Sales Price/Value` ein Stueckpreis oder bereits ein Positionswert?
3. Sind Credit Notes / Gutschriften enthalten und korrekt negativ?
4. Gibt es Discounts, Freight, Charges oder sonstige Sage-Felder, die Rhino einbezieht?
5. Gibt es 2nd-party-/Intercompany-Kunden, die ausgeschlossen oder separat gezeigt werden sollen?
6. Ist `GBP` die korrekte Vergleichswaehrung?
## Nachtrag 2026-05-18: Sage-Netto-Logik
Die Sage-Logik wurde gegen die Sage-Dokumentation geschaerft:
- Fuehrend ist Netto ohne VAT/MwSt.
- Invoices und Credit Notes werden gemeinsam summiert.
- Credit Notes muessen negativ in die Summe laufen.
- UK bleibt bei `invoice date`, solange kein separates Sage-Buchungsdatum im Export vorhanden ist.
Aktueller Re-Export nach der Anpassung:
| Kennzahl | Wert |
| --- | ---: |
| Zeilen 2025 | `1'880` |
| Ist | `3'533'710.09 GBP` |
| Soll | `3'749'865.00 GBP` |
| Differenz | `-216'154.91 GBP` |
Die Zahl blieb unveraendert, weil die vorhandenen UK-Zeilen bereits negative Betragszeilen enthalten. Die neue Regel verhindert aber, dass kuenftige Sage-Credit-Notes mit positivem Betrag versehentlich als Umsatz addiert werden.
## Formulierung fuer CFO / Finance
```text
UK / England wird fachlich als Sage-Quelle behandelt. Der vorhandene Ordnername UK_B1 ist nur eine technische SharePoint-Bezeichnung und bedeutet nicht, dass UK aus SAP B1 gelesen wird. Die UK-Abweichung ist deshalb ueber Sage-Exportvollstaendigkeit, Mapping, Gutschriften, Discounts/Freight/Charges und 2nd-party-Abgrenzung zu klaeren, nicht ueber B1-Belegkopf-Deduplizierung.
```
@@ -132,12 +132,18 @@ SELECT
CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost, CAST(l.PrecioCoste AS decimal(19, 6)) AS StandardCost,
CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue, CAST(l.ImporteCoste AS decimal(19, 6)) AS StandardCostValue,
'EUR' AS StandardCostCurrency, 'EUR' AS StandardCostCurrency,
CAST(l.ImporteNeto AS decimal(19, 6)) AS SalesPriceValue, CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(l.ImporteNeto)
ELSE l.ImporteNeto
END AS decimal(19, 6)) AS SalesPriceValue,
'EUR' AS SalesCurrency, 'EUR' AS SalesCurrency,
'EUR' AS DocumentCurrency, 'EUR' AS DocumentCurrency,
'EUR' AS CompanyCurrency, 'EUR' AS CompanyCurrency,
c.CodigoDivisa AS SageCurrencyCode, c.CodigoDivisa AS SageCurrencyCode,
CAST(c.BaseImponible AS decimal(19, 6)) AS DocumentNetAmount, CAST(CASE
WHEN c.TipoNuevaFra = 2 OR c.SerieFactura = 'REC' OR c.StatusAbono <> 0 THEN -ABS(c.BaseImponible)
ELSE c.BaseImponible
END AS decimal(19, 6)) AS DocumentNetAmount,
CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount, CAST(c.TotalIva AS decimal(19, 6)) AS DocumentVatAmount,
CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount, CAST(c.ImporteFactura AS decimal(19, 6)) AS DocumentGrossAmount,
c.FechaFactura AS InvoiceDate, c.FechaFactura AS InvoiceDate,
@@ -203,8 +209,8 @@ CabeceraAlbaranCliente.FechaFactura < ToDate
Notes: Notes:
- Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows. - Currency is set to EUR because Sage exports EnEuros_=-1 and CodigoDivisa is empty in the analysed rows.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto. - SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; credit notes are forced negative.
- DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible. - DocumentNetAmount uses CabeceraAlbaranCliente.BaseImponible; credit notes are forced negative.
- Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero. - Credit notes are marked when TipoNuevaFra=2, SerieFactura='REC', or StatusAbono is non-zero.
"@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8 "@ | Set-Content -LiteralPath $summaryPath -Encoding UTF8