Commit pending finance and Power BI work

This commit is contained in:
2026-05-13 07:33:00 +02:00
parent 1cd0ad998f
commit 001e2a73d5
44 changed files with 3210 additions and 104 deletions
+122
View File
@@ -2,6 +2,112 @@
Stand: 2026-05-05 Stand: 2026-05-05
## Nachtrag 2026-05-11 UK_B1 Mapping / aktueller Arbeitsstand
Letzter Benutzerwunsch:
- UK/England soll weiter ueber `UK_B1` laufen.
- Das Mapping soll so angepasst werden, dass die Finance-Zahl plausibel wird.
- Danach soll alles nachvollziehbar dokumentiert sein.
Wichtiger Befund:
- FinanceProbe zeigte fuer UK/England:
- `TSC = TRUK`
- `1'881` Zeilen
- Ist `395'605.82 GBP`
- Soll `3'749'865.00 GBP`
- In der lokalen DB waren fuer `TRUK` keine `ManualExcelColumnMappings` vorhanden.
- Der Fallback-Importer hat `Sales Price/Value` direkt als Positionswert importiert.
- Im UK-B1-Export ist `Sales Price/Value` aber ein Stueckpreis.
- Korrekte Positionslogik:
```text
SalesPriceValue = [Sales Price/Value] * [Quantity]
```
Probe auf existierenden Zentraldaten:
```text
Summe SalesPriceValue bisher: 395'605.82 GBP
Summe SalesPriceValue * Quantity: 3'533'348.89 GBP
check.xlsx Soll: 3'749'865.00 GBP
Restdifferenz: -216'516.11 GBP
```
Geaenderte Dateien im aktuellen Worktree:
- `Services/ManualExcelImportService.cs`
- grafische Manual-Excel-Mappings koennen einfache Multiplikationsausdruecke lesen:
```text
=[Header A]*[Header B]
```
- Konstanten wie `=GBP` funktionieren weiterhin.
- `Services/DatabaseSeedService.cs`
- repariert England/TRUK auf:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- seedet fuer `TRUK` ein grafisches Mapping, insbesondere:
```text
SalesPriceValue <- =[Sales Price/Value]*[Quantity]
SalesCurrency <- =GBP
DocumentCurrency<- =GBP
CompanyCurrency <- =GBP
PostingDate <- invoice date
InvoiceDate <- invoice date
```
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
- neuer Test fuer berechnetes Manual-Excel-Mapping.
Aktueller Teststand:
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal`
- Tests erfolgreich: `59/59`.
- Bekannte Warnungen: bestehende MudBlazor-Analyzerwarnungen zu `Dense`.
Zusatzfix:
- `DatabaseSeedService` prueft vor `EnsureUkManualExcelMapping(...)`, ob `ManualExcelColumnMappings` sauber auf `Sites` referenziert.
- Falls die Tabelle noch auf `Sites_repair_old` oder eine andere `Sites_*`-Reparaturtabelle zeigt, wird der UK-Mapping-Seed fuer diesen Start uebersprungen.
- Dadurch kann die Schema-Reparatur sauber durchlaufen.
Naechster praktischer Schritt:
1. SharePoint-/Graph-Zugriff reparieren.
2. FinanceProbe ist bereits auf `http://127.0.0.1:5099` gestartet.
3. `/run/export/TRUK` erneut ausfuehren.
4. `/finance` erneut pruefen.
Praktischer Stand:
- Lokale DB ist aktualisiert:
- `TRUK` Pfad = `UK_B1`
- `18` aktive Manual-Excel-Mapping-Zeilen
- `/finance` antwortet mit HTTP `200`.
- `/run/export/TRUK` scheitert aktuell an Auth/Netzwerk:
```text
ClientSecretCredential authentication failed
127.0.0.1:9 connection refused
```
- Deshalb enthaelt `CentralSalesRecords` fuer UK noch den alten Importstand, bis SharePoint wieder erreichbar ist.
Wichtig:
- Das ist keine Sonderlogik, die UK-Zahlen schoenrechnet.
- Der Mapper setzt die allgemeine fachliche Regel "pro Artikel / Belegposition" um.
- Die Formel ist im grafischen Mapping sichtbar und nicht hart als UK-Spezialberechnung im Importcode versteckt.
- Falls nach neuem Export noch eine Restdifferenz bleibt, muss die UK-Datei auf weitere Netto-/Discount-/Frachtspalten geprueft werden.
## Nachtrag 2026-05-08 Manual Excel/CSV / SharePoint-Ordner ## Nachtrag 2026-05-08 Manual Excel/CSV / SharePoint-Ordner
Aktueller Stand fuer manuelle Quellen: Aktueller Stand fuer manuelle Quellen:
@@ -72,6 +178,22 @@ Wichtig:
- `Keine Daten` bedeutet jetzt nicht zwingend fehlende Referenz, sondern oft: Referenz ist vorhanden, aber Ist-Daten wurden noch nicht exportiert/importiert. - `Keine Daten` bedeutet jetzt nicht zwingend fehlende Referenz, sondern oft: Referenz ist vorhanden, aber Ist-Daten wurden noch nicht exportiert/importiert.
- Fuer neue Laender reicht es, `FinanceReferences` zu pflegen und Daten nach `CentralSalesRecords` zu bringen; die Probe zeigt sie dann automatisch. - Fuer neue Laender reicht es, `FinanceReferences` zu pflegen und Daten nach `CentralSalesRecords` zu bringen; die Probe zeigt sie dann automatisch.
## Nachtrag 2026-05-11 FinanceProbe KI-Steuerung
FinanceProbe kann jetzt nicht nur vergleichen, sondern im Testkontext auch Exporte ausloesen:
- `/run/export/{siteKey}`: einzelner Standort nach `Id`, `TSC` oder `Land`
- `/run/export-all`: alle aktiven Standorte plus zentrale Datei
- `/run/consolidated`: zentrale Datei aus `CentralSalesRecords`
Die Routen liefern eine HTML-Run-Summary mit Exportlogs, Finance-Abgleich und Datenabdeckung.
Wichtig:
- Das ist eine temporaere Test-/KI-Steuerung.
- Nicht als produktive API betrachten.
- Echte SAP/HANA/SharePoint-Zugriffe funktionieren nur mit vorhandenen Credentials und Netzverbindung auf dem Rechner.
## Nachtrag 2026-05-07 Mapper-Konsolidierung / Finance-Konfiguration ## Nachtrag 2026-05-07 Mapper-Konsolidierung / Finance-Konfiguration
Architekturstand: Architekturstand:
@@ -42,6 +42,7 @@ public class CentralSalesRecord
public string CompanyCurrency { get; set; } = string.Empty; public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; } public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
@@ -32,6 +32,7 @@ public class SalesRecord
public string CompanyCurrency { get; set; } = string.Empty; public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; } public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
@@ -2,6 +2,69 @@
Stand: 2026-05-05 Stand: 2026-05-05
## Nachtrag 2026-05-11 UK_B1 Mapping fertigstellen
Aktueller Stand:
- UK/England bleibt auf Quelle `UK_B1`.
- Korrekte Quelle:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- Ursache der grossen UK-Abweichung:
- kein grafisches Mapping fuer `TRUK`
- `Sales Price/Value` wurde als Positionswert gelesen
- in UK_B1 ist es nach aktuellem Befund ein Stueckpreis
- korrekte Formel ist `=[Sales Price/Value]*[Quantity]`
Bereits im Worktree umgesetzt:
- `ManualExcelImportService` kann berechnete Mapping-Quellen `=[Header A]*[Header B]`.
- `DatabaseSeedService` seedet/repariert UK_B1-Pfad und `TRUK`-Mapping.
- `DatabaseSeedService` ueberspringt den UK-Mapping-Seed, solange `ManualExcelColumnMappings` noch auf eine alte SQLite-Reparaturtabelle wie `Sites_repair_old` zeigt.
- Unit-Test fuer berechnetes Manual-Excel-Mapping ist vorhanden.
- Doku wurde in `docs/FINANCE_ENTSCHEIDE.md`, `lastchange.md` und `HANDOFF_2026-04-15.md` ergaenzt.
- Tests sind gruen: `59/59`.
Verifizierter Testlauf:
```text
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
```
Noch offen fuer den praktischen UK-Check:
1. SharePoint-/Graph-Zugriff reparieren.
- letzter Fehler bei `/run/export/TRUK`:
```text
ClientSecretCredential authentication failed
127.0.0.1:9 connection refused
```
2. UK neu exportieren:
```text
http://127.0.0.1:5099/run/export/TRUK
```
3. Finance pruefen:
```text
http://127.0.0.1:5099/finance
```
4. Ergebnis bewerten:
- wenn UK nahe `3'749'865 GBP` liegt: Mapping war Hauptursache.
- wenn UK bei ca. `3'533'349 GBP` bleibt: Restdifferenz gegen weitere UK-Netto-/Discount-/Frachtspalten pruefen.
Nicht vergessen:
- Keine harte Spezialkorrektur fuer genau 2025 einbauen.
- Die Loesung muss ueber Mapping und allgemeine Positionslogik laufen, damit andere Jahre ebenfalls korrekt funktionieren.
## Nachtrag 2026-05-08 Manual Excel/CSV SharePoint-Automatik ## Nachtrag 2026-05-08 Manual Excel/CSV SharePoint-Automatik
Erledigt: Erledigt:
@@ -43,6 +106,23 @@ Naechste fachliche Schritte:
- Referenz ist nur zukuenftig relevant - Referenz ist nur zukuenftig relevant
4. Fuer AT/CH nach `ZSCHWEIZ`-Export pruefen, ob `LAND1` korrekt `AT` bzw. `CH` liefert. 4. Fuer AT/CH nach `ZSCHWEIZ`-Export pruefen, ob `LAND1` korrekt `AT` bzw. `CH` liefert.
## Nachtrag 2026-05-11 FinanceProbe KI-Steuerung
Neue Test-Routen:
- `/run/export/{siteKey}` fuer Einzelstandortexporte
- `/run/export-all` fuer alle aktiven Standorte plus zentrale Datei
- `/run/consolidated` fuer nur zentrale Datei
Naechster sinnvoller Prueflauf:
1. FinanceProbe starten.
2. `/run/export/TRUK` fuer England testen.
3. `/run/export/Spanien` testen.
4. `/run/export/Deutschland` testen, sobald Alphaplan-Pfad korrekt ist.
5. `/run/export/ZSCHWEIZ` testen.
6. Danach `/finance` und `docs/finance_status_2025.svg` aktualisieren.
## Nachtrag 2026-05-07 nach Mapper-/Finance-Aufraeumung ## Nachtrag 2026-05-07 nach Mapper-/Finance-Aufraeumung
Erledigt: Erledigt:
@@ -92,6 +92,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
CompanyCurrency = r.CompanyCurrency, CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020, Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee, SalesResponsibleEmployee = r.SalesResponsibleEmployee,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate, InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate, OrderDate = r.OrderDate,
Land = r.Land, Land = r.Land,
@@ -167,7 +168,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency, DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, PostingDate, InvoiceDate, OrderDate, Land, DocumentType
) )
VALUES ( VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice, $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
@@ -175,7 +176,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency, $documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType $vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $postingDate, $invoiceDate, $orderDate, $land, $documentType
); );
"""; """;
@@ -212,6 +213,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$companyCurrency", SqliteType.Text); command.Parameters.Add("$companyCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text); command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text); command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$postingDate", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text); command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text); command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text); command.Parameters.Add("$land", SqliteType.Text);
@@ -255,6 +257,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty; command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty; command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty; command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$postingDate"].Value = record.PostingDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty; command.Parameters["$land"].Value = record.Land ?? string.Empty;
@@ -413,6 +413,7 @@ public class ConfigTransferService : IConfigTransferService
CompanyCurrency = record.CompanyCurrency, CompanyCurrency = record.CompanyCurrency,
Incoterms2020 = record.Incoterms2020, Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee, SalesResponsibleEmployee = record.SalesResponsibleEmployee,
PostingDate = record.PostingDate,
InvoiceDate = record.InvoiceDate, InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate, OrderDate = record.OrderDate,
Land = record.Land, Land = record.Land,
@@ -9,4 +9,5 @@ public sealed class DataSourceFetchContext
public required ExportSettings Settings { get; init; } public required ExportSettings Settings { get; init; }
public SharePointConfig? SharePointConfig { get; init; } public SharePointConfig? SharePointConfig { get; init; }
public Action<string>? UpdateStatus { get; init; } public Action<string>? UpdateStatus { get; init; }
public int? PreferredImportYear { get; init; }
} }
@@ -31,7 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
string filePath; string filePath;
string? localOutputDirectory = null; string? localOutputDirectory = null;
string? sharePointUploadFolder = null; string? sharePointUploadFolder = null;
string? tempManualImportPath = null; var tempManualImportPaths = new List<string>();
try try
{ {
if (File.Exists(manualImportPath)) if (File.Exists(manualImportPath))
@@ -59,19 +59,28 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
siteId: site.Id, land: site.Land, details: manualImportPath); siteId: site.Id, land: site.Land, details: manualImportPath);
var sharePointFileReference = manualImportPath; var sharePointFileReference = manualImportPath;
var sharePointFileReferences = new List<string>();
if (LooksLikeSharePointFolderReference(manualImportPath)) if (LooksLikeSharePointFolderReference(manualImportPath))
{ {
var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync( var files = await _sharePointService.ResolveManualImportFilesInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, manualImportPath, site.TSC); spConfig.SiteUrl, manualImportPath, site.TSC, context.PreferredImportYear);
sharePointFileReference = latestFile.FileReference; sharePointFileReferences.AddRange(files.Select(file => file.FileReference));
sharePointFileReference = sharePointFileReferences.FirstOrDefault() ?? manualImportPath;
await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt", await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt",
siteId: site.Id, land: site.Land, details: sharePointFileReference); siteId: site.Id, land: site.Land, details: string.Join(" | ", sharePointFileReferences));
}
else
{
sharePointFileReferences.Add(sharePointFileReference);
} }
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( foreach (var fileReference in sharePointFileReferences)
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, {
spConfig.SiteUrl, sharePointFileReference); tempManualImportPaths.Add(await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, fileReference));
}
filePath = sharePointFileReference; filePath = sharePointFileReference;
sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl); sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl);
} }
@@ -81,12 +90,14 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
$"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); $"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}");
} }
var readPath = tempManualImportPath ?? filePath;
context.UpdateStatus?.Invoke("Manuelle Excel lesen..."); context.UpdateStatus?.Invoke("Manuelle Excel lesen...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen",
siteId: site.Id, land: site.Land, details: filePath); siteId: site.Id, land: site.Land, details: filePath);
var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); var records = new List<SalesRecord>();
var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath];
foreach (var readPath in readPaths)
records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site));
return new DataSourceFetchResult return new DataSourceFetchResult
{ {
Records = records, Records = records,
@@ -97,8 +108,11 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
} }
finally finally
{ {
if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) foreach (var tempManualImportPath in tempManualImportPaths)
File.Delete(tempManualImportPath); {
if (File.Exists(tempManualImportPath))
File.Delete(tempManualImportPath);
}
} }
} }
@@ -50,7 +50,7 @@ public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter
var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl);
var records = await _sapCompositionService.BuildSalesRecordsAsync( var records = await _sapCompositionService.BuildSalesRecordsAsync(
effectiveSite, sapSources, sapJoins, sapMappings, effectiveSite, sapSources, sapJoins, sapMappings,
credentials.Username, credentials.Password); credentials.Username, credentials.Password, context.PreferredImportYear);
return new DataSourceFetchResult { Records = records }; return new DataSourceFetchResult { Records = records };
} }
@@ -113,6 +113,7 @@ CREATE TABLE CentralSalesRecords (
CompanyCurrency TEXT NOT NULL DEFAULT '', CompanyCurrency TEXT NOT NULL DEFAULT '',
Incoterms2020 TEXT NOT NULL, Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL, SalesResponsibleEmployee TEXT NOT NULL,
PostingDate TEXT NULL,
InvoiceDate TEXT NULL, InvoiceDate TEXT NULL,
OrderDate TEXT NULL, OrderDate TEXT NULL,
Land TEXT NOT NULL, Land TEXT NOT NULL,
@@ -51,6 +51,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'"); AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "PostingDate", "TEXT NULL");
EnsureAppEventLogTable(db); EnsureAppEventLogTable(db);
} }
@@ -308,12 +308,105 @@ public class DatabaseSeedService : IDatabaseSeedService
} }
if (string.Equals(existing.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase) && if (string.Equals(existing.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(existing.ManualImportFilePath)) (string.IsNullOrWhiteSpace(existing.ManualImportFilePath) ||
existing.ManualImportFilePath.Contains("/England", StringComparison.OrdinalIgnoreCase)))
{ {
existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1"; existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1";
changed = true; changed = true;
} }
if (changed)
db.SaveChanges();
if (CanSeedSiteDependentTable(db, "ManualExcelColumnMappings"))
EnsureUkManualExcelMapping(db, existing.Id);
}
private static bool CanSeedSiteDependentTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName);
if (columns.Count == 0)
return false;
return !DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old") &&
!DatabaseSchemaTools.TableReferencesObsoleteTable(conn, tableName, "Sites");
}
private static void EnsureUkManualExcelMapping(AppDbContext db, int siteId)
{
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "TSC", false),
(nameof(SalesRecord.Land), "Land", false),
(nameof(SalesRecord.InvoiceNumber), "Invoice Number", true),
(nameof(SalesRecord.PositionOnInvoice), "Position on invoice", false),
(nameof(SalesRecord.Material), "Material", false),
(nameof(SalesRecord.Name), "Name", false),
(nameof(SalesRecord.ProductGroup), "Product Group", false),
(nameof(SalesRecord.Quantity), "Quantity", true),
(nameof(SalesRecord.CustomerNumber), "Customer number", false),
(nameof(SalesRecord.CustomerName), "Customer name", false),
(nameof(SalesRecord.CustomerCountry), "Customer country", false),
(nameof(SalesRecord.SalesPriceValue), "=[Sales Price/Value]*[Quantity]", true),
(nameof(SalesRecord.SalesCurrency), "=GBP", false),
(nameof(SalesRecord.DocumentCurrency), "=GBP", false),
(nameof(SalesRecord.CompanyCurrency), "=GBP", false),
(nameof(SalesRecord.PostingDate), "invoice date", false),
(nameof(SalesRecord.InvoiceDate), "invoice date", false),
(nameof(SalesRecord.DocumentType), "=Manual Excel", false)
};
var changed = false;
for (var i = 0; i < mappings.Length; i++)
{
var mapping = db.ManualExcelColumnMappings
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.TargetField == mappings[i].Target);
if (mapping is null)
{
db.ManualExcelColumnMappings.Add(new ManualExcelColumnMapping
{
SiteId = siteId,
TargetField = mappings[i].Target,
SourceHeader = mappings[i].Source,
IsRequired = mappings[i].Required,
IsActive = true,
SortOrder = i
});
changed = true;
continue;
}
if (mapping.SourceHeader != mappings[i].Source)
{
mapping.SourceHeader = mappings[i].Source;
changed = true;
}
if (mapping.IsRequired != mappings[i].Required)
{
mapping.IsRequired = mappings[i].Required;
changed = true;
}
if (!mapping.IsActive)
{
mapping.IsActive = true;
changed = true;
}
if (mapping.SortOrder != i)
{
mapping.SortOrder = i;
changed = true;
}
}
if (changed) if (changed)
db.SaveChanges(); db.SaveChanges();
} }
@@ -386,7 +479,7 @@ public class DatabaseSeedService : IDatabaseSeedService
{ {
SiteId = siteId, SiteId = siteId,
Alias = "Z", Alias = "Z",
EntitySet = "ZSCHWEIZSet", EntitySet = "FinanzdataSchweizOeSet",
IsPrimary = true, IsPrimary = true,
IsActive = true, IsActive = true,
SortOrder = 0 SortOrder = 0
@@ -395,9 +488,9 @@ public class DatabaseSeedService : IDatabaseSeedService
} }
else else
{ {
if (source.EntitySet != "ZSCHWEIZSet") if (source.EntitySet != "FinanzdataSchweizOeSet")
{ {
source.EntitySet = "ZSCHWEIZSet"; source.EntitySet = "FinanzdataSchweizOeSet";
changed = true; changed = true;
} }
@@ -420,33 +513,52 @@ public class DatabaseSeedService : IDatabaseSeedService
} }
} }
var obsoleteSources = db.SapSourceDefinitions
.Where(x => x.SiteId == siteId && x.Alias != "Z")
.ToList();
foreach (var obsoleteSource in obsoleteSources)
{
if (obsoleteSource.IsActive)
{
obsoleteSource.IsActive = false;
changed = true;
}
if (obsoleteSource.IsPrimary)
{
obsoleteSource.IsPrimary = false;
changed = true;
}
}
var mappings = new (string Target, string Source, bool Required)[] var mappings = new (string Target, string Source, bool Required)[]
{ {
(nameof(SalesRecord.Tsc), "Z.TSC", true), (nameof(SalesRecord.Tsc), "Z.Tsc", true),
(nameof(SalesRecord.Land), "Z.LAND1", true), (nameof(SalesRecord.Land), "Z.Land1", true),
(nameof(SalesRecord.DocumentEntry), "Z.VBELN", false), (nameof(SalesRecord.DocumentEntry), "Z.Vbeln", false),
(nameof(SalesRecord.InvoiceNumber), "Z.VBELN", true), (nameof(SalesRecord.InvoiceNumber), "Z.Vbeln", true),
(nameof(SalesRecord.PositionOnInvoice), "Z.POSNR", true), (nameof(SalesRecord.PositionOnInvoice), "Z.Posnr", true),
(nameof(SalesRecord.InvoiceDate), "Z.FKDAT", true), (nameof(SalesRecord.PostingDate), "Z.Fkdat", true),
(nameof(SalesRecord.Material), "Z.MATNR", false), (nameof(SalesRecord.InvoiceDate), "Z.Fkdat", true),
(nameof(SalesRecord.Name), "Z.ARKTX", false), (nameof(SalesRecord.Material), "Z.Matnr", false),
(nameof(SalesRecord.ProductGroup), "Z.PRODH", false), (nameof(SalesRecord.Name), "Z.Arktx", false),
(nameof(SalesRecord.Quantity), "Z.FKIMG", false), (nameof(SalesRecord.ProductGroup), "Z.Prodh", false),
(nameof(SalesRecord.CustomerNumber), "Z.KUNNR", false), (nameof(SalesRecord.Quantity), "Z.Fkimg", false),
(nameof(SalesRecord.CustomerName), "Z.NAME1", false), (nameof(SalesRecord.CustomerNumber), "Z.Kunnr", false),
(nameof(SalesRecord.CustomerCountry), "Z.CUSTOMER_LAND", false), (nameof(SalesRecord.CustomerName), "Z.Name1", false),
(nameof(SalesRecord.CustomerCountry), "Z.CustomerLand", false),
(nameof(SalesRecord.StandardCost), "=0", false), (nameof(SalesRecord.StandardCost), "=0", false),
(nameof(SalesRecord.StandardCostCurrency), "Z.HWAER", false), (nameof(SalesRecord.StandardCostCurrency), "Z.Hwaer", false),
(nameof(SalesRecord.SalesPriceValue), "Z.NETWR_HC", true), (nameof(SalesRecord.SalesPriceValue), "Z.NetwrHc", true),
(nameof(SalesRecord.SalesCurrency), "Z.HWAER", true), (nameof(SalesRecord.SalesCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentCurrency), "Z.WAERK", false), (nameof(SalesRecord.DocumentCurrency), "Z.Waerk", false),
(nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NETWR_DC", false), (nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NetwrDc", false),
(nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NETWR_HC", false), (nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NetwrHc", false),
(nameof(SalesRecord.VatSumForeignCurrency), "Z.TAX_DC", false), (nameof(SalesRecord.VatSumForeignCurrency), "=0", false),
(nameof(SalesRecord.VatSumLocalCurrency), "Z.TAX_HC", false), (nameof(SalesRecord.VatSumLocalCurrency), "=0", false),
(nameof(SalesRecord.DocumentRate), "Z.KURRF", false), (nameof(SalesRecord.DocumentRate), "Z.Kurrf", false),
(nameof(SalesRecord.CompanyCurrency), "Z.HWAER", true), (nameof(SalesRecord.CompanyCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentType), "Z.FKART", false) (nameof(SalesRecord.DocumentType), "Z.Fkart", false)
}; };
for (var i = 0; i < mappings.Length; i++) for (var i = 0; i < mappings.Length; i++)
@@ -70,6 +70,7 @@ public class ExcelExportService : IExcelExportService
"Company Currency", "Company Currency",
"Incoterms 2020", "Incoterms 2020",
"Sales responsible employee", "Sales responsible employee",
"posting date",
"invoice date", "invoice date",
"order date", "order date",
"Land", "Land",
@@ -115,10 +116,11 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 28).Value = record.CompanyCurrency; ws.Cell(row, 28).Value = record.CompanyCurrency;
ws.Cell(row, 29).Value = record.Incoterms2020; ws.Cell(row, 29).Value = record.Incoterms2020;
ws.Cell(row, 30).Value = record.SalesResponsibleEmployee; ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 31).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 31).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 32).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.Land; ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.DocumentType; ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
row++; row++;
} }
@@ -81,15 +81,15 @@ public class ExportOrchestrationService
return await RunConsolidatedExportAsync(); return await RunConsolidatedExportAsync();
} }
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId) public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId, int? preferredImportYear = null)
{ {
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId); var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return null; if (site is null) return null;
return await ExportSiteAsync(site); return await ExportSiteAsync(site, preferredImportYear);
} }
private async Task<SiteExportResult?> ExportSiteAsync(Site site) private async Task<SiteExportResult?> ExportSiteAsync(Site site, int? preferredImportYear = null)
{ {
SiteExportResult? result = null; SiteExportResult? result = null;
@@ -102,7 +102,7 @@ public class ExportOrchestrationService
try try
{ {
result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status)); result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status), preferredImportYear);
return result; return result;
} }
finally finally
@@ -34,13 +34,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
var centralRows = await db.CentralSalesRecords var centralRows = await db.CentralSalesRecords
.AsNoTracking() .AsNoTracking()
.Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year) .Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new NetSalesActualSourceRow( .Select(r => new NetSalesActualSourceRow(
r.Land, r.Land,
r.Tsc, r.Tsc,
r.DocumentEntry, r.DocumentEntry,
r.InvoiceNumber, r.InvoiceNumber,
r.DocumentType, r.DocumentType,
r.PostingDate,
r.InvoiceDate,
r.ExtractionDate,
r.CustomerNumber, r.CustomerNumber,
r.CustomerName, r.CustomerName,
r.SalesCurrency, r.SalesCurrency,
@@ -57,7 +60,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase) .GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules), g => BuildNetSalesActual(g.Key, g, budgetRatesToChf, intercompanyRules),
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
return financeReferences return financeReferences
@@ -73,7 +76,9 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
groupedActuals.TryGetValue(reference.Key, out var actual); groupedActuals.TryGetValue(reference.Key, out var actual);
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue; var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
var selected = actual?.Candidates var selected = actual?.Candidates
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency") .OrderByDescending(candidate => candidate.IsPreferred)
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyPosition")
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyDocument")
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue") .ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
.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;
@@ -106,6 +111,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
Value = candidate.Value, Value = candidate.Value,
IntercompanyValue = candidate.IntercompanyValue, IntercompanyValue = candidate.IntercompanyValue,
ValueExcludingIntercompany = candidate.ValueExcludingIntercompany, ValueExcludingIntercompany = candidate.ValueExcludingIntercompany,
IsPreferred = candidate.IsPreferred,
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null, Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
DifferenceExcludingIntercompany = referenceValue.HasValue DifferenceExcludingIntercompany = referenceValue.HasValue
? candidate.ValueExcludingIntercompany - referenceValue.Value ? candidate.ValueExcludingIntercompany - referenceValue.Value
@@ -139,24 +145,30 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
} }
private static NetSalesActual BuildNetSalesActual( private static NetSalesActual BuildNetSalesActual(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows, IEnumerable<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf, IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules) IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
{ {
var rowList = rows.ToList(); var rowList = rows.ToList();
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
var documentRows = rowList var documentRows = rowList
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase) .GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Select(g => g.First()) .Select(g => g.First())
.ToList(); .ToList();
var repeatedDocumentTotals = LooksLikeRepeatedDocumentTotals(rowList);
var salesPriceValue = rowList.Sum(row => row.SalesPriceValue);
var salesPriceIntercompanyValue = rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue);
var candidates = new List<NetSalesCandidate> var candidates = new List<NetSalesCandidate>
{ {
new( new(
"SalesPriceValue", "SalesPriceValue",
"Sales Price/Value", "Positions-Netto (Sales Price/Value)",
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)), houseCurrency,
rowList.Sum(row => row.SalesPriceValue), salesPriceValue,
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue)) salesPriceIntercompanyValue,
repeatedDocumentTotals && salesPriceValue != 0m)
}; };
var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency); var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency);
@@ -166,46 +178,100 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
"DocTotalFC - VatSumFC", "DocTotalFC - VatSumFC",
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)), ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
netDocumentForeignCurrency, netDocumentForeignCurrency,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency))); documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency),
false));
var positionNetDocumentLocalCurrency = rowList.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (positionNetDocumentLocalCurrency != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyPosition",
"Nettofakturawert Hauswaehrung pro Position",
houseCurrency,
positionNetDocumentLocalCurrency,
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
!repeatedDocumentTotals));
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency); var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (netDocumentLocalCurrency != 0m) if (netDocumentLocalCurrency != 0m)
candidates.Add(new( candidates.Add(new(
"NetDocumentLocalCurrency", "NetDocumentLocalCurrencyDocument",
"Nettofakturawert Hauswaehrung", "Nettofakturawert Hauswaehrung pro Beleg dedupliziert",
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)), houseCurrency,
netDocumentLocalCurrency, netDocumentLocalCurrency,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))); documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
repeatedDocumentTotals && salesPriceValue == 0m));
var selectedNetRows = repeatedDocumentTotals ? documentRows : rowList;
var budgetChf = selectedNetRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
if (budgetChf != 0m) if (budgetChf != 0m)
candidates.Add(new( candidates.Add(new(
"NetDocumentLocalCurrencyBudgetChf", "NetDocumentLocalCurrencyBudgetChf",
"Nettofakturawert Hauswaehrung -> CHF Budget 2025", $"Nettofakturawert Hauswaehrung -> CHF Budget 2025 ({(repeatedDocumentTotals ? "Beleg" : "Position")})",
"CHF", "CHF",
budgetChf, budgetChf,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)))); selectedNetRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)),
false));
return new NetSalesActual return new NetSalesActual
{ {
RowCount = rowList.Count, RowCount = rowList.Count,
Currencies = string.Join(", ", rowList.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency) Currencies = houseCurrency,
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
Candidates = candidates Candidates = candidates
}; };
} }
private static bool LooksLikeRepeatedDocumentTotals(IReadOnlyList<NetSalesActualSourceRow> rows)
{
var multiLineGroups = rows
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.ToList();
if (multiLineGroups.Count == 0)
return false;
var repeatedGroups = multiLineGroups.Count(group =>
group.Select(row => Math.Round(row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, 2))
.Distinct()
.Count() == 1);
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
}
private static decimal ConvertHouseCurrencyNetToBudgetChf( private static decimal ConvertHouseCurrencyNetToBudgetChf(
string houseCurrency,
NetSalesActualSourceRow row, NetSalesActualSourceRow row,
decimal value, decimal value,
IReadOnlyDictionary<string, decimal> budgetRatesToChf) IReadOnlyDictionary<string, decimal> budgetRatesToChf)
{ {
var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant(); var currency = !string.IsNullOrWhiteSpace(houseCurrency) && houseCurrency != "-"
? houseCurrency.Trim().ToUpperInvariant()
: (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m; return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
} }
private static string ResolveHouseCurrency(string referenceKey, IReadOnlyList<NetSalesActualSourceRow> rows)
{
var configured = referenceKey.ToUpperInvariant() switch
{
"CH" => "CHF",
"AT" => "EUR",
"DE" => "EUR",
"ES" => "EUR",
"FR" => "EUR",
"IN" => "INR",
"IT" => "EUR",
"UK" => "GBP",
"US" => "USD",
_ => string.Empty
};
return string.IsNullOrWhiteSpace(configured)
? ResolveCurrencyLabel(rows.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency))
: configured;
}
private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules) private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules)
{ {
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty; var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
@@ -315,6 +381,7 @@ public sealed class NetSalesCandidateRow
public decimal Value { get; set; } public decimal Value { get; set; }
public decimal IntercompanyValue { get; set; } public decimal IntercompanyValue { get; set; }
public decimal ValueExcludingIntercompany { get; set; } public decimal ValueExcludingIntercompany { get; set; }
public bool IsPreferred { get; set; }
public decimal? Difference { get; set; } public decimal? Difference { get; set; }
public decimal? DifferenceExcludingIntercompany { get; set; } public decimal? DifferenceExcludingIntercompany { get; set; }
} }
@@ -332,6 +399,9 @@ internal sealed record NetSalesActualSourceRow(
int DocumentEntry, int DocumentEntry,
string InvoiceNumber, string InvoiceNumber,
string DocumentType, string DocumentType,
DateTime? PostingDate,
DateTime? InvoiceDate,
DateTime ExtractionDate,
string CustomerNumber, string CustomerNumber,
string CustomerName, string CustomerName,
string SalesCurrency, string SalesCurrency,
@@ -343,7 +413,7 @@ internal sealed record NetSalesActualSourceRow(
decimal VatSumForeignCurrency, decimal VatSumForeignCurrency,
decimal VatSumLocalCurrency); decimal VatSumLocalCurrency);
internal sealed record NetSalesCandidate(string Key, string Label, string Currency, decimal Value, decimal IntercompanyValue) internal sealed record NetSalesCandidate(string Key, string Label, string Currency, decimal Value, decimal IntercompanyValue, bool IsPreferred)
{ {
public decimal ValueExcludingIntercompany => Value - IntercompanyValue; public decimal ValueExcludingIntercompany => Value - IntercompanyValue;
} }
@@ -267,6 +267,7 @@ public class HanaQueryService : IHanaQueryService
DocumentEntry = Convert.ToInt32(reader["document_entry"]), DocumentEntry = Convert.ToInt32(reader["document_entry"]),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
PostingDate = reader.IsDBNull(reader.GetOrdinal("posting_date")) ? null : reader.GetDateTime(reader.GetOrdinal("posting_date")),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty, Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty, Name = reader["material_name"]?.ToString() ?? string.Empty,
@@ -373,7 +374,8 @@ SELECT
h.""DocEntry"" AS document_entry, h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material, p.""ItemCode"" AS material,
p.""Dscription"" AS material_name, p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group, COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -391,7 +393,7 @@ SELECT
THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number, ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value, p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency, COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) AS document_total_fc, COALESCE(h.""DocTotalFC"", 0) AS document_total_fc,
COALESCE(h.""DocTotal"", 0) AS document_total_lc, COALESCE(h.""DocTotal"", 0) AS document_total_lc,
@@ -434,7 +436,8 @@ SELECT
h.""DocEntry"" AS document_entry, h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number, h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position, p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date, h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material, p.""ItemCode"" AS material,
p.""Dscription"" AS material_name, p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group, COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -450,7 +453,7 @@ SELECT
COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
'' AS purchase_order_number, '' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value, p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency, COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc, COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc,
COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc, COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc,
@@ -11,5 +11,6 @@ public interface ISapCompositionService
IReadOnlyList<SapFieldMapping> mappings, IReadOnlyList<SapFieldMapping> mappings,
string username, string username,
string password, string password,
int? preferredYear = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
@@ -5,5 +5,5 @@ public interface ISapGatewayService
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default);
} }
@@ -4,7 +4,8 @@ public interface ISharePointUploadService
{ {
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference); Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc); Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl); Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
} }
@@ -4,5 +4,5 @@ namespace TrafagSalesExporter.Services;
public interface ISiteExportService public interface ISiteExportService
{ {
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null); Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null);
} }
@@ -50,7 +50,11 @@ public class ManualExcelImportService : IManualExcelImportService
["companycurrency"] = nameof(SalesRecord.CompanyCurrency), ["companycurrency"] = nameof(SalesRecord.CompanyCurrency),
["incoterms2020"] = nameof(SalesRecord.Incoterms2020), ["incoterms2020"] = nameof(SalesRecord.Incoterms2020),
["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee), ["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee),
["postingdate"] = nameof(SalesRecord.PostingDate),
["buchungsdatum"] = nameof(SalesRecord.PostingDate),
["lineregistrationdate"] = nameof(SalesRecord.PostingDate),
["invoicedate"] = nameof(SalesRecord.InvoiceDate), ["invoicedate"] = nameof(SalesRecord.InvoiceDate),
["fakturadatum"] = nameof(SalesRecord.InvoiceDate),
["orderdate"] = nameof(SalesRecord.OrderDate), ["orderdate"] = nameof(SalesRecord.OrderDate),
["land"] = nameof(SalesRecord.Land), ["land"] = nameof(SalesRecord.Land),
["documenttype"] = nameof(SalesRecord.DocumentType) ["documenttype"] = nameof(SalesRecord.DocumentType)
@@ -180,6 +184,7 @@ public class ManualExcelImportService : IManualExcelImportService
CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)), CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)),
Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)), Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)), SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)),
PostingDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.PostingDate)),
InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)), InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)),
OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)), OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)),
Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land), Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land),
@@ -290,6 +295,7 @@ public class ManualExcelImportService : IManualExcelImportService
CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)), CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)),
Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)), Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)), SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)),
PostingDate = ReadDate(headerIndexes, row, nameof(SalesRecord.PostingDate)),
InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)), InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)),
OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)), OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)),
Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land), Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land),
@@ -442,7 +448,9 @@ public class ManualExcelImportService : IManualExcelImportService
{ {
var trimmed = sourceHeader.Trim(); var trimmed = sourceHeader.Trim();
if (trimmed.StartsWith('=')) if (trimmed.StartsWith('='))
return trimmed[1..]; return EvaluateMappedExpression(trimmed[1..], headerIndexes, header => TryResolveHeaderIndex(headerIndexes, header, out var index)
? row.Cell(index).GetFormattedString().Trim()
: null);
return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) return TryResolveHeaderIndex(headerIndexes, trimmed, out var index)
? row.Cell(index).GetFormattedString().Trim() ? row.Cell(index).GetFormattedString().Trim()
@@ -453,13 +461,41 @@ public class ManualExcelImportService : IManualExcelImportService
{ {
var trimmed = sourceHeader.Trim(); var trimmed = sourceHeader.Trim();
if (trimmed.StartsWith('=')) if (trimmed.StartsWith('='))
return trimmed[1..]; return EvaluateMappedExpression(trimmed[1..], headerIndexes, header => TryResolveHeaderIndex(headerIndexes, header, out var index) && index < fields.Length
? fields[index].Trim()
: null);
return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) && index < fields.Length return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) && index < fields.Length
? fields[index].Trim() ? fields[index].Trim()
: null; : null;
} }
private static object? EvaluateMappedExpression(string expression, Dictionary<string, int> headerIndexes, Func<string, string?> readHeader)
{
if (!expression.Contains('[') || !expression.Contains(']'))
return expression;
var parts = expression.Split('*', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
return expression;
var left = ResolveExpressionOperand(parts[0], headerIndexes, readHeader);
var right = ResolveExpressionOperand(parts[1], headerIndexes, readHeader);
return left * right;
}
private static decimal ResolveExpressionOperand(string operand, Dictionary<string, int> headerIndexes, Func<string, string?> readHeader)
{
var trimmed = operand.Trim();
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
{
var header = trimmed[1..^1].Trim();
return ParseDecimal(readHeader(header) ?? string.Empty);
}
return ParseDecimal(trimmed);
}
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()));
@@ -25,6 +25,7 @@ public class SapCompositionService : ISapCompositionService
IReadOnlyList<SapFieldMapping> mappings, IReadOnlyList<SapFieldMapping> mappings,
string username, string username,
string password, string password,
int? preferredYear = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(site.SapServiceUrl)) if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
@@ -44,7 +45,8 @@ public class SapCompositionService : ISapCompositionService
{ {
await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land, await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet}"); $"Alias={source.Alias} | EntitySet={source.EntitySet}");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken); var filter = BuildODataYearFilter(source.EntitySet, preferredYear);
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, filter, cancellationToken);
sourceRows[source.Alias] = rows; sourceRows[source.Alias] = rows;
await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land, await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}"); $"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
@@ -57,4 +59,14 @@ public class SapCompositionService : ISapCompositionService
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}"); $"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
return result; return result;
} }
private static string? BuildODataYearFilter(string entitySet, int? preferredYear)
{
if (preferredYear is null)
return null;
return string.Equals(entitySet, "FinanzdataSchweizOeSet", StringComparison.OrdinalIgnoreCase)
? $"Gjahr eq '{preferredYear.Value}'"
: null;
}
} }
@@ -87,10 +87,13 @@ public class SapGatewayService : ISapGatewayService
.ToList(); .ToList();
} }
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default) public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default)
{ {
using var client = CreateClient(username, password); using var client = CreateClient(username, password);
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json"; var query = string.IsNullOrWhiteSpace(filter)
? "$format=json"
: $"$format=json&$filter={Uri.EscapeDataString(filter)}";
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?{query}";
await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl); await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl);
using var response = await client.GetAsync(requestUrl, cancellationToken); using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -91,7 +91,22 @@ public class SharePointUploadService : ISharePointUploadService
string clientSecret, string clientSecret,
string siteUrl, string siteUrl,
string folderReference, string folderReference,
string siteTsc) string siteTsc,
int? preferredYear = null)
{
var files = await ResolveManualImportFilesInFolderAsync(
tenantId, clientId, clientSecret, siteUrl, folderReference, siteTsc, preferredYear);
return files.First();
}
public async Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(
string tenantId,
string clientId,
string clientSecret,
string siteUrl,
string folderReference,
string siteTsc,
int? preferredYear = null)
{ {
var normalizedTenantId = Normalize(tenantId); var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId); var normalizedClientId = Normalize(clientId);
@@ -119,18 +134,56 @@ public class SharePointUploadService : ISharePointUploadService
var folderPath = ResolveRemotePath(normalizedReference, siteUri); var folderPath = ResolveRemotePath(normalizedReference, siteUri);
var children = await graphClient.Drives[drive.Id].Root.ItemWithPath(folderPath).Children.GetAsync(); var children = await graphClient.Drives[drive.Id].Root.ItemWithPath(folderPath).Children.GetAsync();
var candidates = children?.Value? var allCandidates = children?.Value?
.Where(item => item.File is not null) .Where(item => item.File is not null)
.Where(item => IsSupportedManualImportFile(item.Name)) .Where(item => IsSupportedManualImportFile(item.Name))
.Where(item => MatchesTsc(item.Name, normalizedTsc)) .Where(item => MatchesTsc(item.Name, normalizedTsc))
.Select(item => new .Select(item => new
{ {
Item = item, Item = item,
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null,
AnnualYear = TryParseAnnualSiteFileName(item.Name, normalizedTsc, out var annualYear) ? annualYear : (int?)null,
SnapshotDate = TryParseSnapshotDate(item.Name, out var snapshotDate) ? snapshotDate : (DateTime?)null
}) })
.ToList() ?? [];
if (preferredYear is not null)
{
var annual = allCandidates
.Where(x => x.AnnualYear == preferredYear.Value)
.OrderByDescending(x => x.SnapshotDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.FirstOrDefault()
?? throw new InvalidOperationException(
$"Im SharePoint-Ordner '{folderPath}' wurde keine Jahresdatei fuer '{normalizedTsc}' und Jahr {preferredYear.Value} gefunden.");
var references = new List<SharePointFileReference>
{
new(string.Join("/", folderPath.Trim('/'), annual.Item.Name).Trim('/'), annual.Item.LastModifiedDateTime)
};
if (preferredYear.Value >= DateTime.Today.Year)
{
var baseDate = annual.SnapshotDate
?? annual.Item.LastModifiedDateTime?.UtcDateTime.Date
?? new DateTime(preferredYear.Value, 1, 1);
references.AddRange(allCandidates
.Where(x => x.FileDate is not null)
.Where(x => x.FileDate!.Value.Year == preferredYear.Value)
.Where(x => x.FileDate!.Value.Date > baseDate.Date)
.OrderBy(x => x.FileDate)
.Select(x => new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), x.Item.Name).Trim('/'),
x.Item.LastModifiedDateTime)));
}
return references;
}
var candidates = allCandidates
.OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue) .OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue) .ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ToList() ?? []; .ToList();
var selected = candidates.FirstOrDefault() var selected = candidates.FirstOrDefault()
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@@ -138,9 +191,12 @@ public class SharePointUploadService : ISharePointUploadService
? $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei gefunden." ? $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei gefunden."
: $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei fuer '{normalizedTsc}' gefunden."); : $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei fuer '{normalizedTsc}' gefunden.");
return new SharePointFileReference( return
string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'), [
selected.Item.LastModifiedDateTime); new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'),
selected.Item.LastModifiedDateTime)
];
} }
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
@@ -217,7 +273,8 @@ public class SharePointUploadService : ISharePointUploadService
return true; return true;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty); var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase); return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase) ||
Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase);
} }
private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate) private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate)
@@ -239,6 +296,33 @@ public class SharePointUploadService : ISharePointUploadService
out fileDate); out fileDate);
} }
private static bool TryParseAnnualSiteFileName(string? fileName, string normalizedTsc, out int year)
{
year = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
if (!Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase))
return false;
if (TryParseDatedSiteFileName(fileName, normalizedTsc, out _))
return false;
var match = Regex.Match(nameWithoutExtension, @"(?<!\d)(20\d{2})(?!\d)");
return match.Success && int.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out year);
}
private static bool TryParseSnapshotDate(string? fileName, out DateTime snapshotDate)
{
snapshotDate = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
var match = Regex.Match(nameWithoutExtension, @"(?<!\d)(?<date>20\d{2}[-_.]\d{2}[-_.]\d{2})(?!\d)");
return match.Success &&
DateTime.TryParseExact(
match.Groups["date"].Value.Replace('_', '-').Replace('.', '-'),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out snapshotDate);
}
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl) private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{ {
var maskedSecret = string.IsNullOrEmpty(clientSecret) var maskedSecret = string.IsNullOrEmpty(clientSecret)
@@ -37,7 +37,7 @@ public class SiteExportService : ISiteExportService
_logger = logger; _logger = logger;
} }
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null) public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var log = new ExportLog var log = new ExportLog
@@ -63,7 +63,8 @@ public class SiteExportService : ISiteExportService
SourceDefinition = sourceDefinition, SourceDefinition = sourceDefinition,
Settings = settings, Settings = settings,
SharePointConfig = spConfig, SharePointConfig = spConfig,
UpdateStatus = updateStatus UpdateStatus = updateStatus,
PreferredImportYear = preferredImportYear
}); });
var records = fetchResult.Records; var records = fetchResult.Records;
@@ -9,4 +9,11 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\TrafagSalesExporter.csproj" /> <ProjectReference Include="..\..\TrafagSalesExporter.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="verify_probe_out*\**\*.cs" />
<Content Remove="verify_probe_out*\**\*" />
<EmbeddedResource Remove="verify_probe_out*\**\*" />
<None Remove="verify_probe_out*\**\*" />
</ItemGroup>
</Project> </Project>
@@ -4,14 +4,52 @@ using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.VisualBasic.FileIO; using Microsoft.VisualBasic.FileIO;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services; using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]); var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]);
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite($"Data Source={databasePath};Default Timeout=60")); options.UseSqlite($"Data Source={databasePath};Default Timeout=60"));
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, PrefixTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, NormalizeCurrencyCodeTransformationStrategy>();
builder.Services.AddSingleton<ICurrencyExchangeRateService, CurrencyExchangeRateService>();
builder.Services.AddSingleton<IExchangeRateImportService, ExchangeRateImportService>();
builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>();
builder.Services.AddSingleton<IRecordTransformationStrategy, ConvertCurrencyRecordTransformationStrategy>();
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>(); builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, SapGatewayDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapter, ManualExcelDataSourceAdapter>();
builder.Services.AddSingleton<IDataSourceAdapterResolver, DataSourceAdapterResolver>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
var app = builder.Build(); var app = builder.Build();
@@ -25,9 +63,157 @@ app.MapGet("/finance", async (IFinanceReconciliationService finance, IDbContextF
var coverage = await LoadSiteCoverageAsync(dbFactory, 2025); var coverage = await LoadSiteCoverageAsync(dbFactory, 2025);
return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample, coverage), "text/html; charset=utf-8"); return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample, coverage), "text/html; charset=utf-8");
}); });
app.MapGet("/run/export-all", async (ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
{
var startedAt = DateTime.Now;
await exports.ExportAllAsync();
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, "Alle aktiven Standorte exportiert und zentrale Datei erzeugt.");
return Results.Content(summary, "text/html; charset=utf-8");
});
app.MapGet("/run/consolidated", async (ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
{
var startedAt = DateTime.Now;
var path = await exports.ExportConsolidatedOnlyAsync();
var message = string.IsNullOrWhiteSpace(path)
? "Zentrale Datei wurde nicht erzeugt; vermutlich keine CentralSalesRecords vorhanden oder Export lief bereits."
: $"Zentrale Datei erzeugt: {path}";
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message);
return Results.Content(summary, "text/html; charset=utf-8");
});
app.MapGet("/run/export/{siteKey}", async (string siteKey, int? year, ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
{
var startedAt = DateTime.Now;
var site = await ResolveSiteAsync(dbFactory, siteKey);
if (site is null)
return Results.NotFound($"Standort nicht gefunden: {siteKey}");
var importYear = year ?? 2025;
var result = await exports.ExportSiteByIdAsync(site.Id, importYear);
var message = result is null
? $"Export wurde nicht gestartet: {site.Land} / {site.TSC}"
: $"Export {result.Log.Status}: {site.Land} / {site.TSC}, Jahr={importYear}, Zeilen={result.Log.RowCount}, Datei={result.FilePath ?? "-"}, Fehler={result.Log.ErrorMessage}";
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message);
return Results.Content(summary, "text/html; charset=utf-8");
});
app.Run(); app.Run();
static async Task<Site?> ResolveSiteAsync(IDbContextFactory<AppDbContext> dbFactory, string siteKey)
{
await using var db = await dbFactory.CreateDbContextAsync();
var normalized = siteKey.Trim();
if (int.TryParse(normalized, out var siteId))
return await db.Sites.AsNoTracking().FirstOrDefaultAsync(s => s.Id == siteId);
var sites = await db.Sites
.AsNoTracking()
.OrderBy(s => s.Id)
.ToListAsync();
return sites.FirstOrDefault(s =>
s.TSC.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
s.Land.Equals(normalized, StringComparison.OrdinalIgnoreCase));
}
static async Task<string> BuildRunSummaryAsync(
IFinanceReconciliationService finance,
IDbContextFactory<AppDbContext> dbFactory,
DateTime startedAt,
string message)
{
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
var coverage = await LoadSiteCoverageAsync(dbFactory, 2025);
var recentLogs = await LoadRecentExportLogsAsync(dbFactory, startedAt);
var okCount = rows.Count(r => r.Status == "OK");
var checkCount = rows.Count(r => r.Status == "Pruefen");
var missingCount = rows.Count(r => r.Status == "Keine Daten");
var financeRows = string.Join(Environment.NewLine, rows.Select(row => $$"""
<tr>
<td>{{Html(row.Status)}}</td>
<td>{{Html(row.Key)}}</td>
<td>{{Html(row.Label)}}</td>
<td class="num">{{Amount(row.ActualValue)}}</td>
<td class="num">{{Amount(row.ReferenceValue)}}</td>
<td class="num">{{Amount(row.Difference)}}</td>
<td>{{Html(row.ValueField)}}</td>
<td class="num">{{row.RowCount}}</td>
</tr>
"""));
var coverageRows = string.Join(Environment.NewLine, coverage.Select(row => $$"""
<tr>
<td>{{Html(row.Land)}}<div class="small">{{Html(row.Tsc)}}</div></td>
<td>{{Html(row.SourceSystem)}}</td>
<td class="num">{{row.RowCount}}</td>
<td class="num">{{Amount(row.SalesPriceValue)}}</td>
<td>{{Html(row.Currencies)}}</td>
<td>{{Html(row.LastExportStatus)}}</td>
<td class="wrap">{{Html(row.LastExportError)}}</td>
</tr>
"""));
var logRows = string.Join(Environment.NewLine, recentLogs.Select(log => $$"""
<tr>
<td>{{Html(log.Timestamp.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}}</td>
<td>{{Html(log.Land)}}</td>
<td>{{Html(log.TSC)}}</td>
<td>{{Html(log.Status)}}</td>
<td class="num">{{log.RowCount}}</td>
<td>{{Html(log.FileName)}}</td>
<td class="wrap">{{Html(log.ErrorMessage)}}</td>
</tr>
"""));
return $$"""
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>FinanceProbe Run Summary</title>
<style>
body { font-family: "Segoe UI", Arial, sans-serif; margin: 24px; background:#f6f7f9; color:#172033; }
.panel { background:#fff; border:1px solid #d8dee8; border-radius:6px; padding:14px; margin-bottom:14px; }
table { width:100%; border-collapse:collapse; background:#fff; border:1px solid #d8dee8; margin-top:8px; }
th { background:#22324a; color:#fff; text-align:left; padding:7px 9px; }
td { border-top:1px solid #d8dee8; padding:6px 9px; vertical-align:top; }
.num { text-align:right; font-variant-numeric:tabular-nums; }
.small { color:#667085; font-size:12px; }
.wrap { max-width:520px; }
a { color:#1f4f7a; }
</style>
</head>
<body>
<section class="panel">
<h1>FinanceProbe Run Summary</h1>
<p>{{Html(message)}}</p>
<p class="small">Start: {{Html(startedAt.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} | Ergebnis: OK={{okCount}}, Pruefen={{checkCount}}, Keine Daten={{missingCount}}</p>
<p><a href="/finance">Zur Finance-Auswertung</a> | <a href="/run/consolidated">Zentrale Datei erzeugen</a> | <a href="/run/export-all">Alle exportieren</a></p>
</section>
<section class="panel">
<h2>Neue Exportlogs seit Start</h2>
<table><thead><tr><th>Zeit</th><th>Land</th><th>TSC</th><th>Status</th><th class="num">Zeilen</th><th>Datei</th><th>Fehler</th></tr></thead><tbody>{{logRows}}</tbody></table>
</section>
<section class="panel">
<h2>Finance-Abgleich</h2>
<table><thead><tr><th>Status</th><th>Key</th><th>Label</th><th class="num">Ist</th><th class="num">Soll</th><th class="num">Diff</th><th>Feld</th><th class="num">Zeilen</th></tr></thead><tbody>{{financeRows}}</tbody></table>
</section>
<section class="panel">
<h2>Datenabdeckung</h2>
<table><thead><tr><th>Standort</th><th>System</th><th class="num">Zeilen</th><th class="num">Sales</th><th>Waehrung</th><th>Letzter Status</th><th>Fehler</th></tr></thead><tbody>{{coverageRows}}</tbody></table>
</section>
</body>
</html>
""";
}
static async Task<List<ExportLog>> LoadRecentExportLogsAsync(IDbContextFactory<AppDbContext> dbFactory, DateTime startedAt)
{
await using var db = await dbFactory.CreateDbContextAsync();
return await db.ExportLogs
.AsNoTracking()
.Where(log => log.Timestamp >= startedAt.AddSeconds(-2))
.OrderByDescending(log => log.Id)
.Take(40)
.ToListAsync();
}
static string ResolveDatabasePath(string? configuredPath) static string ResolveDatabasePath(string? configuredPath)
{ {
if (!string.IsNullOrWhiteSpace(configuredPath)) if (!string.IsNullOrWhiteSpace(configuredPath))
@@ -238,12 +424,12 @@ static async Task<List<SiteCoverageRow>> LoadSiteCoverageAsync(IDbContextFactory
.ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase); .ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase);
var centralBaseRows = await db.CentralSalesRecords var centralBaseRows = await db.CentralSalesRecords
.AsNoTracking() .AsNoTracking()
.Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year) .Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new .Select(r => new
{ {
r.SiteId, r.SiteId,
r.SalesPriceValue, r.SalesPriceValue,
Date = r.InvoiceDate ?? r.ExtractionDate, Date = r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate,
Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency
}) })
.ToListAsync(); .ToListAsync();
@@ -74,6 +74,8 @@ public class CentralSalesRecordServiceTests : IDisposable
VatSumLocalCurrency = 7.6m, VatSumLocalCurrency = 7.6m,
DocumentRate = 0.95m, DocumentRate = 0.95m,
CompanyCurrency = "CHF", CompanyCurrency = "CHF",
PostingDate = new DateTime(2026, 4, 28),
InvoiceDate = new DateTime(2026, 4, 29),
Land = "Schweiz", Land = "Schweiz",
DocumentType = "INV" DocumentType = "INV"
} }
@@ -90,6 +92,8 @@ public class CentralSalesRecordServiceTests : IDisposable
Assert.Equal(7.6m, row.VatSumLocalCurrency); Assert.Equal(7.6m, row.VatSumLocalCurrency);
Assert.Equal(0.95m, row.DocumentRate); Assert.Equal(0.95m, row.DocumentRate);
Assert.Equal("CHF", row.CompanyCurrency); Assert.Equal("CHF", row.CompanyCurrency);
Assert.Equal(new DateTime(2026, 4, 28), row.PostingDate);
Assert.Equal(new DateTime(2026, 4, 29), row.InvoiceDate);
} }
private sealed class NullAppEventLogService : IAppEventLogService private sealed class NullAppEventLogService : IAppEventLogService
@@ -0,0 +1,164 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Tests;
public class FinanceReconciliationServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TestDbContextFactory _dbFactory;
public FinanceReconciliationServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
using var db = new AppDbContext(options);
db.Database.EnsureCreated();
_dbFactory = new TestDbContextFactory(options);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public async Task BuildNetSalesReferenceRowsAsync_Uses_PostingDate_For_Year_Filter()
{
await using (var db = await _dbFactory.CreateDbContextAsync())
{
db.Sites.Add(BuildSite());
db.FinanceReferences.Add(new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, CheckValue = 100m, IsActive = true });
db.CentralSalesRecords.AddRange(
BuildCentralRecord("TRDE", "Deutschland", 1, 1, 100m, new DateTime(2025, 1, 5), new DateTime(2024, 12, 31)),
BuildCentralRecord("TRDE", "Deutschland", 2, 1, 999m, new DateTime(2024, 12, 31), new DateTime(2025, 1, 5)));
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("OK", row.Status);
Assert.Equal("Nettofakturawert Hauswaehrung pro Position", row.ValueField);
}
[Fact]
public async Task BuildNetSalesReferenceRowsAsync_Does_Not_Multiply_Repeated_Document_Header_Totals()
{
await using (var db = await _dbFactory.CreateDbContextAsync())
{
db.Sites.Add(BuildSite());
db.FinanceReferences.Add(new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, CheckValue = 90m, IsActive = true });
db.CentralSalesRecords.AddRange(
BuildCentralRecord("TRIT", "Italien", 10, 1, 100m, new DateTime(2025, 2, 1), new DateTime(2025, 2, 1), vatLocal: 10m, salesPriceValue: 40m),
BuildCentralRecord("TRIT", "Italien", 10, 2, 100m, new DateTime(2025, 2, 1), new DateTime(2025, 2, 1), vatLocal: 10m, salesPriceValue: 50m));
await db.SaveChangesAsync();
}
var service = new FinanceReconciliationService(_dbFactory);
var rows = await service.BuildNetSalesReferenceRowsAsync(2025);
var row = Assert.Single(rows);
Assert.Equal(90m, row.ActualValue);
Assert.Equal("Positions-Netto (Sales Price/Value)", row.ValueField);
Assert.Contains(row.Candidates, c => c.Key == "NetDocumentLocalCurrencyPosition" && c.Value == 180m && !c.IsPreferred);
Assert.Contains(row.Candidates, c => c.Key == "NetDocumentLocalCurrencyDocument" && c.Value == 90m && !c.IsPreferred);
Assert.Contains(row.Candidates, c => c.Key == "SalesPriceValue" && c.Value == 90m && c.IsPreferred);
}
[Fact]
public async Task BuildNetSalesReferenceRowsAsync_Reports_India_As_Inr_House_Currency()
{
await using (var db = await _dbFactory.CreateDbContextAsync())
{
db.Sites.Add(BuildSite());
db.FinanceReferences.Add(new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, CheckValue = 300m, IsActive = true });
db.CentralSalesRecords.AddRange(
BuildCentralRecord("TRIN", "Indien", 20, 1, 0m, new DateTime(2025, 3, 1), new DateTime(2025, 3, 1), salesPriceValue: 100m, salesCurrency: "USD"),
BuildCentralRecord("TRIN", "Indien", 21, 1, 0m, new DateTime(2025, 3, 2), new DateTime(2025, 3, 2), salesPriceValue: 200m, salesCurrency: "EUR"));
await db.SaveChangesAsync();
}
var service = new FinanceReconciliationService(_dbFactory);
var rows = await service.BuildNetSalesReferenceRowsAsync(2025);
var row = Assert.Single(rows);
Assert.Equal(300m, row.ActualValue);
Assert.Equal("INR", row.ActualCurrency);
Assert.Equal("INR", row.Currencies);
Assert.All(row.Candidates, candidate => Assert.NotEqual("EUR, USD", candidate.Currency));
}
private static CentralSalesRecord BuildCentralRecord(
string tsc,
string land,
int documentEntry,
int position,
decimal documentTotalLocal,
DateTime postingDate,
DateTime invoiceDate,
decimal vatLocal = 0m,
decimal? salesPriceValue = null,
string salesCurrency = "EUR")
=> new()
{
StoredAtUtc = DateTime.UtcNow,
SiteId = 1,
SourceSystem = "TEST",
ExtractionDate = DateTime.UtcNow,
Tsc = tsc,
DocumentEntry = documentEntry,
InvoiceNumber = documentEntry.ToString(),
PositionOnInvoice = position,
SalesPriceValue = salesPriceValue ?? documentTotalLocal - vatLocal,
SalesCurrency = salesCurrency,
DocumentCurrency = salesCurrency,
DocumentTotalLocalCurrency = documentTotalLocal,
VatSumLocalCurrency = vatLocal,
CompanyCurrency = "EUR",
PostingDate = postingDate,
InvoiceDate = invoiceDate,
Land = land,
DocumentType = "INV"
};
private static Site BuildSite()
=> new()
{
Id = 1,
Schema = "TEST",
TSC = "TEST",
Land = "Test",
SourceSystem = "TEST",
IsActive = true
};
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
{
_options = options;
}
public AppDbContext CreateDbContext() => new(_options);
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AppDbContext(_options));
}
}
@@ -140,12 +140,22 @@ public class ManualExcelDataSourceAdapterTests
return Task.FromResult(tempPath); return Task.FromResult(tempPath);
} }
public Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc) public Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null)
{ {
LastResolvedTsc = siteTsc; LastResolvedTsc = siteTsc;
return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero))); return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)));
} }
public Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null)
{
LastResolvedTsc = siteTsc;
IReadOnlyList<SharePointFileReference> result =
[
new(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero))
];
return Task.FromResult(result);
}
public Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) public Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
=> Task.CompletedTask; => Task.CompletedTask;
} }
@@ -48,10 +48,11 @@ public class ManualExcelImportServiceTests
ws.Cell(2, 28).Value = "CHF"; ws.Cell(2, 28).Value = "CHF";
ws.Cell(2, 29).Value = "DAP"; ws.Cell(2, 29).Value = "DAP";
ws.Cell(2, 30).Value = "Alice"; ws.Cell(2, 30).Value = "Alice";
ws.Cell(2, 31).Value = "14.04.2026"; ws.Cell(2, 31).Value = "13.04.2026";
ws.Cell(2, 32).Value = "10.04.2026"; ws.Cell(2, 32).Value = "14.04.2026";
ws.Cell(2, 33).Value = "Deutschland"; ws.Cell(2, 33).Value = "10.04.2026";
ws.Cell(2, 34).Value = "Invoice"; ws.Cell(2, 34).Value = "Deutschland";
ws.Cell(2, 35).Value = "Invoice";
}); });
try try
@@ -78,6 +79,7 @@ public class ManualExcelImportServiceTests
Assert.Equal("CHF", row.CompanyCurrency); Assert.Equal("CHF", row.CompanyCurrency);
Assert.Equal("Deutschland", row.Land); Assert.Equal("Deutschland", row.Land);
Assert.Equal("Invoice", row.DocumentType); Assert.Equal("Invoice", row.DocumentType);
Assert.Equal(new DateTime(2026, 4, 13), row.PostingDate);
Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate); Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate);
Assert.Equal(new DateTime(2026, 4, 10), row.OrderDate); Assert.Equal(new DateTime(2026, 4, 10), row.OrderDate);
} }
@@ -264,6 +266,7 @@ public class ManualExcelImportServiceTests
Map(nameof(SalesRecord.CompanyCurrency), "Währung"), Map(nameof(SalesRecord.CompanyCurrency), "Währung"),
Map(nameof(SalesRecord.Incoterms2020), "Versandbedingung"), Map(nameof(SalesRecord.Incoterms2020), "Versandbedingung"),
Map(nameof(SalesRecord.SalesResponsibleEmployee), "AdressNummer_V"), Map(nameof(SalesRecord.SalesResponsibleEmployee), "AdressNummer_V"),
Map(nameof(SalesRecord.PostingDate), "Belegdatum-Rechnung"),
Map(nameof(SalesRecord.InvoiceDate), "Belegdatum-Rechnung"), Map(nameof(SalesRecord.InvoiceDate), "Belegdatum-Rechnung"),
Map(nameof(SalesRecord.OrderDate), "BelegDatum Auftrag"), Map(nameof(SalesRecord.OrderDate), "BelegDatum Auftrag"),
Map(nameof(SalesRecord.DocumentType), "=Manual Excel") Map(nameof(SalesRecord.DocumentType), "=Manual Excel")
@@ -287,6 +290,7 @@ public class ManualExcelImportServiceTests
Assert.Equal("EUR", row.SalesCurrency); Assert.Equal("EUR", row.SalesCurrency);
Assert.Equal("EUR", row.DocumentCurrency); Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal("EUR", row.CompanyCurrency); Assert.Equal("EUR", row.CompanyCurrency);
Assert.Equal(new DateTime(2026, 4, 27), row.PostingDate);
Assert.Equal(new DateTime(2026, 4, 27), row.InvoiceDate); Assert.Equal(new DateTime(2026, 4, 27), row.InvoiceDate);
Assert.Equal(new DateTime(2026, 3, 9), row.OrderDate); Assert.Equal(new DateTime(2026, 3, 9), row.OrderDate);
Assert.Equal("Manual Excel", row.DocumentType); Assert.Equal("Manual Excel", row.DocumentType);
@@ -307,8 +311,8 @@ public class ManualExcelImportServiceTests
}; };
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv");
var csv = string.Join(Environment.NewLine, 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\"", "\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"LineRegistrationDate\";\"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\""); "\"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-03 00:00:00\";\"2025-01-02 00:00:00\";\"Invoice\"");
await File.WriteAllTextAsync(filePath, csv); await File.WriteAllTextAsync(filePath, csv);
try try
@@ -330,6 +334,7 @@ public class ManualExcelImportServiceTests
Assert.Equal("EUR", row.SalesCurrency); Assert.Equal("EUR", row.SalesCurrency);
Assert.Equal("EUR", row.DocumentCurrency); Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal("EUR", row.CompanyCurrency); Assert.Equal("EUR", row.CompanyCurrency);
Assert.Equal(new DateTime(2025, 1, 3), row.PostingDate);
Assert.Equal(new DateTime(2025, 1, 2), row.InvoiceDate); Assert.Equal(new DateTime(2025, 1, 2), row.InvoiceDate);
Assert.Equal("Invoice", row.DocumentType); Assert.Equal("Invoice", row.DocumentType);
} }
@@ -339,6 +344,60 @@ public class ManualExcelImportServiceTests
} }
} }
[Fact]
public async Task ReadSalesRecordsAsync_Evaluates_Mapped_Multiply_Expression()
{
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 = "invoice date";
ws.Cell(2, 1).Value = "42885";
ws.Cell(2, 2).Value = 9;
ws.Cell(2, 3).Value = 7;
ws.Cell(2, 4).Value = 123.45m;
ws.Cell(2, 5).Value = "18.11.2025";
});
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), "=[Sales Price/Value]*[Quantity]"),
Map(nameof(SalesRecord.SalesCurrency), "=GBP"),
Map(nameof(SalesRecord.CompanyCurrency), "=GBP"),
Map(nameof(SalesRecord.PostingDate), "invoice date"),
Map(nameof(SalesRecord.DocumentType), "=Manual Excel")
};
try
{
var service = new ManualExcelImportService();
var rows = await service.ReadSalesRecordsAsync(filePath, site, mappings);
var row = Assert.Single(rows);
Assert.Equal(864.15m, row.SalesPriceValue);
Assert.Equal(7m, row.Quantity);
Assert.Equal("GBP", row.SalesCurrency);
Assert.Equal("GBP", row.CompanyCurrency);
Assert.Equal(new DateTime(2025, 11, 18), row.PostingDate);
}
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");
@@ -382,6 +441,7 @@ public class ManualExcelImportServiceTests
"Company Currency", "Company Currency",
"Incoterms 2020", "Incoterms 2020",
"Sales responsible employee", "Sales responsible employee",
"posting date",
"invoice date", "invoice date",
"order date", "order date",
"Land", "Land",
@@ -36,12 +36,20 @@
<ItemGroup> <ItemGroup>
<Compile Remove="TrafagSalesExporter.Tests\**\*.cs" /> <Compile Remove="TrafagSalesExporter.Tests\**\*.cs" />
<Compile Remove="Tools\**\*.cs" /> <Compile Remove="Tools\**\*.cs" />
<Compile Remove=".tmp_tools\**\*.cs" />
<Compile Remove="verify_probe_out*\**\*.cs" />
<Content Remove="TrafagSalesExporter.Tests\**\*" /> <Content Remove="TrafagSalesExporter.Tests\**\*" />
<Content Remove="Tools\**\*" /> <Content Remove="Tools\**\*" />
<Content Remove=".tmp_tools\**\*" />
<Content Remove="verify_probe_out*\**\*" />
<EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" /> <EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" />
<EmbeddedResource Remove="Tools\**\*" /> <EmbeddedResource Remove="Tools\**\*" />
<EmbeddedResource Remove=".tmp_tools\**\*" />
<EmbeddedResource Remove="verify_probe_out*\**\*" />
<None Remove="TrafagSalesExporter.Tests\**\*" /> <None Remove="TrafagSalesExporter.Tests\**\*" />
<None Remove="Tools\**\*" /> <None Remove="Tools\**\*" />
<None Remove=".tmp_tools\**\*" />
<None Remove="verify_probe_out*\**\*" />
</ItemGroup> </ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences"> <Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
@@ -0,0 +1,237 @@
# Finance-Entscheide fuer Net Sales Actuals
Stand: 2026-05-11
Dieses Dokument haelt die fachlichen Entscheide fuer den Finance-Abgleich fest. Es ist die verbindliche Grundlage fuer das Testprogramm, die zentrale Tabelle und den Soll/Ist-Abgleich gegen `check.xlsx`.
## Grundsatz
Net Sales Actuals werden pro Land aus dem Landessystem gelesen, in der fachlich fuehrenden Hauswaehrung bewertet und gegen die Sollwerte aus `check.xlsx` verglichen.
Die Logik darf nicht auf einzelne Testzahlen optimiert werden. Sie muss je Jahr gleich funktionieren, sofern Sollwerte und Budgetkurse fuer das jeweilige Jahr gepflegt sind.
## Entscheide
| Thema | Entscheid |
| --- | --- |
| Fuehrende Waehrung | Immer Hauswaehrung des Landessystems. |
| CHF-Umrechnung | Nur als separater Kontroll-/Reporting-Kandidat ueber Budgetkurse. Keine SNB-Tageskurse fuer den Standardabgleich. |
| Aggregation | Pro Artikel bzw. Belegposition rechnen und summieren. |
| Wertbasis | Nettofakturawert. |
| Jahresabgrenzung | Buchungsdatum. |
| Gutschriften/Storno | Separat als eigene Beleg-/Positionszeilen ausweisen. Immer ueber Artikelnummer/Positionslogik behandeln. |
| Intercompany | In einem zweiten Schritt als 2nd-party/IC ausweisen. Nicht still aus dem Standard-Ist entfernen. |
## Landesspezifische Praezisierungen
| Land | Entscheid / Regel |
| --- | --- |
| 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. |
| UK | Hauswaehrung `GBP` verwenden. Die aktuell geladene Zahl wirkt wie eine Teilmenge und muss gegen vollstaendige Jahresquelle geprueft werden. |
| 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. |
| ES | SAGE-Excel/CSV; Serien, Gutschriften und Datumsbasis bleiben Kontrollpunkte bis fachlich final bestaetigt. |
## Intercompany / 2nd Party
Intercompany wird ueber stabile Kundenregeln klassifiziert. Aktuelle fachliche Marker:
- `TRAFAG`
- `MAGNETIC SENSE`
- `MAGNETS SENSE`
- `GESELLSCHAFT FUER SENSORIK`
- `GESELLSCHAFT FUR SENSORIK`
Weitere Uebersetzungen, Kundennummern oder lokale Schreibweisen muessen bei Bedarf ergaenzt werden.
Ergebnis im Reporting:
- Standard-Ist bleibt inklusive aller Positionen.
- 2nd-party/IC wird als separater Betrag und als Sicht "ohne 2nd-party" gezeigt.
- Finance entscheidet danach, ob und wo IC fuer offizielle Abgrenzungen ausgeschlossen wird.
## Technische Umsetzung im Programm
| Regel | Umsetzung |
| --- | --- |
| Buchungsdatum | `PostingDate` in `SalesRecord` und `CentralSalesRecord`; Finance-Abgleich filtert nach `PostingDate`. |
| Fallback Datum | Nur falls Quelle kein Buchungsdatum liefert: `InvoiceDate`, danach `ExtractionDate`. |
| Hauswaehrung | Finance-Abgleich weist bekannte Land-Hauswaehrungen aus, z. B. `INR` fuer Indien und `GBP` fuer UK. |
| Nettofakturawert | Kandidat `Nettofakturawert Hauswaehrung pro Position`. |
| B1-Belegkopfwerte | Wiederholte `DocTotal - VatSum`-Werte werden erkannt, damit Belegkopfwerte nicht pro Position multipliziert werden. |
| Budget-CHF | Budgetkurs-Kandidat wird aus Hauswaehrung pro Position gerechnet. |
| IC | `FinanceIntercompanyRules` klassifizieren 2nd-party/IC. |
## 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.
- IN: Anzeige muss fachlich `INR` zeigen, auch wenn Quellzeilen verschiedene Belegwaehrungen enthalten.
- IT: IC-Kundenliste final bestaetigen.
- CH / AT: echtes SAP-Buchungsdatum pruefen, falls `ZSCHWEIZ` aktuell nur Fakturadatum liefert.
- DE: finalen Jahresfile laden.
- ES: Serien und Gutschriften fachlich final bestaetigen.
## Pruefstand 2026-05-11
Die Finance-Regeln wurden im Code abgesichert und mit Tests geprueft.
Geprueft:
- Finance-Abgleich nutzt `PostingDate` fuer die Jahresabgrenzung.
- FinanceProbe-Coverage nutzt ebenfalls `PostingDate`.
- Indien wird in der Finance-Logik als `INR`-Hauswaehrung ausgewiesen, auch wenn die Quellzeilen verschiedene Belegwaehrungen enthalten.
- UK wird als `GBP`-Hauswaehrung ausgewiesen.
- Wiederholte B1-Belegkopfwerte werden erkannt, damit `DocTotal - VatSum` nicht pro Position multipliziert wird.
- 2nd-party/IC bleibt separat sichtbar.
Testergebnis:
```text
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
```
Ergebnis:
```text
58/58 Tests gruen
```
Bekannte Warnungen:
- MudBlazor Analyzer meldet bestehende `Dense`-Attribute in einzelnen Razor-Komponenten.
- NuGet-Sicherheitsdaten konnten lokal nicht von `api.nuget.org` geladen werden.
## UK / England Befund
England/UK ist im System vorhanden und wird im FinanceProbe-Abgleich angezeigt.
Aktueller Befund aus der Probe:
| Kennzahl | Wert |
| --- | ---: |
| Land | UK / England |
| TSC | `TRUK` |
| Hauswaehrung | `GBP` |
| Geladene Zeilen | `1'881` |
| Ist-Wert | `395'605.82 GBP` |
| Sollwert check.xlsx | `3'749'865.00` |
| Differenz | `-3'354'259.18` |
Interpretation:
- Die UK-Zahl ist fachlich nicht plausibel als Jahreswert.
- Wahrscheinlich wurde nur eine Teilmenge bzw. eine Monatsdatei geladen.
- Der SharePoint-Ordner `Import/Finance/UK_B1` enthaelt Dateien nach Muster `ddMMyy_TRUK.xlsx`, z. B. `010426_TRUK.xlsx` und `010526_TRUK.xlsx`.
- Die App soll die neueste passende Datei lesen. Fuer einen Jahresvergleich muss geklaert werden, ob die neueste Datei kumulierte Jahresdaten oder nur Monatsdaten enthaelt.
Naechster fachlicher Check fuer UK:
- Bestaetigen, ob `010526_TRUK.xlsx` kumuliert Januar bis Mai oder nur Mai enthaelt.
- Falls Monatsdateien geliefert werden, muss Finance entscheiden:
- alle Monatsdateien 2025 aufsummieren, oder
- nur einen kumulierten Jahresfile lesen.
## Nachtrag 2026-05-11: UK_B1 Mapping
Der UK-Befund wurde nachtraeglich technisch untersucht.
Wichtige Feststellungen:
- Quelle bleibt `UK_B1`.
- Der Standort ist `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`.
- Der korrekte SharePoint-Ordner ist:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- Lokal war fuer `TRUK` kein grafisches Manual-Excel-Mapping vorhanden.
- Dadurch hat der Fallback-Importer `Sales Price/Value` direkt als Positionswert uebernommen.
- In der UK-B1-Datei ist `Sales Price/Value` aber ein Stueckpreis.
- Der fachliche Positionswert muss pro Belegposition berechnet werden:
```text
Positionswert = [Sales Price/Value] * [Quantity]
```
Technische Probe auf den bereits importierten UK-Zeilen:
| Variante | Wert |
| --- | ---: |
| Bisherige Summe `SalesPriceValue` | `395'605.82 GBP` |
| Rekonstruierte Summe `SalesPriceValue * Quantity` | `3'533'348.89 GBP` |
| Sollwert check.xlsx | `3'749'865.00 GBP` |
| Restdifferenz nach Multiplikation | ca. `-216'516.11 GBP` |
Bewertung:
- Die grosse UK-Abweichung war hauptsaechlich ein Mapping-Fehler.
- 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.
- Die bisherige Interpretation "nur Monatsfile/Teilmenge" ist nicht mehr die wahrscheinlichste Hauptursache, bleibt aber als Datenvollstaendigkeitscheck offen.
Ziel-Mapping fuer `TRUK`:
| Zielfeld | Quelle |
| --- | --- |
| `Tsc` | `TSC` |
| `Land` | `Land` |
| `InvoiceNumber` | `Invoice Number` |
| `PositionOnInvoice` | `Position on invoice` |
| `Material` | `Material` |
| `Name` | `Name` |
| `ProductGroup` | `Product Group` |
| `Quantity` | `Quantity` |
| `CustomerNumber` | `Customer number` |
| `CustomerName` | `Customer name` |
| `CustomerCountry` | `Customer country` |
| `SalesPriceValue` | `=[Sales Price/Value]*[Quantity]` |
| `SalesCurrency` | `=GBP` |
| `DocumentCurrency` | `=GBP` |
| `CompanyCurrency` | `=GBP` |
| `PostingDate` | `invoice date` |
| `InvoiceDate` | `invoice date` |
| `DocumentType` | `=Manual Excel` |
Code-Stand dazu:
- `ManualExcelImportService` unterstuetzt im grafischen Manual-Excel-Mapping einfache Multiplikationsausdruecke mit Excel-Headern:
```text
=[Header A]*[Header B]
```
- Konstanten wie `=GBP` funktionieren unveraendert.
- `DatabaseSeedService` repariert den alten/falschen England-Pfad auf `UK_B1` und seedet das `TRUK`-Mapping.
- Ein Unit-Test prueft, dass `SalesPriceValue = [Sales Price/Value] * [Quantity]` korrekt gelesen wird.
Aktueller Verifikationsstand:
- Die neue UK-Mapping-Logik ist implementiert.
- `DatabaseSeedService` seedet das UK-Mapping nur, wenn `ManualExcelColumnMappings` sauber auf `Sites` referenziert.
- Damit blockieren alte SQLite-Reparaturreferenzen wie `Sites_repair_old` den Initialisierungslauf nicht mehr.
- Der volle Testlauf ist gruen:
```text
59/59 Tests gruen
```
Naechster praktischer Schritt:
- App oder FinanceProbe starten, damit die lokale DB den Seed/Repair bekommt.
- Danach UK per `/run/export/TRUK` gegen SharePoint `UK_B1` neu laden.
- Anschliessend `/finance` erneut gegen `check.xlsx` pruefen.
Praktischer Nachtrag:
- Lokale DB ist aktualisiert: `TRUK` hat den `UK_B1`-Pfad und `18` aktive Mapping-Zeilen.
- FinanceProbe laeuft auf `http://127.0.0.1:5099` und `/finance` antwortet.
- Der neue `/run/export/TRUK`-Lauf konnte noch nicht abgeschlossen werden, weil die lokale SharePoint-/Graph-Authentifizierung scheitert:
```text
ClientSecretCredential authentication failed
127.0.0.1:9 connection refused
```
- Bis dieser Zugriff funktioniert, bleibt `CentralSalesRecords` fuer UK auf dem alten Importstand.
+46 -1
View File
@@ -9,6 +9,10 @@ Fuer das Programm bieten sich zwei Diagrammarten an:
## Dateien ## Dateien
- `docs/FINANCE_ENTSCHEIDE.md`
- dokumentiert die verbindlichen Financechef-Entscheide fuer Waehrung, Budgetkurse, Nettofakturawert, Buchungsdatum, Gutschriften und Intercompany
- ist die fachliche Grundlage fuer FinanceProbe und den Soll/Ist-Abgleich
- `docs/program-user-stories.svg` - `docs/program-user-stories.svg`
- zeigt Finance, Power User/Admin und IT/SAP als Rollen - zeigt Finance, Power User/Admin und IT/SAP als Rollen
- ordnet Stories nach Quellenpflege, Mapping, Import, Konsolidierung, Finance-Abgleich und Betrieb - ordnet Stories nach Quellenpflege, Mapping, Import, Konsolidierung, Finance-Abgleich und Betrieb
@@ -20,6 +24,11 @@ Fuer das Programm bieten sich zwei Diagrammarten an:
- zeigt den zentralen Weg ueber grafisches Mapping, `MappedSalesRecordComposer`, `CentralSalesRecords`, Finance-Abgleich und Export - zeigt den zentralen Weg ueber grafisches Mapping, `MappedSalesRecordComposer`, `CentralSalesRecords`, Finance-Abgleich und Export
- markiert bewusste Rest-Doppelspuren wie HANA-B1-Legacy und den offenen Ausbau fuer Finance-Regelpflege - markiert bewusste Rest-Doppelspuren wie HANA-B1-Legacy und den offenen Ausbau fuer Finance-Regelpflege
- `docs/finance-land-algorithms.svg`
- zeigt fuer Finance den buchhalterischen Fluss je Land
- beschreibt Quelle, Mapping, Hauswaehrung, Nettofakturawert, Buchungsdatum, IC-Ausweis und Sollvergleich
- macht sichtbar, dass der Algorithmus regelbasiert ist und nicht auf einzelne Testzahlen frisiert wurde
## Abgleich gegen Quellcode ## Abgleich gegen Quellcode
Die Diagramme wurden gegen folgende Codebereiche abgeglichen: Die Diagramme wurden gegen folgende Codebereiche abgeglichen:
@@ -37,9 +46,45 @@ Die Diagramme wurden gegen folgende Codebereiche abgeglichen:
Wichtige Praezisierung aus dem Code: Wichtige Praezisierung aus dem Code:
- `SalesPriceValue` wird im Finance-Abgleich positionsweise summiert. - `SalesPriceValue` wird im Finance-Abgleich positionsweise summiert.
- Belegkopfwerte wie `DocTotal - VatSum` werden vor der Summierung pro Beleg dedupliziert. - `PostingDate` ist die fuehrende Jahresabgrenzung. Falls eine Quelle kein Buchungsdatum liefert, faellt der Code auf `InvoiceDate` und danach `ExtractionDate` zurueck.
- Hauswaehrung ist fuehrend. CHF wird als Budgetkurs-Kandidat gerechnet, nicht als Tageskurs-Standard.
- Belegkopfwerte wie `DocTotal - VatSum` werden nicht blind pro Position multipliziert. Der Code erkennt wiederholte Headerwerte und zeigt Positionswert sowie deduplizierten Belegwert als Kandidaten.
- Der ausgewaehlte Finance-Wert ist daher ein Ist-Kandidat, nicht pauschal immer eine Positionssumme. - Der ausgewaehlte Finance-Wert ist daher ein Ist-Kandidat, nicht pauschal immer eine Positionssumme.
## FinanceProbe starten
Normale Ansicht:
```powershell
dotnet run --project .\Tools\FinanceProbe\FinanceProbe.csproj --urls http://127.0.0.1:5099
```
Danach im Browser:
```text
http://127.0.0.1:5099/finance
```
Export-/Prueflaeufe:
```text
http://127.0.0.1:5099/run/export-all
http://127.0.0.1:5099/run/consolidated
http://127.0.0.1:5099/run/export/TRUK
```
Wenn der Build-Output durch ein laufendes Programm gesperrt ist, zuerst den alten `dotnet`-Prozess beenden oder ohne Rebuild die vorhandene DLL starten:
```powershell
dotnet .\Tools\FinanceProbe\bin\Debug\net8.0\FinanceProbe.dll --urls http://127.0.0.1:5099
```
Hinweis fuer das Testprogramm:
- FinanceProbe verwendet Console-Logging, damit lokale Windows-EventLog-Rechte den Prueflauf nicht abbrechen.
- Falls Visual Studio oder ein alter `dotnet`-Prozess DLLs sperrt, den Prozess beenden und danach neu bauen/starten.
- Der aktuelle Entwicklungs-Pruefstand wurde zusaetzlich mit einem separaten Output unter `.codex\memories\financeprobe_check\out` gebaut, um Build-Locks zu umgehen.
## Einsatz ## Einsatz
Die SVG-Dateien koennen direkt im Browser geoeffnet, in Markdown verlinkt oder in Praesentationen eingefuegt werden. Die SVG-Dateien koennen direkt im Browser geoeffnet, in Markdown verlinkt oder in Praesentationen eingefuegt werden.
@@ -0,0 +1,259 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1450" viewBox="0 0 1800 1450" role="img" aria-labelledby="title desc">
<title id="title">Finance-Fluss je Land</title>
<desc id="desc">Blockdiagramm fuer den buchhalterischen Finanzfluss je Land vom Quellsystem bis zum Soll-Ist-Abgleich.</desc>
<defs>
<style>
.bg { fill: #f6f7f9; }
.panel { fill: #ffffff; stroke: #d8dee8; stroke-width: 1.4; rx: 8; }
.head { fill: #22324a; }
.source { fill: #e9f1fb; stroke: #8eb3df; }
.mapping { fill: #eef8f0; stroke: #8bc790; }
.finance { fill: #fff4e3; stroke: #e1a84d; }
.check { fill: #f4eefb; stroke: #b497d6; }
.warn { fill: #fff7cc; stroke: #d5b53b; }
.line { stroke: #7b8798; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
.title { font: 700 28px "Segoe UI", Arial, sans-serif; fill: #17202a; }
.subtitle { font: 400 15px "Segoe UI", Arial, sans-serif; fill: #667085; }
.h2 { font: 700 17px "Segoe UI", Arial, sans-serif; fill: #ffffff; }
.country { font: 700 16px "Segoe UI", Arial, sans-serif; fill: #17202a; }
.txt { font: 400 13px "Segoe UI", Arial, sans-serif; fill: #17202a; }
.small { font: 400 12px "Segoe UI", Arial, sans-serif; fill: #667085; }
.mono { font: 600 12px "Consolas", "Segoe UI", monospace; fill: #17202a; }
</style>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6 Z" fill="#7b8798"/>
</marker>
</defs>
<rect class="bg" x="0" y="0" width="1800" height="1450"/>
<text class="title" x="48" y="58">Finance-Fluss je Land: was buchhalterisch im Hintergrund passiert</text>
<text class="subtitle" x="48" y="86">Zielbild fuer Finance: je Land wird der Nettofakturawert in Hauswaehrung pro Position gelesen, per Buchungsdatum abgegrenzt und gegen check.xlsx verglichen.</text>
<rect class="panel" x="48" y="118" width="1704" height="130"/>
<rect class="head" x="48" y="118" width="1704" height="34" rx="8"/>
<text class="h2" x="68" y="141">Globale Finance-Regeln</text>
<text class="txt" x="70" y="176">1. Datumsabgrenzung: Buchungsdatum. Technischer Fallback, falls Quelle kein Buchungsdatum liefert: Fakturadatum, danach Extraktionsdatum.</text>
<text class="txt" x="70" y="200">2. Waehrung: Hauswaehrung ist fuehrend. CHF wird nur als eigener Kandidat ueber Budgetkurs gerechnet, nicht mit Tageskurs.</text>
<text class="txt" x="70" y="224">3. Wertbasis: Nettofakturawert pro Belegposition. Wiederholte B1-Belegkopfwerte werden erkannt, damit DocTotal nicht pro Position multipliziert wird.</text>
<text class="txt" x="945" y="176">4. Gutschriften/Storno: eigene Beleg-/Positionszeilen bleiben sichtbar und laufen ueber Artikel/Position.</text>
<text class="txt" x="945" y="200">5. Intercompany: 2nd/3rd Party wird separat ausgewiesen; der Abzug ist Kontrollsicht, nicht stiller Standardabzug.</text>
<text class="txt" x="945" y="224">6. Sollwert: FinanceReferences/check.xlsx je Jahr; gleiche Logik funktioniert fuer andere Jahre mit passenden Sollwerten und Budgetkursen.</text>
<rect class="panel" x="48" y="278" width="1704" height="148"/>
<rect class="head" x="48" y="278" width="1704" height="34" rx="8"/>
<text class="h2" x="68" y="301">Zentraler Algorithmus im Programm</text>
<g transform="translate(78 332)">
<rect class="source" x="0" y="0" width="245" height="64" rx="6"/>
<text class="txt" x="16" y="25">Landesquelle lesen</text>
<text class="small" x="16" y="46">SAP OData, HANA, Excel/CSV</text>
<path class="line" d="M245 32 H305"/>
<rect class="mapping" x="305" y="0" width="250" height="64" rx="6"/>
<text class="txt" x="321" y="25">Grafisches Mapping</text>
<text class="small" x="321" y="46">Zielmodell SalesRecord</text>
<path class="line" d="M555 32 H615"/>
<rect class="mapping" x="615" y="0" width="250" height="64" rx="6"/>
<text class="txt" x="631" y="25">Zentrale Tabelle</text>
<text class="small" x="631" y="46">CentralSalesRecords</text>
<path class="line" d="M865 32 H925"/>
<rect class="finance" x="925" y="0" width="285" height="64" rx="6"/>
<text class="txt" x="941" y="25">FinanceReconciliationService</text>
<text class="small" x="941" y="46">Jahr, Hauswaehrung, Netto, IC</text>
<path class="line" d="M1210 32 H1270"/>
<rect class="check" x="1270" y="0" width="285" height="64" rx="6"/>
<text class="txt" x="1286" y="25">Vergleich gegen Soll</text>
<text class="small" x="1286" y="46">FinanceReferences / check.xlsx</text>
</g>
<g transform="translate(48 466)">
<rect class="head" x="0" y="0" width="1704" height="34" rx="8"/>
<text class="h2" x="20" y="23">Landesspezifische Fluesse</text>
<!-- row helper coordinates: x source 20, map 310, finance 600, check 965, note 1270 -->
<g transform="translate(0 54)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">CH / AT</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">SAP ZSCHWEIZ OData</text>
<text class="small" x="151" y="55">BUKRS 1100=CH, 1200=AT</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Mapping ZSCHWEIZSet</text>
<text class="small" x="451" y="55">LAND1/TSC trennt CH und AT</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">NETWR_HC pro Position</text>
<text class="small" x="761" y="55">Hauswaehrung CHF/EUR, FKDAT aktuell als Datum</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Soll je Land</text>
<text class="small" x="1161" y="55">CH und AT separat in FinanceReferences</text>
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Offen</text>
<text class="small" x="1456" y="55">echtes SAP-BUDAT noch klaeren/mappen</text>
</g>
<g transform="translate(0 158)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">FR</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
<text class="small" x="151" y="55">OINV/INV1, ORIN/RIN1</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Legacy B1 oder grafisches HANA</text>
<text class="small" x="451" y="55">DocDate=PostingDate, TaxDate=InvoiceDate</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Positions-Netto in Hauswaehrung EUR</text>
<text class="small" x="761" y="55">keine DocTotal-Multiplikation</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll FR</text>
<text class="small" x="1161" y="55">bisher nahe an check.xlsx</text>
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">IC sichtbar</text>
<text class="small" x="1456" y="55">2nd/3rd Party Kandidat</text>
</g>
<g transform="translate(0 262)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">IT</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
<text class="small" x="151" y="55">Italien ist B1</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Hauswaehrung EUR</text>
<text class="small" x="451" y="55">B1-Headerwerte werden erkannt</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Netto nach Position, IC separat</text>
<text class="small" x="761" y="55">Trafag/Magnetic Sense usw. als 2nd Party</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll IT</text>
<text class="small" x="1161" y="55">mit und ohne IC-Deduction sichtbar</text>
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
<text class="small" x="1456" y="55">IC-Kundenliste finalisieren</text>
</g>
<g transform="translate(0 366)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">UK</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">SharePoint Excel</text>
<text class="small" x="151" y="55">neueste Datei ddMMyy_TRUK.xlsx</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Manual Excel Mapper</text>
<text class="small" x="451" y="55">Hauswaehrung GBP fuehrend</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Delta/neueste Datei lesen</text>
<text class="small" x="761" y="55">Netto pro Position, Jahr nach Buchungsdatum</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll UK</text>
<text class="small" x="1161" y="55">Reporting in GBP, CHF nur Kandidat</text>
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Quelle bleibt Ordner</text>
<text class="small" x="1456" y="55">Export in gleichen SharePoint-Ort</text>
</g>
<g transform="translate(0 470)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">IN</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">SAGE / HANA</text>
<text class="small" x="151" y="55">Indien wird als INR gefuehrt</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Waehrung normalisieren</text>
<text class="small" x="451" y="55">Hauswaehrung INR statt Belegmix</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Nettofakturawert INR</text>
<text class="small" x="761" y="55">Budget-CHF nur Kontrollkandidat</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll IN</text>
<text class="small" x="1161" y="55">bisher nahe an check.xlsx</text>
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
<text class="small" x="1456" y="55">keine gemischten Belegwaehrungen summieren</text>
</g>
<g transform="translate(0 574)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">ES</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">SAGE Export Excel/CSV</text>
<text class="small" x="151" y="55">SharePoint oder lokales File</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Manual Excel/CSV Mapper</text>
<text class="small" x="451" y="55">LineRegistrationDate als Buchungsdatum</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Netto pro Position in EUR</text>
<text class="small" x="761" y="55">CSV und XLSX technisch erlaubt</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll ES</text>
<text class="small" x="1161" y="55">Differenz fachlich klaeren</text>
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
<text class="small" x="1456" y="55">Serien/Gutschriften bestaetigen</text>
</g>
<g transform="translate(0 678)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">DE</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">Alphaplan Excel</text>
<text class="small" x="151" y="55">Jahresfile von Deutschland</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">Manual Excel Mapper</text>
<text class="small" x="451" y="55">NettoPreisGesamtX, Belegdatum</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Netto pro Position in EUR</text>
<text class="small" x="761" y="55">Sample nicht als Jahres-Ist verwenden</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll DE</text>
<text class="small" x="1161" y="55">erst nach finalem Jahresfile</text>
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">Offen</text>
<text class="small" x="1456" y="55">vollstaendiger 2025-Export fehlt</text>
</g>
<g transform="translate(0 782)">
<rect class="panel" x="0" y="0" width="1704" height="92"/>
<text class="country" x="20" y="30">US</text>
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
<text class="small" x="151" y="55">US B1 Schema</text>
<path class="line" d="M385 43 H435"/>
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
<text class="txt" x="451" y="35">B1-Positionsdaten</text>
<text class="small" x="451" y="55">DocDate=PostingDate</text>
<path class="line" d="M695 43 H745"/>
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
<text class="txt" x="761" y="35">Netto pro Position in USD</text>
<text class="small" x="761" y="55">CHF Budget als Kandidat</text>
<path class="line" d="M1095 43 H1145"/>
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
<text class="txt" x="1161" y="35">Vergleich Soll US</text>
<text class="small" x="1161" y="55">FinanceReferences je Jahr</text>
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
<text class="txt" x="1456" y="35">IC sichtbar</text>
<text class="small" x="1456" y="55">gleiche Regel wie B1-Laender</text>
</g>
</g>
<rect class="panel" x="48" y="1382" width="1704" height="44"/>
<text class="small" x="68" y="1409">Stand: 2026-05-11. Abgeleitet aus Services/DataSources, MappedSalesRecordComposer, CentralSalesRecords, DatabaseSeedService und FinanceReconciliationService. Das Diagramm beschreibt die Buchhaltungslogik, nicht einzelne Testzahlen.</text>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+212
View File
@@ -1,5 +1,122 @@
# Last Change 2026-05-04 # Last Change 2026-05-04
## UK_B1 Mapping / FinanceProbe Nachtrag 2026-05-11
Anlass:
- In der FinanceProbe zeigte UK/England fuer `TRUK` nur `395'605.82 GBP` Ist gegen `3'749'865.00 GBP` Soll.
- In den Varianten fehlten weitere sinnvolle Abgrenzungen; sichtbar war nur `Positions-Netto (Sales Price/Value)`.
- Der Standort soll weiterhin `UK_B1` verwenden.
Technischer Befund:
- Standort:
- `Land = England`
- `TSC = TRUK`
- `SourceSystem = MANUAL_EXCEL`
- Korrekte Quelle:
```text
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
```
- Lokal waren fuer `TRUK` keine `ManualExcelColumnMappings` vorhanden.
- Der Import lief deshalb ueber die Header-Automatik.
- Die Header-Automatik behandelte `Sales Price/Value` als fertigen Positionswert.
- In der UK-B1-Datei ist `Sales Price/Value` nach aktuellem Befund aber ein Stueckpreis.
- Der Finance-Positionswert muss deshalb berechnet werden:
```text
[Sales Price/Value] * [Quantity]
```
Probe auf den bereits geladenen UK-Daten:
| Berechnung | Wert |
| --- | ---: |
| Bisher importiert: Summe `SalesPriceValue` | `395'605.82 GBP` |
| Rekonstruiert: Summe `SalesPriceValue * Quantity` | `3'533'348.89 GBP` |
| Soll `check.xlsx` | `3'749'865.00 GBP` |
| Restdifferenz nach Multiplikation | ca. `216'516.11 GBP` |
Umgesetzte Codeaenderung:
- `Services/ManualExcelImportService.cs`
- grafische Manual-Excel-Mappings koennen jetzt einfache berechnete Quellen auswerten
- aktuell benoetigte Syntax:
```text
=[Header A]*[Header B]
```
- Konstanten wie `=GBP` bleiben unveraendert gueltig
- `Services/DatabaseSeedService.cs`
- England/TRUK wird auf den SharePoint-Ordner `Import/Finance/UK_B1` repariert, wenn der alte/falsche Pfad `Import/Finance/England` oder ein leerer Pfad vorhanden ist
- fuer `TRUK` wird ein grafisches Manual-Excel-Mapping geseedet
- wichtigste Zuordnung:
```text
SalesPriceValue <- =[Sales Price/Value]*[Quantity]
SalesCurrency <- =GBP
DocumentCurrency<- =GBP
CompanyCurrency <- =GBP
PostingDate <- invoice date
InvoiceDate <- invoice date
```
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
- neuer Test fuer Multiplikationsausdruck im Manual-Excel-Mapping
- prueft, dass `123.45 * 7 = 864.15` als `SalesPriceValue` importiert wird
Aktueller Verifikationsstand:
```text
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
```
Ergebnis:
- Tests erfolgreich.
- `59/59` Tests gruen.
- Bekannte Warnungen bleiben die bestehenden MudBlazor-Analyzerwarnungen zu `Dense`.
Zusatzfix:
- `DatabaseSeedService` wurde gehaertet.
- Der UK-Mapping-Seed wird nur ausgefuehrt, wenn `ManualExcelColumnMappings` sauber auf `Sites` referenziert.
- Dadurch wird der Initialisierungslauf nicht blockiert, wenn eine bestehende SQLite-DB gerade noch aus alten Reparaturtabellen wie `Sites_repair_old` bereinigt wird.
Naechster praktischer Schritt:
- Lokale DB wurde direkt aktualisiert:
- `TRUK` zeigt auf `https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1`
- `TRUK` hat `18` aktive Manual-Excel-Mapping-Zeilen
- `SalesPriceValue <= =[Sales Price/Value]*[Quantity]`
- FinanceProbe wurde auf `http://127.0.0.1:5099` neu gestartet.
- `/finance` antwortet mit HTTP `200`.
- `/run/export/TRUK` wurde angestossen, konnte aber wegen lokaler SharePoint-/Graph-Authentifizierung nicht neu laden:
```text
ClientSecretCredential authentication failed
Es konnte keine Verbindung hergestellt werden, da der Zielcomputer die Verbindung verweigerte. (127.0.0.1:9)
```
Damit gilt:
- Code, Seed und lokale Mapping-Konfiguration sind vorbereitet.
- Die zentrale Tabelle `CentralSalesRecords` enthaelt fuer UK noch den alten Importstand, bis der SharePoint-Zugriff wieder funktioniert und `TRUK` neu exportiert wird.
- Aktueller alter Zentralstand bleibt deshalb:
- `1'882` Zeilen
- `395'605.82 GBP` Summe `SalesPriceValue`
- rekonstruiert `3'533'348.89 GBP` ueber `SalesPriceValue * Quantity`
Offen fachlich fuer UK:
- Nach neuem Export mit Mapping muss die Restdifferenz gegen `check.xlsx` erneut gemessen werden.
- Wenn der Wert bei ca. `3.53 Mio. GBP` liegt, UK-Datei auf Rabatte, Fracht, Nebenpositionen oder eine andere Netto-Spalte pruefen.
- Wenn der Wert auf `3.75 Mio. GBP` steigt, war das Mapping die Hauptursache.
## Manual Excel/CSV SharePoint-Ordner und Quellordner-Export 2026-05-08 ## Manual Excel/CSV SharePoint-Ordner und Quellordner-Export 2026-05-08
Umgesetzte Anpassungen: Umgesetzte Anpassungen:
@@ -69,6 +186,32 @@ Verifikation:
- `Tools/FinanceProbe` Build erfolgreich. - `Tools/FinanceProbe` Build erfolgreich.
- Haupttests wurden mit separatem Output/Obj-Pfad ausgefuehrt, damit die laufende App nicht stoert. - Haupttests wurden mit separatem Output/Obj-Pfad ausgefuehrt, damit die laufende App nicht stoert.
## FinanceProbe als KI-Steuerprogramm 2026-05-11
Die FinanceProbe ist bewusst als temporaeres Test-/KI-Steuerprogramm erweitert worden. Die produktive Blazor-App bleibt davon getrennt.
Neue Routen:
- `/run/export/{siteKey}`
- startet einen Standortexport nach `Id`, `TSC` oder `Land`
- Beispiele: `/run/export/TRUK`, `/run/export/Spanien`, `/run/export/7`
- `/run/export-all`
- startet Export aller aktiven Standorte
- erzeugt danach die zentrale Datei
- `/run/consolidated`
- erzeugt nur die zentrale Datei aus `CentralSalesRecords`
Nach jedem Lauf zeigt die FinanceProbe eine Run Summary:
- neue Exportlogs seit Start
- Finance-Abgleich gegen `check.xlsx`
- Datenabdeckung je Standort
Zweck:
- Exporte und Finance-Abgleich koennen fuer Tests von der KI per HTTP angestossen werden.
- Die Funktion ist nicht als produktive Bedienoberflaeche gedacht und kann spaeter wieder entfernt werden.
## Mapper-/Finance-Konfiguration konsolidiert 2026-05-07 ## Mapper-/Finance-Konfiguration konsolidiert 2026-05-07
Umgesetzte Aufraeumarbeiten: Umgesetzte Aufraeumarbeiten:
@@ -960,3 +1103,72 @@ Ergebnis:
- `Meeting Ampel 2025` - `Meeting Ampel 2025`
- `Spain CSV direct check` - `Spain CSV direct check`
- `Germany Excel sample check` - `Germany Excel sample check`
## Financechef-Regeln abgesichert 2026-05-11
Umgesetzt:
- `PostingDate` als eigenes Feld in `SalesRecord` und `CentralSalesRecord`.
- Zentrale SQLite-Tabelle erhaelt `PostingDate` automatisch per Schema-Maintenance.
- HANA-B1 liest `DocDate` als Buchungsdatum und `TaxDate` als Fakturadatum.
- Excel/CSV-Import erkennt `posting date`, `Buchungsdatum` und `LineRegistrationDate`.
- Finance-Abgleich filtert das Jahr nach `PostingDate`, mit Fallback auf `InvoiceDate` und danach `ExtractionDate`.
- Finance-Abgleich bevorzugt Nettofakturawert in Hauswaehrung positionsweise.
- Wenn lokale Belegkopfwerte pro Position wiederholt wirken, wird die Ueberzaehlung erkannt:
- B1-Positionswert `SalesPriceValue` wird dann als Positions-Netto bevorzugt.
- deduplizierter Belegkopfwert bleibt als Kandidat sichtbar.
- Intercompany wird weiterhin separat ausgewiesen und nicht still entfernt.
Verifikation:
```text
dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\verify_probe_out\ --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
```
Ergebnis:
- FinanceProbe Build erfolgreich.
- Tests erfolgreich: `57/57`.
- Bekannte externe Warnung: NuGet-Sicherheitsdaten konnten wegen fehlendem Zugriff auf `api.nuget.org` nicht geladen werden.
- Lokaler Smoke-Test `/finance`: `HTTP 200`.
- Hinweis: Ein bestehender `dotnet`-Prozess sperrt den normalen FinanceProbe-Build-Output. Der Smoke-Test wurde deshalb ohne Rebuild direkt aus dem vorhandenen Output gestartet.
## Finance-Entscheide dokumentiert 2026-05-11
Neue Doku:
```text
docs/FINANCE_ENTSCHEIDE.md
```
Enthaelt die verbindlichen Financechef-Entscheide:
- Hauswaehrung ist fuehrend.
- CHF-Umrechnung ueber Budgetkurse.
- Aggregation pro Artikel/Belegposition.
- Net Sales Actuals = Nettofakturawert.
- Jahresabgrenzung ueber Buchungsdatum.
- Gutschriften separat ueber Beleg-/Positionslogik.
- Intercompany/2nd-party separat ausweisen.
- Indien fachlich immer in `INR`.
## FinanceProbe / UK Nachdokumentation 2026-05-11
Ergaenzt in `docs/FINANCE_ENTSCHEIDE.md`:
- Pruefstand der Finance-Regeln.
- Testergebnis `58/58`.
- UK/England-Befund:
- `TRUK`
- `1'881` geladene Zeilen
- `395'605.82 GBP` Ist
- `3'749'865.00` Soll
- Differenz `-3'354'259.18`
- Interpretation: vermutlich Teilmenge/Monatsfile statt Jahreswert.
- Offener UK-Entscheid: Monatsdateien aufsummieren oder kumulierten Jahresfile lesen.
Ergaenzt in `docs/PROGRAMM_DIAGRAMME.md`:
- FinanceProbe-Start und Hinweis zu Console-Logging.
- Hinweis zu DLL-Sperren durch Visual Studio bzw. alte `dotnet`-Prozesse.
+214
View File
@@ -0,0 +1,214 @@
# Fluktuation Nachdokumentation - 2026-05-12
## Ausgangslage
Die Fluktuationsformeln aus `formeln.docx` sollten in die Power-BI-Logik uebernommen werden.
Fachliche Definition laut HR:
- Zaehler: nur Arbeitnehmerkuendigungen
- Nicht zaehlen: befristete Vertraege, Aushilfen, Pensionierungen und Kuendigungen durch Trafag
- Nenner: durchschnittlicher Headcount, nicht FTE
- Monat: Austritte des Monats / Headcount des Monats
- Quartal: Austritte des Quartals / durchschnittlicher Headcount des Quartals
- Jahreshochrechnung: aktuelle Quartals-Fluktuation x 4
- Effektives Jahr: Austritte des Jahres / durchschnittlicher Headcount des Jahres
## Geaenderte Dateien
### `rexx_ausgeschieden.txt`
Die bestehende Power-Query fuer `C:\temp\Personalausgeschieden.xlsx` wurde erweitert.
Neu bzw. angepasst:
- robuste Umwandlung von `Austrittsdatum` und `Eintrittsdatum`
- Date
- DateTime
- Excel-Seriennummer, z.B. `45396.0`
- Text im Format `dd.MM.yyyy`
- Normalisierung von `Austrittsart`
- Kleinbuchstaben
- Umlaute nach ASCII, z.B. `kuendigung`
- neue fachliche Spalten:
- `Austrittsart_Normalisiert`
- `Ist_Arbeitnehmerkuendigung`
- `Ist_Fluktuation_Ausgeschlossen`
- `Ist_Fluktuationsrelevant`
- `Fluktuation_Ausschlussgrund`
Wichtig: `Kündigung AN` aus Rexx wird jetzt als Arbeitnehmerkuendigung erkannt.
### `fluktuation_measures_dax.txt`
Neues DAX-File fuer die Fluktuations-Measures.
Tabellenreferenzen wurden auf `HR_KPI_DATEN_SAP` gesetzt.
Enthaltene Measures:
- `Headcount Festangestellt`
- `Headcount Aktiv Total`
- `Austritte Total Rexx`
- `Austritte Arbeitnehmerkuendigung`
- `Austritte Fluktuationsrelevant`
- `Austritte Nicht Fluktuationsrelevant`
- `Fluktuation Monat %`
- `Avg Headcount Quartal`
- `Austritte Quartal`
- `Fluktuation Quartal %`
- `Fluktuation Hochrechnung Jahr %`
- `Avg Headcount Jahr`
- `Austritte Jahr`
- `Fluktuation Jahr Effektiv %`
- `Fluktuation Ausschlussgrund Anzahl`
Die Austritts-Measures verwenden `TREATAS` auf `Rexx_Ausgeschieden[Austrittsmonat]`, damit die Filterung ueber `HR_KPI_DATEN_SAP[Periode]` auch ohne direkte Beziehung funktionieren kann.
## Konsolenpruefung der Rexx-Datei
Gepruefte Datei:
```text
C:\temp\Personalausgeschieden.xlsx
```
Gefundene Austritte:
```text
104 total
42 Kuendigung AN
34 Kuendigung AG
15 Befristung
7 leer
5 Ruhestand
1 Aufhebungsvertrag
```
Nach der korrigierten Logik:
```text
33 fluktuationsrelevante Austritte
```
Die Differenz zu 42 `Kuendigung AN` entsteht, weil Aushilfen, Praktikanten, Werkstudenten und Lehrlinge nicht in die Fluktuationsberechnung einfliessen.
## Ursache fuer 0/leere Fluktuation
Die erste Erkennung suchte nach Begriffen wie:
```text
arbeitnehmer
mitarbeiter
eigenkuendigung
kuendigung ma
```
Rexx liefert aber:
```text
Kündigung AN
```
Dadurch war `Ist_Arbeitnehmerkuendigung` ueberall `false`, und die Fluktuations-Measures hatten keinen Zaehler.
## Erwartete Kontrollwerte in Power BI
Nach Aktualisierung der Queries sollten ohne zusaetzliche Filter ungefaehr folgende Werte sichtbar sein:
```text
Austritte Total Rexx = 104
Austritte Arbeitnehmerkuendigung = 42
Austritte Fluktuationsrelevant = 33
```
Wenn `Fluktuation Monat %`, `Fluktuation Quartal %` oder `Fluktuation Jahr Effektiv %` leer bleiben, zuerst diese Punkte pruefen:
- ist `Rexx_Ausgeschieden` geladen?
- heisst die Haupttabelle wirklich `HR_KPI_DATEN_SAP`?
- existieren `HR_KPI_DATEN_SAP[Periode]` und `Rexx_Ausgeschieden[Austrittsmonat]` als Date-Spalten?
- liefert `Headcount Festangestellt` einen Wert groesser 0?
- gibt es aktive Filter auf Jahr, Monat, Organisation oder Kostenstelle?
## Nachtrag: Leere Quartals-/Jahres-Measures
Am 2026-05-12 wurden die DAX-Measures in `fluktuation_measures_dax.txt`
nochmals angepasst, weil folgende Kennzahlen in Power BI leer waren:
- `Austritte Jahr`
- `Austritte Quartal`
- `Fluktuation Hochrechnung Jahr %`
- `Fluktuation Quartal %`
- `BU_Tage_Total`
Wahrscheinliche Ursache:
`HR_KPI_DATEN_SAP[Periode]` wird in `hr_kpi_daten_query.txt` aktuell als
aktueller Monat aus `DateTime.LocalNow()` erzeugt. Dadurch enthalten die
Perioden in der Haupttabelle nicht zwingend dieselben Monate wie
`Rexx_Ausgeschieden[Austrittsmonat]`. Die bisherigen `DATESQTD`- und
`DATESYTD`-Measures konnten deshalb keine passenden Austritte finden und
lieferten leere Werte.
Anpassung in `fluktuation_measures_dax.txt`:
- `Austritte Quartal` rechnet jetzt ueber Quartalsstart und Quartalsende.
- `Austritte Jahr` filtert jetzt ueber das Jahr von `Austrittsmonat`.
- Prozent-Measures sind mit `COALESCE(..., 0)` gegen leere Werte abgesichert.
- Basis-Measures fuer Headcount und Austritte geben ebenfalls `0` statt leer zurueck.
- `BU_Tage_Total`, `NBU_Tage_Total` und `Unfalltage Total` wurden ergaenzt.
Wichtig:
Die `.pbix` wurde weiterhin nicht direkt bearbeitet. Die geaenderten Measures
muessen in Power BI Desktop manuell ersetzt bzw. eingefuegt werden. Falls die
Haupttabelle im Modell nicht `HR_KPI_DATEN_SAP`, sondern z.B. `HR_KPI_Daten`
heisst, muss der Tabellenname in den DAX-Measures entsprechend angepasst werden.
## Power-BI-Datei / PBIX
Die `.pbix`-Datei wurde nicht direkt bearbeitet.
Grund:
- `.pbix` ist kein normales Textprojekt.
- Power-Query-Code und DAX-Measures liegen intern in Power-BI-Modellstrukturen.
- Direktes Bearbeiten kann die Datei beschaedigen.
- Ohne Power BI Desktop, Tabular Editor oder ein `.pbip`-Projekt ist das direkte Patchen riskant und unverhaeltnismaessig.
Empfohlener Weg fuer diese Aenderung:
1. Power BI Desktop oeffnen.
2. Query `Rexx_Ausgeschieden` im Power Query Editor oeffnen.
3. Inhalt durch den aktuellen Code aus `rexx_ausgeschieden.txt` ersetzen.
4. Modell aktualisieren.
5. Nur die geaenderten bzw. benoetigten DAX-Measures aus `fluktuation_measures_dax.txt` ersetzen/einfuegen.
Nicht alle DAX-Measures muessen neu kopiert werden. Zwingend relevant sind vor allem:
- `Headcount Festangestellt`
- `Austritte Fluktuationsrelevant`
- `Avg Headcount Quartal`
- `Austritte Quartal`
- `Avg Headcount Jahr`
- `Austritte Jahr`
Optional als Diagnose:
- `Headcount Aktiv Total`
- `Austritte Total Rexx`
- `Austritte Arbeitnehmerkuendigung`
Falls das Projekt spaeter als `.pbip` statt `.pbix` gespeichert wird, koennen Modell-/Query-Dateien deutlich besser versioniert und direkt angepasst werden.
## Nicht geaenderte Dateien
Nicht angepasst wurden:
- `hr_kpi_daten_query.txt`
- `REXX_aBSENZEN.txt`
- `formeln.docx`
- `HANDOFF_2026-05-11.md`
- `HR_KPI_Formeln_CH.xlsx`
- `infos.txt`
- `infos2.txt`
+440
View File
@@ -0,0 +1,440 @@
# Handoff Power BI HR KPI - 2026-05-11
## Kontext
Ziel ist ein Power-BI-Dashboard fuer HR-KPIs Schweiz. HR soll das Dashboard konsumieren, nicht selbst bauen. Die Daten sollen aus SAP und mehreren Rexx-Exports in Power BI zusammengefuehrt werden.
Der aktuelle Fokus ist Phase 1: Zeit und Absenzen, insbesondere Krankheit, Unfall, Ferien, GLZ/Saldo und Vergleich der Quoten mit SAP.
## Dateien im Projektordner
Arbeitsordner:
```text
C:\Users\koi\source\repos\Ai\Powerbi
```
Vorhandene Dateien:
```text
HR_KPI_Formeln_CH.xlsx
infos.txt
infos2.txt
```
`infos.txt` und `infos2.txt` wurden gelesen. Sie enthalten den bisherigen Chat-/Projektverlauf zum HR-KPI-Dashboard.
## Relevante Exportdateien in C:\temp
In `C:\temp` wurden relevante SAP- und Rexx-Dateien gefunden:
```text
C:\temp\HR_KPI_EXPORT.xlsx
C:\temp\Abwesenheitinstunden.xlsx
C:\temp\Exportkommengehen.xlsx
C:\temp\Saldiperstichdatum.xlsx
C:\temp\Saldistundenferien.xlsx
C:\temp\Personalausgeschieden.xlsx
```
Wichtige Beobachtung: `HR_KPI_EXPORT.xlsx` wirkt wie ein SAP-Live-Export. Die ersten gelesenen Daten waren fuer `Geschäftsjahr = 2026` und `Buchungsperiode = 4`, also April 2026. Fuer den von HR gewuenschten Test Q1/2026 muessen vermutlich SAP-Daten fuer Perioden 1, 2 und 3 exportiert bzw. bereitgestellt werden.
## Vorhandene Formelmappe
Datei:
```text
C:\Users\koi\source\repos\Ai\Powerbi\HR_KPI_Formeln_CH.xlsx
```
Sheets:
```text
KPI_Formeln
Beispielrechnung
PowerBI_Mapping
```
Diese Datei ist aktuell eine Formel-/Beispielmappe, keine Live-Datendatei.
### Inhalt KPI_Formeln
Enthaelt mathematische Definitionen fuer:
- Krankheitstage je Mitarbeitenden
- Krankheitstage gesamt
- Krankheitsquote %
- Unfallquote %
- Gesundheitsbedingte Absenzen %
- Soll-Arbeitszeit %
- FTE
- allgemeine Prozentanteile
Wichtige Formelidee:
```text
Krankheitstage je Mitarbeitenden = Krankheitsstunden / persoenliche Sollzeit pro Tag
Krankheitsquote % = Summe Krankheitsstunden / Summe Soll-Arbeitsstunden * 100
Unfallquote % = (BU-Stunden + NBU-Stunden) / Summe Soll-Arbeitsstunden * 100
Gesundheitsbedingte Absenzen % = (Krankheit + BU + NBU) / Sollstunden * 100
```
### Inhalt Beispielrechnung
Enthaelt Beispielwerte fuer drei Mitarbeitende:
- Vollzeit
- Teilzeit 50%
- 80%
Diese Beispielrechnung dient zum Validieren der Formellogik.
### Inhalt PowerBI_Mapping
Enthaelt Mapping von KPI auf Power-BI-Felder/Measures, z.B.:
- `Rexx_Absenzen[Krankheit_Gesamt_Std]`
- `M_Krankheitstage_Gesamt`
- `M_Gesundheitsbedingte_Absenzen_Prozent`
- `HR_KPI_Daten[FTE]`
## Architekturentscheidung
SAP und Rexx werden nicht in SAP zusammengefuehrt.
Stattdessen:
```text
SAP CSV/XLSX + mehrere Rexx XLSX/CSV + manuelle Excel-Dateien
werden in Power BI ueber Personalnummer/PERNR zusammengefuehrt.
```
### SAP-Report
Der SAP-Report `Z_HR_KPI_CONSOLIDATE` liefert nur SAP-Daten:
- Stammdaten
- Organisation/Kostenstelle
- Beschaeftigungsgrad/FTE
- Ein-/Austritt
- Lohn
- SAP-Abwesenheiten
- Stellenplan
- CSV-/Excel-Export fuer Power BI
Rexx-Daten werden nicht aus SAP extrahiert. Rexx-Felder im SAP-Report sind nur Platzhalter bzw. sollten langfristig eher separat aus Rexx kommen.
### Rexx
Rexx kann nicht alles in einem Export liefern, da pro Export maximal ca. 40 Felder moeglich sind. Daher werden thematische Exports benoetigt:
- Abwesenheiten/Krankheit
- Ferien
- GLZ/Salden/Ueberstunden
- Austritte/Fluktuation
- Pulsumfrage/Zufriedenheit
- ggf. weitere Themen
## Gelesene Spalten aus den Live-/Exportdateien
### C:\temp\HR_KPI_EXPORT.xlsx
Sheet:
```text
Data
```
Dimension:
```text
A1:AK1139
```
Relevante Spalten aus Zeile 1:
```text
Personalnummer
Geschäftsjahr
Buchungsperiode
Buchungskreis
Personalbereich
Personalteilbereich
Kostenstelle
Organisationseinheit
Planstelle
Stellenschlüssel
Mitarbeiterkreis
Abrechnungskreis
Teilzeitkraft
Mitarbeitergruppe
Beschäftigungsgrad %
Vorname
Nachname
Geschlecht
Geburtsdatum
Datum
Datum
Bruttolohn Monat
Krankheitstage gesamt
Krankheit < 60 Tage
Krankheit >= 60 Tage (LZK)
Nichtberufsunfall Tage
Berufsunfall Tage
Soll-Stelle vorhanden
Pulsumfrage Score (Rexx)
MA-Zufriedenheit (Rexx)
Kununu Score
Angelegt am
Uhrzeit
Angelegt von
```
Beispiel aus den ersten Datenzeilen:
```text
Personalnummer 2005, Geschäftsjahr 2026, Buchungsperiode 4
Personalnummer 2010, Geschäftsjahr 2026, Buchungsperiode 4
Personalnummer 2012, Geschäftsjahr 2026, Buchungsperiode 4
```
Hinweis: Fuer Q1/2026 brauchen wir Perioden 1, 2, 3 oder einen Q1-Gesamtexport.
### C:\temp\Abwesenheitinstunden.xlsx
Sheet:
```text
Abwesenheit in Stunden
```
Dimension:
```text
A1:Z253
```
Spalten:
```text
Personalnummer
Foto rund
Nachname, Vorname (Link Personal)
Stelle
Organisation
Leitung j/n
Eintrittsdatum
Personal Status
Krank nicht buchbar angetreten (Stunden Ind.)
Krankheit angetreten (Stunden Ind.)
Krank nicht buchbar angetreten (Stunden)
Krankheit angetreten (Stunden)
Krank nicht buchbar angetreten (Zeitraum)
Krankheit angetreten (Zeitraum)
Krank nicht buchbar ausstehend (Stunden Ind.)
Krankheit ausstehend (Stunden Ind.)
Krank nicht buchbar gebucht gesamt (Stunden)
Krankheit genehmigt (Zeitraum)
Krank nicht buchbar genehmigt (Zeitraum)
Krankheit genehmigt (Stunden)
Krank nicht buchbar genehmigt (Stunden)
Krankheit genehmigt (Stunden Ind.)
Krank nicht buchbar genehmigt (Stunden Ind.)
Krankheit gebucht gesamt (Stunden)
Krankheit gebucht gesamt (Stunden Ind.)
Krank nicht buchbar gebucht gesamt (Stunden Ind.)
```
Wichtig: Rexx liefert Krankheit in Stunden. Laut Projektentscheidung ist Rexx fuer Krankheit/Absenzen fuehrend, weil SAP nur Tage liefert.
### C:\temp\Exportkommengehen.xlsx
Sheet:
```text
Export KOMMENGEHEN
```
Dimension:
```text
A1:O253
```
Spalten:
```text
Sozialversicherungsnummer
Nachname, Vorname (Link Personal)
Geburtsdatum
Personal Status
Stelle
Organisation
Arbeitszeitmodell
Arbeitszeit
Ø tägliche Sollarbeitszeit (Woche)
Arbeitszeit Mo.
Arbeitszeit Di.
Arbeitszeit Mi.
Arbeitszeit Do.
Arbeitszeit Fr.
Arbeitszeit Sa.
```
Wichtig: Dieser Export enthaelt keine Personalnummer, sondern Sozialversicherungsnummer und Namen. Fuer stabile Power-BI-Joins ist Personalnummer besser. Falls moeglich, Rexx-Export um `Personalnummer` ergaenzen.
### C:\temp\Saldiperstichdatum.xlsx
Sheet:
```text
Saldi per Stichdatum
```
Dimension:
```text
A1:Q253
```
Spalten:
```text
Personalnummer
Kürzel
Nachname, Vorname (Link Personal)
Stelle
Organisation
Kostenstelle
Leitung j/n
Eintrittsdatum
Personal Status
Anstellungsverhältnis
Stunden Saldo
Urlaubsanspruch
Urlaub Rest
Ferien ausstehend (Tage)
Lohnart
Lohn
Lohn Währung
```
Dieser Export ist wichtig fuer:
- GLZ/Stunden Saldo
- Ferienanspruch
- Urlaub Rest
- Ferien ausstehend
- ggf. Lohn aus Rexx, wobei SAP fuer Lohn fuehrend/sensibler sein sollte
## Fachliche Entscheidungen aus den TXT-Dateien
- HR soll das Dashboard konsumieren, nicht bauen.
- Phase 1 ist Zeit und Absenzen.
- Rexx ist fuer Krankheit/Absenzen fuehrend.
- SAP liefert ergaenzende Basisdaten, die Rexx nicht sauber oder nicht vollstaendig liefert.
- Kununu wird manuell gepflegt.
- Refline/Time-to-hire ist spaeteres Thema.
- Rexx Scheduled Reports wurden gesucht, aber offenbar nicht gefunden/freigeschaltet.
- Manuelles Ablegen der Exports in einem Ordner ist fuer den Start akzeptabel.
## Welche Daten nur aus SAP kommen
Diese Felder sind wichtig und kommen eher aus SAP als aus Rexx:
- Geschlecht
- Beschaeftigungsgrad %
- FTE-Grundlage
- Mitarbeitergruppe/Mitarbeiterkreis
- Planstelle/Stellenschluessel
- Soll-Stelle vorhanden
- Bruttolohn Monat
- SAP-Abwesenheiten fuer Vergleich
- BU-/NBU-Tage, falls nicht in Rexx als Stunden vorhanden
## Welche Daten aus Rexx kommen
Rexx ist wichtig fuer:
- Krankheit in Stunden
- Krankheit genehmigt/gebucht/angetreten/ausstehend
- GLZ/Stunden Saldo
- Ferienanspruch
- Urlaub Rest
- Ferien ausstehend
- Organisation/Kostenstelle aus Rexx fuer operative Sicht
- ggf. Pulsumfrage/Zufriedenheit
## Q1/2026-Test mit Live-Daten
HR-Anfrage:
> Waere es moeglich, dass wir mit den Live Daten das Q1/2026 testen koennten? Dann koennten wir die Quoten mit jenen aus dem SAP vergleichen um zu pruefen, ob wir die identischen Formeln haben.
Interpretation:
Es soll mit Live-Daten fuer Q1/2026 gerechnet werden:
```text
Januar 2026
Februar 2026
Maerz 2026
```
Dann sollen die berechneten Quoten gegen SAP verglichen werden.
### Noetige Daten fuer Q1/2026
Aus SAP:
- Export fuer Perioden 1, 2, 3 im Jahr 2026
- oder ein Export, der Q1 kumuliert enthaelt
Aus Rexx:
- Abwesenheit/Krankheit mit Zeitraum Q1/2026
- Sollzeit pro Mitarbeitenden fuer Q1/2026
- optional BU/NBU, falls Rexx das liefert
- GLZ/Ferien nur falls fuer den Q1-Vergleich benoetigt
### Kritischer Punkt
Die aktuell gefundene SAP-Datei `C:\temp\HR_KPI_EXPORT.xlsx` zeigt in den ersten Datenzeilen Periode 4/2026. Das reicht nicht fuer Q1/2026.
Naechster sinnvoller Schritt:
1. SAP-Report fuer Q1/2026 laufen lassen, also Perioden 1 bis 3 oder Q1-Gesamt.
2. Rexx-Abwesenheitsexport ebenfalls fuer Q1/2026 ziehen.
3. Power-BI-/Excel-Testdatei erstellen, die beide Quellen ueber Personalnummer verbindet.
4. Quoten berechnen:
- Krankheitsquote %
- Unfallquote %
- Gesundheitsbedingte Absenzen %
- Krankheitstage gesamt
5. Ergebnis mit SAP-Quote vergleichen.
## Technische Hinweise aus dieser Session
Excel-Dateien wurden nicht ueber Excel-GUI bearbeitet, sondern als XLSX-Zip/XML gelesen. Das vermeidet Dateisperren.
Ein Versuch mit Excel-COM hing und wurde abgebrochen. Danach wurde ein unsichtbarer Excel-Prozess geprueft. Beim Beenden war der Prozess bereits nicht mehr vorhanden. Es wurden keine Excel-Dateien gespeichert oder veraendert.
In dieser Session wurde bis zur Erstellung dieser Handoff-Datei keine vorhandene Excel-Datei manipuliert.
## Empfohlene Antwort an HR
Vorschlag:
```text
Ja, das koennen wir machen. Fuer den Q1/2026-Test brauche ich die Live-Exports fuer Januar bis Maerz 2026 bzw. einen Q1-Gesamtexport aus SAP sowie den passenden Rexx-Abwesenheitsexport fuer denselben Zeitraum. Dann berechne ich die Quoten mit unserer Formel und stelle sie den SAP-Werten gegenueber, damit wir Abweichungen in Definition oder Zeitraum sofort sehen.
```
## Offene Punkte
- SAP-Q1/2026-Datei fehlt noch oder wurde noch nicht gefunden.
- Rexx-Q1/2026-Abwesenheitsexport muss bereitgestellt werden.
- Klaeren, ob `Exportkommengehen.xlsx` kuenftig Personalnummer enthalten kann. Ohne Personalnummer ist der Join unsauber.
- Klaeren, ob BU/NBU in Rexx als Stunden verfuegbar ist oder fuer Unfallquote weiter aus SAP-Tagen umgerechnet werden muss.
- Definieren, ob Q1-Quote mit Sollstunden aus Kalender/SAP oder Rexx-Sollarbeitszeit gerechnet wird.
- Sicherstellen, dass alle Dateien denselben Zeitraum abdecken.
+85
View File
@@ -0,0 +1,85 @@
// ============================================================
// QUERY 2: Rexx_Absenzen
// ============================================================
// Quelle: Abwesenheitinstunden.xlsx (Rexx #744)
// Krankheitsstunden Detail → fuehrend statt SAP-Tage
// ============================================================
let
Source = Excel.Workbook(File.Contents("C:\temp\Abwesenheitinstunden.xlsx"), null, true),
Data = Source{0}[Data],
Head = Table.PromoteHeaders(Data, [PromoteAllScalars = true]),
Sel = Table.SelectColumns(Head, {
"Personalnummer",
"Nachname, Vorname (Link Personal)",
"Stelle", "Organisation", "Leitung j/n", "Personal Status",
"Krankheit angetreten (Stunden Ind.)",
"Krank nicht buchbar angetreten (Stunden Ind.)",
"Krankheit angetreten (Zeitraum)",
"Krank nicht buchbar angetreten (Zeitraum)",
"Krankheit genehmigt (Stunden Ind.)",
"Krank nicht buchbar genehmigt (Stunden Ind.)",
"Krankheit gebucht gesamt (Stunden Ind.)",
"Krank nicht buchbar gebucht gesamt (Stunden Ind.)"
}),
Ren = Table.RenameColumns(Sel, {
{"Personalnummer", "PERNR_Text"},
{"Nachname, Vorname (Link Personal)", "Name"},
{"Stelle", "Stelle"},
{"Organisation", "Organisation"},
{"Leitung j/n", "Leitung"},
{"Personal Status", "Status"},
{"Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std"},
{"Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std"},
{"Krankheit angetreten (Zeitraum)", "Krankheit_Kurz_Zeitraum"},
{"Krank nicht buchbar angetreten (Zeitraum)", "Krankheit_Lang_Zeitraum"},
{"Krankheit genehmigt (Stunden Ind.)", "Krankheit_Kurz_Genehmigt"},
{"Krank nicht buchbar genehmigt (Stunden Ind.)", "Krankheit_Lang_Genehmigt"},
{"Krankheit gebucht gesamt (Stunden Ind.)", "Krankheit_Kurz_Gebucht"},
{"Krank nicht buchbar gebucht gesamt (Stunden Ind.)", "Krankheit_Lang_Gebucht"}
}),
AddPernr = Table.AddColumn(Ren, "Personalnummer", each
try Number.FromText(Text.Trim(Text.From([PERNR_Text]))) otherwise null, Int64.Type),
SetTypes = Table.TransformColumnTypes(AddPernr, {
{"Krankheit_Kurz_Std", type number},
{"Krankheit_Lang_Std", type number},
{"Krankheit_Kurz_Genehmigt", type number},
{"Krankheit_Lang_Genehmigt", type number},
{"Krankheit_Kurz_Gebucht", type number},
{"Krankheit_Lang_Gebucht", type number}
}),
// Gesamt-Krankheitsstunden
AddGes = Table.AddColumn(SetTypes, "Krankheit_Gesamt_Std", each
(if [Krankheit_Kurz_Std] = null then 0 else [Krankheit_Kurz_Std]) +
(if [Krankheit_Lang_Std] = null then 0 else [Krankheit_Lang_Std]),
type number),
// Krankheitstage (Stunden / 8.4h pro Tag)
AddKTG = Table.AddColumn(AddGes, "Krankheitstage_Gesamt", each
Number.Round([Krankheit_Gesamt_Std] / 8.4, 1), type number),
AddKTK = Table.AddColumn(AddKTG, "Krankheitstage_Kurz", each
let s = if [Krankheit_Kurz_Std] = null then 0 else [Krankheit_Kurz_Std] in
Number.Round(s / 8.4, 1), type number),
AddKTL = Table.AddColumn(AddKTK, "Krankheitstage_Lang", each
let s = if [Krankheit_Lang_Std] = null then 0 else [Krankheit_Lang_Std] in
Number.Round(s / 8.4, 1), type number),
// Krankenquote (kompatibel, Basis 21 Tage)
AddKQ = Table.AddColumn(AddKTL, "Krankenquote_MA", each
if [Krankheitstage_Gesamt] = 0 then 0 else [Krankheitstage_Gesamt] / 21, type number),
AddKQO = Table.AddColumn(AddKQ, "Krankenquote_ohne_LZK", each
if [Krankheitstage_Kurz] = 0 then 0 else [Krankheitstage_Kurz] / 21, type number),
AddAbs = Table.AddColumn(AddKQO, "Absenztage_Total", each [Krankheitstage_Gesamt], type number),
Filter = Table.SelectRows(AddAbs, each [Status] = "Aktiv"),
Clean = Table.RemoveColumns(Filter, {"PERNR_Text"}),
Reorder = Table.ReorderColumns(Clean, {"Personalnummer"} &
List.RemoveItems(Table.ColumnNames(Clean), {"Personalnummer"}))
in
Reorder
+5
View File
@@ -0,0 +1,5 @@
Datenbeschaffung:
SAP Daten kommen von Programm Z_HR_KPI_CONS
Rexx Daten aus Rexx export mit Exportprofil SAP_EXPORT(#711) exporieren nach Excel erstell sapexport.xlss
+198
View File
@@ -0,0 +1,198 @@
// ============================================================
// DAX MEASURES: Fluktuation gemaess formeln.docx
// ============================================================
// Voraussetzung:
// - Query/Tabelle: HR_KPI_DATEN_SAP mit aktiven Mitarbeitenden
// - Query/Tabelle: Rexx_Ausgeschieden aus rexx_ausgeschieden.txt
// - Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] filtert:
// nur Arbeitnehmerkuendigungen, keine Aushilfen/Praktikanten/
// Werkstudenten/Lehrlinge, keine Pensionierungen, keine befristeten
// Vertraege, keine Kuendigungen durch Trafag.
//
// Hinweis:
// HR_KPI_DATEN_SAP ist aktuell eine Stichtags-/Monatstabelle. Falls spaeter
// echte Monats-Snapshots geladen werden, funktionieren die Durchschnitts-
// Headcount-Measures ueber Monat/Quartal/Jahr genauer.
// ============================================================
Headcount Festangestellt =
COALESCE(
CALCULATE(
DISTINCTCOUNT(HR_KPI_DATEN_SAP[Personalnummer]),
HR_KPI_DATEN_SAP[Ist_Aktiv] = TRUE(),
HR_KPI_DATEN_SAP[Mitarbeitertyp] = "Festangestellt"
),
0
)
Headcount Aktiv Total =
COALESCE(
CALCULATE(
DISTINCTCOUNT(HR_KPI_DATEN_SAP[Personalnummer]),
HR_KPI_DATEN_SAP[Ist_Aktiv] = TRUE()
),
0
)
Austritte Total Rexx =
COALESCE(DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]), 0)
Austritte Arbeitnehmerkuendigung =
COALESCE(
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Arbeitnehmerkuendigung] = TRUE()
),
0
)
Austritte Fluktuationsrelevant =
VAR Perioden = VALUES(HR_KPI_DATEN_SAP[Periode])
VAR Basis =
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE()
)
VAR NachPeriode =
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
TREATAS(Perioden, Rexx_Ausgeschieden[Austrittsmonat])
)
RETURN
COALESCE(
IF(
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) || ISINSCOPE(HR_KPI_DATEN_SAP[Periode]),
NachPeriode,
Basis
),
0
)
Austritte Nicht Fluktuationsrelevant =
COALESCE(
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = FALSE()
),
0
)
Fluktuation Monat % =
COALESCE(
DIVIDE(
[Austritte Fluktuationsrelevant],
[Headcount Festangestellt]
),
0
)
Avg Headcount Quartal =
COALESCE(
AVERAGEX(
VALUES(HR_KPI_DATEN_SAP[Periode]),
[Headcount Festangestellt]
),
[Headcount Festangestellt],
0
)
Austritte Quartal =
VAR HatPeriodenfilter =
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) ||
ISFILTERED(HR_KPI_DATEN_SAP[Jahr]) ||
ISFILTERED(HR_KPI_DATEN_SAP[Monat])
VAR Auswertungsdatum =
IF(
HatPeriodenfilter,
MAX(HR_KPI_DATEN_SAP[Periode]),
MAX(Rexx_Ausgeschieden[Austrittsmonat])
)
VAR QuartalsStart =
DATE(
YEAR(Auswertungsdatum),
1 + 3 * QUOTIENT(MONTH(Auswertungsdatum) - 1, 3),
1
)
VAR QuartalsEnde = EOMONTH(QuartalsStart, 2)
RETURN
COALESCE(
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
FILTER(
ALL(Rexx_Ausgeschieden[Austrittsmonat]),
Rexx_Ausgeschieden[Austrittsmonat] >= QuartalsStart &&
Rexx_Ausgeschieden[Austrittsmonat] <= QuartalsEnde
)
),
0
)
Fluktuation Quartal % =
COALESCE(
DIVIDE(
[Austritte Quartal],
[Avg Headcount Quartal]
),
0
)
Fluktuation Hochrechnung Jahr % =
COALESCE([Fluktuation Quartal %] * 4, 0)
Avg Headcount Jahr =
COALESCE(
AVERAGEX(
VALUES(HR_KPI_DATEN_SAP[Periode]),
[Headcount Festangestellt]
),
[Headcount Festangestellt],
0
)
Austritte Jahr =
VAR HatPeriodenfilter =
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) ||
ISFILTERED(HR_KPI_DATEN_SAP[Jahr]) ||
ISFILTERED(HR_KPI_DATEN_SAP[Monat])
VAR Auswertungsdatum =
IF(
HatPeriodenfilter,
MAX(HR_KPI_DATEN_SAP[Periode]),
MAX(Rexx_Ausgeschieden[Austrittsmonat])
)
VAR Auswertungsjahr = YEAR(Auswertungsdatum)
RETURN
COALESCE(
CALCULATE(
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
FILTER(
ALL(Rexx_Ausgeschieden[Austrittsmonat]),
YEAR(Rexx_Ausgeschieden[Austrittsmonat]) = Auswertungsjahr
)
),
0
)
Fluktuation Jahr Effektiv % =
COALESCE(
DIVIDE(
[Austritte Jahr],
[Avg Headcount Jahr]
),
0
)
BU_Tage_Total =
COALESCE(SUM(HR_KPI_DATEN_SAP[BU_Tage]), 0)
NBU_Tage_Total =
COALESCE(SUM(HR_KPI_DATEN_SAP[NBU_Tage]), 0)
Unfalltage Total =
[BU_Tage_Total] + [NBU_Tage_Total]
Fluktuation Ausschlussgrund Anzahl =
COUNTROWS(Rexx_Ausgeschieden)
Binary file not shown.
+304
View File
@@ -0,0 +1,304 @@
let
// ===== REXX #757 LADEN =====
Src757 = Excel.Workbook(File.Contents("C:\temp\Saldiperstichdatum.xlsx"), null, true),
Data757 = Src757{0}[Data],
Head757 = Table.PromoteHeaders(Data757, [PromoteAllScalars = true]),
Ren757 = Table.RenameColumns(Head757, {
{"Personalnummer", "Personalnummer"},
{"Kürzel", "Kuerzel"},
{"Nachname, Vorname (Link Personal)", "Name_Rexx"},
{"Stelle", "Stelle_Rexx"},
{"Organisation", "Organisation_Text"},
{"Kostenstelle", "Kostenstelle_Rexx"},
{"Leitung j/n", "Leitung"},
{"Eintrittsdatum", "Eintrittsdatum_Raw"},
{"Personal Status", "Personal_Status"},
{"Anstellungsverhältnis", "Anstellungsverhaeltnis"},
{"Stunden Saldo", "Stunden_Saldo_Raw"},
{"Urlaubsanspruch", "Urlaubsanspruch_Raw"},
{"Urlaub Rest", "Urlaub_Rest_Raw"},
{"Ferien ausstehend (Tage)", "Ferien_Ausstehend_Raw"},
{"Lohnart", "Lohnart"},
{"Lohn", "Lohn_Raw"},
{"Lohn Währung", "Lohn_Waehrung"}
}),
TypePernr = Table.TransformColumnTypes(Ren757, {{"Personalnummer", Int64.Type}}),
AddKey = Table.AddColumn(TypePernr, "PERNR_Key", each Text.From([Personalnummer]), type text),
// Name_Voll früh erzeugen für Name-Join mit #732
AddNameVollEarly = Table.AddColumn(AddKey, "Name_Voll", each
Text.From(if [Name_Rexx] = null then "" else [Name_Rexx]), type text),
// ===== REXX #732 LADEN (Geburtsdatum + AZ-Modell) =====
// Hat keine Personalnummer → Join über Name
Tbl732 = try
let
S = Excel.Workbook(File.Contents("C:\temp\Exportkommengehen.xlsx"), null, true),
D = S{0}[Data],
H = Table.PromoteHeaders(D, [PromoteAllScalars = true]),
Sel = Table.SelectColumns(H, {
"Nachname, Vorname (Link Personal)",
"Geburtsdatum",
"Arbeitszeitmodell",
"Ø tägliche Sollarbeitszeit (Woche)"
}, MissingField.UseNull),
Ren = Table.RenameColumns(Sel, {
{"Nachname, Vorname (Link Personal)", "Name_732"},
{"Geburtsdatum", "Geburtsdatum_Raw"},
{"Arbeitszeitmodell", "Arbeitszeitmodell"},
{"Ø tägliche Sollarbeitszeit (Woche)", "Avg_Sollzeit_Tag_Raw"}
})
in Ren
otherwise null,
// Merge #757 + #732 ueber Name
Merged732 = if Tbl732 <> null then
let
M = Table.NestedJoin(AddNameVollEarly, {"Name_Voll"}, Tbl732, {"Name_732"}, "R732", JoinKind.LeftOuter),
E = Table.ExpandTableColumn(M, "R732", {"Geburtsdatum_Raw", "Arbeitszeitmodell", "Avg_Sollzeit_Tag_Raw"})
in E
else
let
A1 = Table.AddColumn(AddNameVollEarly, "Geburtsdatum_Raw", each null),
A2 = Table.AddColumn(A1, "Arbeitszeitmodell", each null, type text),
A3 = Table.AddColumn(A2, "Avg_Sollzeit_Tag_Raw", each null)
in A3,
// ===== SAP LADEN (HR_KPI_Export.xlsx) =====
TblSAP = try
let
S = Excel.Workbook(File.Contents("C:\temp\HR_KPI_Export.xlsx"), null, true),
D = S{0}[Data],
H = Table.PromoteHeaders(D, [PromoteAllScalars = true]),
Sel = Table.SelectColumns(H, {
"Personalnummer", "Buchungskreis", "Personalbereich", "Personalteilbereich",
"Mitarbeitergruppe", "Mitarbeiterkreis", "Teilzeitkraft",
"Beschäftigungsgrad %", "Geschlecht", "Planstelle", "Stellenschlüssel",
"Nichtberufsunfall Tage", "Berufsunfall Tage",
"Abrechnungskreis"
}, MissingField.UseNull),
Ren = Table.RenameColumns(Sel, {
{"Personalnummer", "PERNR_SAP"},
{"Teilzeitkraft", "Teilzeitkennzeichen"},
{"Beschäftigungsgrad %", "Beschaeftigungsgrad_Prozent"},
{"Stellenschlüssel", "Soll_Stelle"},
{"Nichtberufsunfall Tage", "NBU_Tage"},
{"Berufsunfall Tage", "BU_Tage"}
}, MissingField.Ignore),
// PERNR-Key normalisiert (entfernt fuehrende Nullen)
AddK = Table.AddColumn(Ren, "PERNR_SAP_Key", each
try Text.From(Number.FromText(Text.Trim(Text.From([PERNR_SAP])))) otherwise null,
type text)
in AddK
otherwise null,
// Merge + SAP
MergedSAP = if TblSAP <> null then
let
M = Table.NestedJoin(Merged732, {"PERNR_Key"}, TblSAP, {"PERNR_SAP_Key"}, "SAP", JoinKind.LeftOuter),
E = Table.ExpandTableColumn(M, "SAP", {
"Buchungskreis", "Personalbereich", "Personalteilbereich",
"Mitarbeitergruppe", "Mitarbeiterkreis", "Teilzeitkennzeichen",
"Beschaeftigungsgrad_Prozent", "Geschlecht", "Planstelle", "Soll_Stelle",
"NBU_Tage", "BU_Tage",
"Abrechnungskreis"
})
in E
else
let
A1 = Table.AddColumn(Merged732, "Buchungskreis", each null),
A2 = Table.AddColumn(A1, "Personalbereich", each null),
A3 = Table.AddColumn(A2, "Personalteilbereich", each null),
A4 = Table.AddColumn(A3, "Mitarbeitergruppe", each null),
A5 = Table.AddColumn(A4, "Mitarbeiterkreis", each null),
A6 = Table.AddColumn(A5, "Teilzeitkennzeichen", each null, type text),
A7 = Table.AddColumn(A6, "Beschaeftigungsgrad_Prozent", each null),
A8 = Table.AddColumn(A7, "Geschlecht", each null),
A9 = Table.AddColumn(A8, "Planstelle", each null),
A10 = Table.AddColumn(A9, "Soll_Stelle", each null, type text),
A11 = Table.AddColumn(A10, "NBU_Tage", each null),
A12 = Table.AddColumn(A11, "BU_Tage", each null),
A13 = Table.AddColumn(A12, "Abrechnungskreis", each null, type text)
in A13,
// ===== BERECHNETE SPALTEN =====
// Eintrittsdatum
AddEintritt = Table.AddColumn(MergedSAP, "Eintrittsdatum", each
let raw = [Eintrittsdatum_Raw] in
if raw = null then null
else if raw is date then raw
else if raw is datetime then Date.From(raw)
else try Date.FromText(Text.From(raw), [Format = "dd.MM.yyyy"]) otherwise null,
type date),
// Geburtsdatum
AddGebdat = Table.AddColumn(AddEintritt, "Geburtsdatum", each
let raw = [Geburtsdatum_Raw] in
if raw = null then null
else if raw is date then raw
else if raw is datetime then Date.From(raw)
else try Date.FromText(Text.From(raw), [Format = "dd.MM.yyyy"]) otherwise null,
type date),
// Numerische Konvertierungen
AddNum = Table.TransformColumnTypes(AddGebdat, {
{"Urlaubsanspruch_Raw", type number},
{"Urlaub_Rest_Raw", type number},
{"Ferien_Ausstehend_Raw", type number},
{"Lohn_Raw", type number},
{"Avg_Sollzeit_Tag_Raw", type number},
{"Beschaeftigungsgrad_Prozent", type number},
{"Geschlecht", Int64.Type},
{"NBU_Tage", type number},
{"BU_Tage", type number}
}),
RenNum = Table.RenameColumns(AddNum, {
{"Urlaubsanspruch_Raw", "Urlaubsanspruch"},
{"Urlaub_Rest_Raw", "Urlaub_Rest"},
{"Ferien_Ausstehend_Raw", "Ferien_Ausstehend"},
{"Lohn_Raw", "Bruttolohn"},
{"Avg_Sollzeit_Tag_Raw", "Avg_Sollzeit_Tag"}
}),
// Abrechnungskreis als Text sicherstellen (fuehrende Null bleibt erhalten)
TypAbkrs = Table.TransformColumnTypes(RenNum, {{"Abrechnungskreis", type text}}),
// Name splitten
AddNachname = Table.AddColumn(TypAbkrs, "Nachname", each
let n = Text.From(if [Name_Rexx] = null then "" else [Name_Rexx]) in
Text.Trim(Text.Split(n, ","){0}), type text),
AddVorname = Table.AddColumn(AddNachname, "Vorname", each
let n = Text.From(if [Name_Rexx] = null then "" else [Name_Rexx]),
p = Text.Split(n, ",") in
if List.Count(p) > 1 then Text.Trim(p{1}) else "", type text),
// Stunden_Saldo parsen: "58:10" → 58.17, "-6:12" → -6.20
AddSaldo = Table.AddColumn(AddVorname, "Stunden_Saldo", each
let
raw = Text.Trim(Text.From(if [Stunden_Saldo_Raw] = null then "0:00" else [Stunden_Saldo_Raw])),
isNeg = Text.StartsWith(raw, "-"),
cleaned = Text.Replace(raw, "-", ""),
parts = Text.Split(cleaned, ":"),
h = try Number.FromText(parts{0}) otherwise 0,
m = if List.Count(parts) > 1 then (try Number.FromText(parts{1}) otherwise 0) else 0,
dec = h + m / 60
in if isNeg then -dec else dec,
type number),
// FTE
AddFTE = Table.AddColumn(AddSaldo, "FTE", each
if [Beschaeftigungsgrad_Prozent] <> null and [Beschaeftigungsgrad_Prozent] > 0
then [Beschaeftigungsgrad_Prozent] / 100
else if [Arbeitszeitmodell] = "Vollzeit" then 1 else 0.5,
type number),
// Alter
AddAlter = Table.AddColumn(AddFTE, "Alter_Jahre", each
if [Geburtsdatum] = null then null
else Number.RoundDown(Duration.TotalDays(Date.From(DateTime.LocalNow()) - [Geburtsdatum]) / 365.25),
Int64.Type),
// Altersgruppe
AddAG = Table.AddColumn(AddAlter, "Altersgruppe", each
if [Alter_Jahre] = null then "Unbekannt"
else if [Alter_Jahre] < 30 then "< 30"
else if [Alter_Jahre] < 40 then "30-39"
else if [Alter_Jahre] < 50 then "40-49"
else if [Alter_Jahre] < 60 then "50-59"
else "60+", type text),
// Geschlecht Text
AddGT = Table.AddColumn(AddAG, "Geschlecht_Text", each
if [Geschlecht] = 1 then "Maennlich"
else if [Geschlecht] = 2 then "Weiblich"
else "Unbekannt", type text),
// Ist Teilzeit
AddTZ = Table.AddColumn(AddGT, "Ist_Teilzeit", each
if [Beschaeftigungsgrad_Prozent] <> null and [Beschaeftigungsgrad_Prozent] > 0
then [Beschaeftigungsgrad_Prozent] < 100
else if [Arbeitszeitmodell] <> null then [Arbeitszeitmodell] = "Teilzeit"
else false, type logical),
// Dienstjahre
AddDJ = Table.AddColumn(AddTZ, "Dienstjahre", each
if [Eintrittsdatum] = null then null
else Number.RoundDown(Duration.TotalDays(Date.From(DateTime.LocalNow()) - [Eintrittsdatum]) / 365.25),
Int64.Type),
// Ist Aktiv
AddAktiv = Table.AddColumn(AddDJ, "Ist_Aktiv", each [Personal_Status] = "Aktiv", type logical),
// Periode
AddPeriode = Table.AddColumn(AddAktiv, "Periode", each Date.StartOfMonth(Date.From(DateTime.LocalNow())), type date),
AddJahr = Table.AddColumn(AddPeriode, "Jahr", each Date.Year([Periode]), Int64.Type),
AddMonat = Table.AddColumn(AddJahr, "Monat", each Date.Month([Periode]), Int64.Type),
AddPT = Table.AddColumn(AddMonat, "Periode_Text", each
Text.From([Jahr]) & "-" & Text.PadStart(Text.From([Monat]), 2, "0"), type text),
// Sollarbeitstage
AddSAT = Table.AddColumn(AddPT, "Sollarbeitstage", each 21, Int64.Type),
// Ferien bezogen
AddFB = Table.AddColumn(AddSAT, "Ferien_Bezogen", each
let a = if [Urlaubsanspruch] = null then 0 else [Urlaubsanspruch],
r = if [Urlaub_Rest] = null then 0 else [Urlaub_Rest],
au = if [Ferien_Ausstehend] = null then 0 else [Ferien_Ausstehend]
in a - r - au, type number),
AddFT = Table.AddColumn(AddFB, "Ferientage", each
let fb = [Ferien_Bezogen] in if fb = null or fb < 0 then 0 else fb, type number),
// GLZ Ampel
AddGLZ = Table.AddColumn(AddFT, "GLZ_Ampel", each
let abs = Number.Abs([Stunden_Saldo]) in
if abs <= 50 then "Gruen" else if abs <= 100 then "Gelb" else "Rot", type text),
AddGLZS = Table.AddColumn(AddGLZ, "GLZ_Ampel_Sort", each
if [GLZ_Ampel] = "Gruen" then 1 else if [GLZ_Ampel] = "Gelb" then 2 else 3, Int64.Type),
// Restferien Ampel
AddRA = Table.AddColumn(AddGLZS, "Restferien_Ampel", each
if [Urlaub_Rest] = null or [Urlaub_Rest] <= 5 then "Gruen" else "Rot", type text),
// Mitarbeitertyp
AddMT = Table.AddColumn(AddRA, "Mitarbeitertyp", each
let s = Text.Lower(Text.From(if [Stelle_Rexx] = null then "" else [Stelle_Rexx])) in
if Text.Contains(s, "praktik") then "Praktikant"
else if Text.Contains(s, "werkstudent") then "Werkstudent"
else if Text.Contains(s, "aushilfe") then "Aushilfe"
else if Text.Contains(s, "lehrling") then "Lehrling"
else "Festangestellt", type text),
// Kostenstelle
AddKNr = Table.AddColumn(AddMT, "Kostenstelle", each
let raw = Text.From(if [Kostenstelle_Rexx] = null then "" else [Kostenstelle_Rexx]),
parts = Text.Split(raw, "/"),
num = Text.Trim(parts{0})
in try Number.FromText(num) otherwise null, Int64.Type),
AddKTxt = Table.AddColumn(AddKNr, "Kostenstelle_Text", each
if [Kostenstelle_Rexx] = null then "" else Text.From([Kostenstelle_Rexx]), type text),
// Organisation + Stelle Klartext
AddOrg = Table.AddColumn(AddKTxt, "Organisationseinheit", each
if [Organisation_Text] = null then "" else Text.From([Organisation_Text]), type text),
AddSt = Table.AddColumn(AddOrg, "Stelle", each
if [Stelle_Rexx] = null then "" else Text.From([Stelle_Rexx]), type text),
// Nur aktive MA
FilterAktiv = Table.SelectRows(AddSt, each [Personal_Status] = "Aktiv"),
// Hilfsspalten entfernen
Clean = Table.RemoveColumns(FilterAktiv, {
"Name_Rexx", "Stelle_Rexx", "Organisation_Text",
"Kostenstelle_Rexx", "Eintrittsdatum_Raw", "Geburtsdatum_Raw",
"Stunden_Saldo_Raw", "Personal_Status", "PERNR_Key"
}),
// Sortieren
Sorted = Table.Sort(Clean, {{"Personalnummer", Order.Ascending}})
in
Sorted
+118
View File
@@ -0,0 +1,118 @@
let
Source = Excel.Workbook(File.Contents("C:\temp\Personalausgeschieden.xlsx"), null, true),
Data = Source{0}[Data],
Head = Table.PromoteHeaders(Data, [PromoteAllScalars = true]),
Ren = Table.RenameColumns(Head, {
{"Personalnummer", "PERNR_Text"},
{"Nachname, Vorname (Link Personal)", "Name_Voll"},
{"Stelle-1", "Stelle"},
{"Organisation-1", "Organisationseinheit"},
{"Leitung j/n", "Leitung"},
{"Austrittsdatum", "Austrittsdatum_Raw"},
{"Austrittsart", "Austrittsart"},
{"Eintrittsdatum", "Eintrittsdatum_Raw"},
{"Personal Status", "Status"}
}),
AddPernr = Table.AddColumn(Ren, "Personalnummer", each
try Number.FromText(Text.Trim(Text.From([PERNR_Text]))) otherwise null, Int64.Type),
// Datumsfelder (Excel liefert evtl. schon als Date)
AddAustritt = Table.AddColumn(AddPernr, "Austrittsdatum", each
let raw = [Austrittsdatum_Raw] in
if raw is date then raw
else if raw is datetime then Date.From(raw)
else if raw is number then Date.AddDays(#date(1899, 12, 30), Number.RoundDown(raw))
else if (try Number.FromText(Text.From(raw)) otherwise null) <> null then
Date.AddDays(#date(1899, 12, 30), Number.RoundDown(Number.FromText(Text.From(raw))))
else try Date.FromText(Text.From(raw), [Format = "dd.MM.yyyy"]) otherwise null,
type date),
AddEintritt = Table.AddColumn(AddAustritt, "Eintrittsdatum", each
let raw = [Eintrittsdatum_Raw] in
if raw is date then raw
else if raw is datetime then Date.From(raw)
else if raw is number then Date.AddDays(#date(1899, 12, 30), Number.RoundDown(raw))
else if (try Number.FromText(Text.From(raw)) otherwise null) <> null then
Date.AddDays(#date(1899, 12, 30), Number.RoundDown(Number.FromText(Text.From(raw))))
else try Date.FromText(Text.From(raw), [Format = "dd.MM.yyyy"]) otherwise null,
type date),
// Verweildauer
AddVW = Table.AddColumn(AddEintritt, "Verweildauer_Monate", each
try Number.Round(Duration.TotalDays([Austrittsdatum] - [Eintrittsdatum]) / 30.44, 1)
otherwise null, type number),
// Mitarbeitertyp
AddMT = Table.AddColumn(AddVW, "Mitarbeitertyp", each
let s = Text.Lower(Text.From(if [Stelle] = null then "" else [Stelle])) in
if Text.Contains(s, "praktik") then "Praktikant"
else if Text.Contains(s, "werkstudent") then "Werkstudent"
else if Text.Contains(s, "aushilfe") then "Aushilfe"
else if Text.Contains(s, "lehrling") then "Lehrling"
else "Festangestellt", type text),
// Fluktuationslogik gemaess HR-Definition aus formeln.docx:
// Zaehlen nur Arbeitnehmerkuendigungen von festangestellten Mitarbeitenden.
// Nicht zaehlen: Aushilfen, Praktikanten, Werkstudenten, Lehrlinge,
// befristete Vertraege, Pensionierungen und Kuendigungen durch Trafag.
AddAustrittsartNorm = Table.AddColumn(AddMT, "Austrittsart_Normalisiert", each
let
raw = Text.Lower(Text.Trim(Text.From(if [Austrittsart] = null then "" else [Austrittsart]))),
ae = Text.Replace(raw, Character.FromNumber(228), "ae"),
oe = Text.Replace(ae, Character.FromNumber(246), "oe"),
ue = Text.Replace(oe, Character.FromNumber(252), "ue"),
ss = Text.Replace(ue, Character.FromNumber(223), "ss")
in ss,
type text),
AddIstArbeitnehmerkuendigung = Table.AddColumn(AddAustrittsartNorm, "Ist_Arbeitnehmerkuendigung", each
let a = [Austrittsart_Normalisiert] in
Text.Contains(a, "arbeitnehmer") or
Text.Contains(a, "mitarbeiter") or
Text.Contains(a, "kuendigung an") or
Text.Contains(a, "an kuendigung") or
Text.Contains(a, "eigenkuendigung") or
Text.Contains(a, "kuendigung ma") or
Text.Contains(a, "kuendigung durch ma"),
type logical),
AddIstAusgeschlossen = Table.AddColumn(AddIstArbeitnehmerkuendigung, "Ist_Fluktuation_Ausgeschlossen", each
let a = [Austrittsart_Normalisiert] in
[Mitarbeitertyp] <> "Festangestellt" or
Text.Contains(a, "befrist") or
Text.Contains(a, "pension") or
Text.Contains(a, "rente") or
Text.Contains(a, "trafag") or
Text.Contains(a, "arbeitgeber") or
Text.Contains(a, "ag-kuendigung") or
Text.Contains(a, "ag kuendigung") or
Text.Contains(a, "kuendigung ag"),
type logical),
AddFluktuationRelevant = Table.AddColumn(AddIstAusgeschlossen, "Ist_Fluktuationsrelevant", each
[Ist_Arbeitnehmerkuendigung] = true and [Ist_Fluktuation_Ausgeschlossen] = false,
type logical),
AddAusschlussgrund = Table.AddColumn(AddFluktuationRelevant, "Fluktuation_Ausschlussgrund", each
let a = [Austrittsart_Normalisiert] in
if [Ist_Fluktuationsrelevant] then null
else if [Mitarbeitertyp] <> "Festangestellt" then [Mitarbeitertyp]
else if Text.Trim(a) = "" then "Austrittsart leer/unklar"
else if Text.Contains(a, "befrist") then "Befristeter Vertrag"
else if Text.Contains(a, "pension") or Text.Contains(a, "rente") then "Pensionierung"
else if Text.Contains(a, "trafag") or Text.Contains(a, "arbeitgeber") or Text.Contains(a, "ag-kuendigung") or Text.Contains(a, "ag kuendigung") or Text.Contains(a, "kuendigung ag") then "Kuendigung durch Trafag"
else if [Ist_Arbeitnehmerkuendigung] = false then "Keine Arbeitnehmerkuendigung"
else "Ausgeschlossen",
type text),
AddAM = Table.AddColumn(AddAusschlussgrund, "Austrittsmonat", each Date.StartOfMonth([Austrittsdatum]), type date),
AddAJ = Table.AddColumn(AddAM, "Austrittsjahr", each Date.Year([Austrittsdatum]), Int64.Type),
AddNN = Table.AddColumn(AddAJ, "Nachname", each
Text.Trim(Text.Split(Text.From([Name_Voll]), ","){0}), type text),
AddVN = Table.AddColumn(AddNN, "Vorname", each
let p = Text.Split(Text.From([Name_Voll]), ",") in
if List.Count(p) > 1 then Text.Trim(p{1}) else "", type text),
Clean = Table.RemoveColumns(AddVN, {"PERNR_Text", "Austrittsdatum_Raw", "Eintrittsdatum_Raw"}),
Reorder = Table.ReorderColumns(Clean, {"Personalnummer"} &
List.RemoveItems(Table.ColumnNames(Clean), {"Personalnummer"}))
in
Reorder