diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index e9f6dc9..7c8f471 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,112 @@ 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 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. - 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 Architekturstand: diff --git a/TrafagSalesExporter/Models/CentralSalesRecord.cs b/TrafagSalesExporter/Models/CentralSalesRecord.cs index fc8848b..2d01399 100644 --- a/TrafagSalesExporter/Models/CentralSalesRecord.cs +++ b/TrafagSalesExporter/Models/CentralSalesRecord.cs @@ -42,6 +42,7 @@ public class CentralSalesRecord public string CompanyCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; + public DateTime? PostingDate { get; set; } public DateTime? InvoiceDate { get; set; } public DateTime? OrderDate { get; set; } public string Land { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/Models/SalesRecord.cs b/TrafagSalesExporter/Models/SalesRecord.cs index 30520bc..4ba5696 100644 --- a/TrafagSalesExporter/Models/SalesRecord.cs +++ b/TrafagSalesExporter/Models/SalesRecord.cs @@ -32,6 +32,7 @@ public class SalesRecord public string CompanyCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; + public DateTime? PostingDate { get; set; } public DateTime? InvoiceDate { get; set; } public DateTime? OrderDate { get; set; } public string Land { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 7e1dd8f..f8165c1 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,69 @@ 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 Erledigt: @@ -43,6 +106,23 @@ Naechste fachliche Schritte: - Referenz ist nur zukuenftig relevant 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 Erledigt: diff --git a/TrafagSalesExporter/Services/CentralSalesRecordService.cs b/TrafagSalesExporter/Services/CentralSalesRecordService.cs index e2fb671..77b7037 100644 --- a/TrafagSalesExporter/Services/CentralSalesRecordService.cs +++ b/TrafagSalesExporter/Services/CentralSalesRecordService.cs @@ -92,6 +92,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService CompanyCurrency = r.CompanyCurrency, Incoterms2020 = r.Incoterms2020, SalesResponsibleEmployee = r.SalesResponsibleEmployee, + PostingDate = r.PostingDate, InvoiceDate = r.InvoiceDate, OrderDate = r.OrderDate, Land = r.Land, @@ -167,7 +168,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency, - VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType + VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, PostingDate, InvoiceDate, OrderDate, Land, DocumentType ) VALUES ( $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice, @@ -175,7 +176,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, $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("$incoterms2020", SqliteType.Text); command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text); + command.Parameters.Add("$postingDate", SqliteType.Text); command.Parameters.Add("$invoiceDate", SqliteType.Text); command.Parameters.Add("$orderDate", 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["$incoterms2020"].Value = record.Incoterms2020 ?? 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["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$land"].Value = record.Land ?? string.Empty; diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index b68b67d..48e9f12 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -413,6 +413,7 @@ public class ConfigTransferService : IConfigTransferService CompanyCurrency = record.CompanyCurrency, Incoterms2020 = record.Incoterms2020, SalesResponsibleEmployee = record.SalesResponsibleEmployee, + PostingDate = record.PostingDate, InvoiceDate = record.InvoiceDate, OrderDate = record.OrderDate, Land = record.Land, diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs b/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs index afd9c98..927dc46 100644 --- a/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs +++ b/TrafagSalesExporter/Services/DataSources/DataSourceFetchContext.cs @@ -9,4 +9,5 @@ public sealed class DataSourceFetchContext public required ExportSettings Settings { get; init; } public SharePointConfig? SharePointConfig { get; init; } public Action? UpdateStatus { get; init; } + public int? PreferredImportYear { get; init; } } diff --git a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs index 0fa4a7b..d63208b 100644 --- a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs +++ b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs @@ -31,7 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter string filePath; string? localOutputDirectory = null; string? sharePointUploadFolder = null; - string? tempManualImportPath = null; + var tempManualImportPaths = new List(); try { if (File.Exists(manualImportPath)) @@ -59,19 +59,28 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter siteId: site.Id, land: site.Land, details: manualImportPath); var sharePointFileReference = manualImportPath; + var sharePointFileReferences = new List(); if (LooksLikeSharePointFolderReference(manualImportPath)) { - var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync( + var files = await _sharePointService.ResolveManualImportFilesInFolderAsync( spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, - spConfig.SiteUrl, manualImportPath, site.TSC); - sharePointFileReference = latestFile.FileReference; + spConfig.SiteUrl, manualImportPath, site.TSC, context.PreferredImportYear); + sharePointFileReferences.AddRange(files.Select(file => file.FileReference)); + sharePointFileReference = sharePointFileReferences.FirstOrDefault() ?? manualImportPath; 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( - spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, - spConfig.SiteUrl, sharePointFileReference); + foreach (var fileReference in sharePointFileReferences) + { + tempManualImportPaths.Add(await _sharePointService.DownloadToTempFileAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, + spConfig.SiteUrl, fileReference)); + } filePath = sharePointFileReference; sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl); } @@ -81,12 +90,14 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter $"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}"); } - var readPath = tempManualImportPath ?? filePath; context.UpdateStatus?.Invoke("Manuelle Excel lesen..."); await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land, details: filePath); - var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site); + var records = new List(); + var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath]; + foreach (var readPath in readPaths) + records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site)); return new DataSourceFetchResult { Records = records, @@ -97,8 +108,11 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter } finally { - if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath)) - File.Delete(tempManualImportPath); + foreach (var tempManualImportPath in tempManualImportPaths) + { + if (File.Exists(tempManualImportPath)) + File.Delete(tempManualImportPath); + } } } diff --git a/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs index 263d7ea..2991fbb 100644 --- a/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs +++ b/TrafagSalesExporter/Services/DataSources/SapGatewayDataSourceAdapter.cs @@ -50,7 +50,7 @@ public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); var records = await _sapCompositionService.BuildSalesRecordsAsync( effectiveSite, sapSources, sapJoins, sapMappings, - credentials.Username, credentials.Password); + credentials.Username, credentials.Password, context.PreferredImportYear); return new DataSourceFetchResult { Records = records }; } diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index 280f5e1..ed35fba 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -113,6 +113,7 @@ CREATE TABLE CentralSalesRecords ( CompanyCurrency TEXT NOT NULL DEFAULT '', Incoterms2020 TEXT NOT NULL, SalesResponsibleEmployee TEXT NOT NULL, + PostingDate TEXT NULL, InvoiceDate TEXT NULL, OrderDate TEXT NULL, Land TEXT NOT NULL, diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index f9ded6e..8caaebb 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -51,6 +51,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "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", "PostingDate", "TEXT NULL"); EnsureAppEventLogTable(db); } diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index 1dcb0e1..e8cb8a8 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -308,12 +308,105 @@ public class DatabaseSeedService : IDatabaseSeedService } 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"; 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) db.SaveChanges(); } @@ -386,7 +479,7 @@ public class DatabaseSeedService : IDatabaseSeedService { SiteId = siteId, Alias = "Z", - EntitySet = "ZSCHWEIZSet", + EntitySet = "FinanzdataSchweizOeSet", IsPrimary = true, IsActive = true, SortOrder = 0 @@ -395,9 +488,9 @@ public class DatabaseSeedService : IDatabaseSeedService } else { - if (source.EntitySet != "ZSCHWEIZSet") + if (source.EntitySet != "FinanzdataSchweizOeSet") { - source.EntitySet = "ZSCHWEIZSet"; + source.EntitySet = "FinanzdataSchweizOeSet"; 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)[] { - (nameof(SalesRecord.Tsc), "Z.TSC", true), - (nameof(SalesRecord.Land), "Z.LAND1", true), - (nameof(SalesRecord.DocumentEntry), "Z.VBELN", false), - (nameof(SalesRecord.InvoiceNumber), "Z.VBELN", true), - (nameof(SalesRecord.PositionOnInvoice), "Z.POSNR", true), - (nameof(SalesRecord.InvoiceDate), "Z.FKDAT", true), - (nameof(SalesRecord.Material), "Z.MATNR", false), - (nameof(SalesRecord.Name), "Z.ARKTX", false), - (nameof(SalesRecord.ProductGroup), "Z.PRODH", false), - (nameof(SalesRecord.Quantity), "Z.FKIMG", false), - (nameof(SalesRecord.CustomerNumber), "Z.KUNNR", false), - (nameof(SalesRecord.CustomerName), "Z.NAME1", false), - (nameof(SalesRecord.CustomerCountry), "Z.CUSTOMER_LAND", false), + (nameof(SalesRecord.Tsc), "Z.Tsc", true), + (nameof(SalesRecord.Land), "Z.Land1", true), + (nameof(SalesRecord.DocumentEntry), "Z.Vbeln", false), + (nameof(SalesRecord.InvoiceNumber), "Z.Vbeln", true), + (nameof(SalesRecord.PositionOnInvoice), "Z.Posnr", true), + (nameof(SalesRecord.PostingDate), "Z.Fkdat", true), + (nameof(SalesRecord.InvoiceDate), "Z.Fkdat", true), + (nameof(SalesRecord.Material), "Z.Matnr", false), + (nameof(SalesRecord.Name), "Z.Arktx", false), + (nameof(SalesRecord.ProductGroup), "Z.Prodh", false), + (nameof(SalesRecord.Quantity), "Z.Fkimg", false), + (nameof(SalesRecord.CustomerNumber), "Z.Kunnr", false), + (nameof(SalesRecord.CustomerName), "Z.Name1", false), + (nameof(SalesRecord.CustomerCountry), "Z.CustomerLand", false), (nameof(SalesRecord.StandardCost), "=0", false), - (nameof(SalesRecord.StandardCostCurrency), "Z.HWAER", false), - (nameof(SalesRecord.SalesPriceValue), "Z.NETWR_HC", true), - (nameof(SalesRecord.SalesCurrency), "Z.HWAER", true), - (nameof(SalesRecord.DocumentCurrency), "Z.WAERK", false), - (nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NETWR_DC", false), - (nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NETWR_HC", false), - (nameof(SalesRecord.VatSumForeignCurrency), "Z.TAX_DC", false), - (nameof(SalesRecord.VatSumLocalCurrency), "Z.TAX_HC", false), - (nameof(SalesRecord.DocumentRate), "Z.KURRF", false), - (nameof(SalesRecord.CompanyCurrency), "Z.HWAER", true), - (nameof(SalesRecord.DocumentType), "Z.FKART", false) + (nameof(SalesRecord.StandardCostCurrency), "Z.Hwaer", false), + (nameof(SalesRecord.SalesPriceValue), "Z.NetwrHc", true), + (nameof(SalesRecord.SalesCurrency), "Z.Hwaer", true), + (nameof(SalesRecord.DocumentCurrency), "Z.Waerk", false), + (nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NetwrDc", false), + (nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NetwrHc", false), + (nameof(SalesRecord.VatSumForeignCurrency), "=0", false), + (nameof(SalesRecord.VatSumLocalCurrency), "=0", false), + (nameof(SalesRecord.DocumentRate), "Z.Kurrf", false), + (nameof(SalesRecord.CompanyCurrency), "Z.Hwaer", true), + (nameof(SalesRecord.DocumentType), "Z.Fkart", false) }; for (var i = 0; i < mappings.Length; i++) diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs index fafff27..a2ccfdb 100644 --- a/TrafagSalesExporter/Services/ExcelExportService.cs +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -70,6 +70,7 @@ public class ExcelExportService : IExcelExportService "Company Currency", "Incoterms 2020", "Sales responsible employee", + "posting date", "invoice date", "order date", "Land", @@ -115,10 +116,11 @@ public class ExcelExportService : IExcelExportService ws.Cell(row, 28).Value = record.CompanyCurrency; ws.Cell(row, 29).Value = record.Incoterms2020; ws.Cell(row, 30).Value = record.SalesResponsibleEmployee; - ws.Cell(row, 31).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; - ws.Cell(row, 32).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; - ws.Cell(row, 33).Value = record.Land; - ws.Cell(row, 34).Value = record.DocumentType; + ws.Cell(row, 31).Value = record.PostingDate?.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.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; + ws.Cell(row, 34).Value = record.Land; + ws.Cell(row, 35).Value = record.DocumentType; row++; } diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs index e12d900..3b965c8 100644 --- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -81,15 +81,15 @@ public class ExportOrchestrationService return await RunConsolidatedExportAsync(); } - public async Task ExportSiteByIdAsync(int siteId) + public async Task ExportSiteByIdAsync(int siteId, int? preferredImportYear = null) { using var db = await _dbFactory.CreateDbContextAsync(); var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId); if (site is null) return null; - return await ExportSiteAsync(site); + return await ExportSiteAsync(site, preferredImportYear); } - private async Task ExportSiteAsync(Site site) + private async Task ExportSiteAsync(Site site, int? preferredImportYear = null) { SiteExportResult? result = null; @@ -102,7 +102,7 @@ public class ExportOrchestrationService try { - result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status)); + result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status), preferredImportYear); return result; } finally diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index 647af25..980b046 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -34,13 +34,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService var centralRows = await db.CentralSalesRecords .AsNoTracking() - .Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year) + .Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year) .Select(r => new NetSalesActualSourceRow( r.Land, r.Tsc, r.DocumentEntry, r.InvoiceNumber, r.DocumentType, + r.PostingDate, + r.InvoiceDate, + r.ExtractionDate, r.CustomerNumber, r.CustomerName, r.SalesCurrency, @@ -57,7 +60,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService .GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules), + g => BuildNetSalesActual(g.Key, g, budgetRatesToChf, intercompanyRules), StringComparer.OrdinalIgnoreCase); return financeReferences @@ -73,7 +76,9 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService groupedActuals.TryGetValue(reference.Key, out var actual); var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue; 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") .FirstOrDefault(); 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, IntercompanyValue = candidate.IntercompanyValue, ValueExcludingIntercompany = candidate.ValueExcludingIntercompany, + IsPreferred = candidate.IsPreferred, Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null, DifferenceExcludingIntercompany = referenceValue.HasValue ? candidate.ValueExcludingIntercompany - referenceValue.Value @@ -139,24 +145,30 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService } private static NetSalesActual BuildNetSalesActual( + string referenceKey, IEnumerable rows, IReadOnlyDictionary budgetRatesToChf, IReadOnlyList intercompanyRules) { var rowList = rows.ToList(); + var houseCurrency = ResolveHouseCurrency(referenceKey, rowList); var documentRows = rowList .GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .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 { new( "SalesPriceValue", - "Sales Price/Value", - ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)), - rowList.Sum(row => row.SalesPriceValue), - rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue)) + "Positions-Netto (Sales Price/Value)", + houseCurrency, + salesPriceValue, + salesPriceIntercompanyValue, + repeatedDocumentTotals && salesPriceValue != 0m) }; var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency); @@ -166,46 +178,100 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService "DocTotalFC - VatSumFC", ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)), 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); if (netDocumentLocalCurrency != 0m) candidates.Add(new( - "NetDocumentLocalCurrency", - "Nettofakturawert Hauswaehrung", - ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)), + "NetDocumentLocalCurrencyDocument", + "Nettofakturawert Hauswaehrung pro Beleg dedupliziert", + houseCurrency, 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) candidates.Add(new( "NetDocumentLocalCurrencyBudgetChf", - "Nettofakturawert Hauswaehrung -> CHF Budget 2025", + $"Nettofakturawert Hauswaehrung -> CHF Budget 2025 ({(repeatedDocumentTotals ? "Beleg" : "Position")})", "CHF", 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 { RowCount = rowList.Count, - Currencies = string.Join(", ", rowList.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)), + Currencies = houseCurrency, Candidates = candidates }; } + private static bool LooksLikeRepeatedDocumentTotals(IReadOnlyList 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( + string houseCurrency, NetSalesActualSourceRow row, decimal value, IReadOnlyDictionary 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; } + private static string ResolveHouseCurrency(string referenceKey, IReadOnlyList 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 rules) { var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty; @@ -315,6 +381,7 @@ public sealed class NetSalesCandidateRow public decimal Value { get; set; } public decimal IntercompanyValue { get; set; } public decimal ValueExcludingIntercompany { get; set; } + public bool IsPreferred { get; set; } public decimal? Difference { get; set; } public decimal? DifferenceExcludingIntercompany { get; set; } } @@ -332,6 +399,9 @@ internal sealed record NetSalesActualSourceRow( int DocumentEntry, string InvoiceNumber, string DocumentType, + DateTime? PostingDate, + DateTime? InvoiceDate, + DateTime ExtractionDate, string CustomerNumber, string CustomerName, string SalesCurrency, @@ -343,7 +413,7 @@ internal sealed record NetSalesActualSourceRow( decimal VatSumForeignCurrency, 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; } diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs index ae1f3ce..8a45cd6 100644 --- a/TrafagSalesExporter/Services/HanaQueryService.cs +++ b/TrafagSalesExporter/Services/HanaQueryService.cs @@ -267,6 +267,7 @@ public class HanaQueryService : IHanaQueryService DocumentEntry = Convert.ToInt32(reader["document_entry"]), InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, 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")), Material = reader["material"]?.ToString() ?? string.Empty, Name = reader["material_name"]?.ToString() ?? string.Empty, @@ -373,7 +374,8 @@ SELECT h.""DocEntry"" AS document_entry, h.""DocNum"" AS invoice_number, 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.""Dscription"" AS material_name, COALESCE(grp.""ItmsGrpNam"", '') AS product_group, @@ -391,7 +393,7 @@ SELECT THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) ELSE '' END AS purchase_order_number, 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.""DocTotalFC"", 0) AS document_total_fc, COALESCE(h.""DocTotal"", 0) AS document_total_lc, @@ -434,7 +436,8 @@ SELECT h.""DocEntry"" AS document_entry, h.""DocNum"" AS invoice_number, 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.""Dscription"" AS material_name, COALESCE(grp.""ItmsGrpNam"", '') AS product_group, @@ -450,7 +453,7 @@ SELECT COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, '' AS purchase_order_number, 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.""DocTotalFC"", 0) * -1 AS document_total_fc, COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc, diff --git a/TrafagSalesExporter/Services/ISapCompositionService.cs b/TrafagSalesExporter/Services/ISapCompositionService.cs index f01cd52..74867fc 100644 --- a/TrafagSalesExporter/Services/ISapCompositionService.cs +++ b/TrafagSalesExporter/Services/ISapCompositionService.cs @@ -11,5 +11,6 @@ public interface ISapCompositionService IReadOnlyList mappings, string username, string password, + int? preferredYear = null, CancellationToken cancellationToken = default); } diff --git a/TrafagSalesExporter/Services/ISapGatewayService.cs b/TrafagSalesExporter/Services/ISapGatewayService.cs index 510c1c9..139f5d1 100644 --- a/TrafagSalesExporter/Services/ISapGatewayService.cs +++ b/TrafagSalesExporter/Services/ISapGatewayService.cs @@ -5,5 +5,5 @@ public interface ISapGatewayService Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default); Task> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); - Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default); + Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default); } diff --git a/TrafagSalesExporter/Services/ISharePointUploadService.cs b/TrafagSalesExporter/Services/ISharePointUploadService.cs index 242af87..363f3c9 100644 --- a/TrafagSalesExporter/Services/ISharePointUploadService.cs +++ b/TrafagSalesExporter/Services/ISharePointUploadService.cs @@ -4,7 +4,8 @@ public interface ISharePointUploadService { Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); Task DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference); - Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc); + Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null); + Task> 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); } diff --git a/TrafagSalesExporter/Services/ISiteExportService.cs b/TrafagSalesExporter/Services/ISiteExportService.cs index b3f882e..53e7d80 100644 --- a/TrafagSalesExporter/Services/ISiteExportService.cs +++ b/TrafagSalesExporter/Services/ISiteExportService.cs @@ -4,5 +4,5 @@ namespace TrafagSalesExporter.Services; public interface ISiteExportService { - Task ExportAsync(Site site, Action? updateStatus = null); + Task ExportAsync(Site site, Action? updateStatus = null, int? preferredImportYear = null); } diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index 058e0b3..e9823d7 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -50,7 +50,11 @@ public class ManualExcelImportService : IManualExcelImportService ["companycurrency"] = nameof(SalesRecord.CompanyCurrency), ["incoterms2020"] = nameof(SalesRecord.Incoterms2020), ["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee), + ["postingdate"] = nameof(SalesRecord.PostingDate), + ["buchungsdatum"] = nameof(SalesRecord.PostingDate), + ["lineregistrationdate"] = nameof(SalesRecord.PostingDate), ["invoicedate"] = nameof(SalesRecord.InvoiceDate), + ["fakturadatum"] = nameof(SalesRecord.InvoiceDate), ["orderdate"] = nameof(SalesRecord.OrderDate), ["land"] = nameof(SalesRecord.Land), ["documenttype"] = nameof(SalesRecord.DocumentType) @@ -180,6 +184,7 @@ public class ManualExcelImportService : IManualExcelImportService CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)), Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)), SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)), + PostingDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.PostingDate)), InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)), OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)), Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land), @@ -290,6 +295,7 @@ public class ManualExcelImportService : IManualExcelImportService CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)), Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)), SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)), + PostingDate = ReadDate(headerIndexes, row, nameof(SalesRecord.PostingDate)), InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)), OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)), Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land), @@ -442,7 +448,9 @@ public class ManualExcelImportService : IManualExcelImportService { var trimmed = sourceHeader.Trim(); 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) ? row.Cell(index).GetFormattedString().Trim() @@ -453,13 +461,41 @@ public class ManualExcelImportService : IManualExcelImportService { var trimmed = sourceHeader.Trim(); 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 ? fields[index].Trim() : null; } + private static object? EvaluateMappedExpression(string expression, Dictionary headerIndexes, Func 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 headerIndexes, Func 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) => row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())); diff --git a/TrafagSalesExporter/Services/SapCompositionService.cs b/TrafagSalesExporter/Services/SapCompositionService.cs index e65acd4..c6f6285 100644 --- a/TrafagSalesExporter/Services/SapCompositionService.cs +++ b/TrafagSalesExporter/Services/SapCompositionService.cs @@ -25,6 +25,7 @@ public class SapCompositionService : ISapCompositionService IReadOnlyList mappings, string username, string password, + int? preferredYear = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(site.SapServiceUrl)) @@ -44,7 +45,8 @@ public class SapCompositionService : ISapCompositionService { await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land, $"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; await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land, $"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)}"); 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; + } } diff --git a/TrafagSalesExporter/Services/SapGatewayService.cs b/TrafagSalesExporter/Services/SapGatewayService.cs index 63774fd..748b1e4 100644 --- a/TrafagSalesExporter/Services/SapGatewayService.cs +++ b/TrafagSalesExporter/Services/SapGatewayService.cs @@ -87,10 +87,13 @@ public class SapGatewayService : ISapGatewayService .ToList(); } - public async Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default) + public async Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default) { 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); using var response = await client.GetAsync(requestUrl, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs index ff920bb..8e4fa30 100644 --- a/TrafagSalesExporter/Services/SharePointUploadService.cs +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -91,7 +91,22 @@ public class SharePointUploadService : ISharePointUploadService string clientSecret, string siteUrl, 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> ResolveManualImportFilesInFolderAsync( + string tenantId, + string clientId, + string clientSecret, + string siteUrl, + string folderReference, + string siteTsc, + int? preferredYear = null) { var normalizedTenantId = Normalize(tenantId); var normalizedClientId = Normalize(clientId); @@ -119,18 +134,56 @@ public class SharePointUploadService : ISharePointUploadService var folderPath = ResolveRemotePath(normalizedReference, siteUri); 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 => IsSupportedManualImportFile(item.Name)) .Where(item => MatchesTsc(item.Name, normalizedTsc)) .Select(item => new { 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 + { + 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) .ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue) - .ToList() ?? []; + .ToList(); var selected = candidates.FirstOrDefault() ?? 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 fuer '{normalizedTsc}' gefunden."); - return new SharePointFileReference( - string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'), - selected.Item.LastModifiedDateTime); + return + [ + 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) @@ -217,7 +273,8 @@ public class SharePointUploadService : ISharePointUploadService return true; 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) @@ -239,6 +296,33 @@ public class SharePointUploadService : ISharePointUploadService 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, @"(?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) { var maskedSecret = string.IsNullOrEmpty(clientSecret) diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 38aeeed..6169963 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -37,7 +37,7 @@ public class SiteExportService : ISiteExportService _logger = logger; } - public async Task ExportAsync(Site site, Action? updateStatus = null) + public async Task ExportAsync(Site site, Action? updateStatus = null, int? preferredImportYear = null) { var sw = Stopwatch.StartNew(); var log = new ExportLog @@ -63,7 +63,8 @@ public class SiteExportService : ISiteExportService SourceDefinition = sourceDefinition, Settings = settings, SharePointConfig = spConfig, - UpdateStatus = updateStatus + UpdateStatus = updateStatus, + PreferredImportYear = preferredImportYear }); var records = fetchResult.Records; diff --git a/TrafagSalesExporter/Tools/FinanceProbe/FinanceProbe.csproj b/TrafagSalesExporter/Tools/FinanceProbe/FinanceProbe.csproj index d5645a5..c5d8134 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/FinanceProbe.csproj +++ b/TrafagSalesExporter/Tools/FinanceProbe/FinanceProbe.csproj @@ -9,4 +9,11 @@ + + + + + + + diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs index af88290..933742c 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs +++ b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs @@ -4,14 +4,52 @@ using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; using TrafagSalesExporter.Services; +using TrafagSalesExporter.Services.DataSources; var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); var databasePath = ResolveDatabasePath(builder.Configuration["FinanceProbe:DatabasePath"]); builder.Services.AddDbContextFactory(options => options.UseSqlite($"Data Source={databasePath};Default Timeout=60")); +builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -25,9 +63,157 @@ app.MapGet("/finance", async (IFinanceReconciliationService finance, IDbContextF var coverage = await LoadSiteCoverageAsync(dbFactory, 2025); 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 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 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 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(); +static async Task ResolveSiteAsync(IDbContextFactory 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 BuildRunSummaryAsync( + IFinanceReconciliationService finance, + IDbContextFactory 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 => $$""" + + {{Html(row.Status)}} + {{Html(row.Key)}} + {{Html(row.Label)}} + {{Amount(row.ActualValue)}} + {{Amount(row.ReferenceValue)}} + {{Amount(row.Difference)}} + {{Html(row.ValueField)}} + {{row.RowCount}} + +""")); + var coverageRows = string.Join(Environment.NewLine, coverage.Select(row => $$""" + + {{Html(row.Land)}}
{{Html(row.Tsc)}}
+ {{Html(row.SourceSystem)}} + {{row.RowCount}} + {{Amount(row.SalesPriceValue)}} + {{Html(row.Currencies)}} + {{Html(row.LastExportStatus)}} + {{Html(row.LastExportError)}} + +""")); + var logRows = string.Join(Environment.NewLine, recentLogs.Select(log => $$""" + + {{Html(log.Timestamp.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} + {{Html(log.Land)}} + {{Html(log.TSC)}} + {{Html(log.Status)}} + {{log.RowCount}} + {{Html(log.FileName)}} + {{Html(log.ErrorMessage)}} + +""")); + + return $$""" + + + + + FinanceProbe Run Summary + + + +
+

FinanceProbe Run Summary

+

{{Html(message)}}

+

Start: {{Html(startedAt.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} | Ergebnis: OK={{okCount}}, Pruefen={{checkCount}}, Keine Daten={{missingCount}}

+

Zur Finance-Auswertung | Zentrale Datei erzeugen | Alle exportieren

+
+
+

Neue Exportlogs seit Start

+ {{logRows}}
ZeitLandTSCStatusZeilenDateiFehler
+
+
+

Finance-Abgleich

+ {{financeRows}}
StatusKeyLabelIstSollDiffFeldZeilen
+
+
+

Datenabdeckung

+ {{coverageRows}}
StandortSystemZeilenSalesWaehrungLetzter StatusFehler
+
+ + +"""; +} + +static async Task> LoadRecentExportLogsAsync(IDbContextFactory 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) { if (!string.IsNullOrWhiteSpace(configuredPath)) @@ -238,12 +424,12 @@ static async Task> LoadSiteCoverageAsync(IDbContextFactory .ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase); var centralBaseRows = await db.CentralSalesRecords .AsNoTracking() - .Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year) + .Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year) .Select(r => new { r.SiteId, r.SalesPriceValue, - Date = r.InvoiceDate ?? r.ExtractionDate, + Date = r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate, Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency }) .ToListAsync(); diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs index 4994f0b..c9edc3a 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/CentralSalesRecordServiceTests.cs @@ -74,6 +74,8 @@ public class CentralSalesRecordServiceTests : IDisposable VatSumLocalCurrency = 7.6m, DocumentRate = 0.95m, CompanyCurrency = "CHF", + PostingDate = new DateTime(2026, 4, 28), + InvoiceDate = new DateTime(2026, 4, 29), Land = "Schweiz", DocumentType = "INV" } @@ -90,6 +92,8 @@ public class CentralSalesRecordServiceTests : IDisposable Assert.Equal(7.6m, row.VatSumLocalCurrency); Assert.Equal(0.95m, row.DocumentRate); 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 diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs new file mode 100644 index 0000000..6a65a5e --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/FinanceReconciliationServiceTests.cs @@ -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() + .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 + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs index c091a70..ebd4567 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs @@ -140,12 +140,22 @@ public class ManualExcelDataSourceAdapterTests return Task.FromResult(tempPath); } - public Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc) + public Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null) { LastResolvedTsc = siteTsc; return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero))); } + public Task> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null) + { + LastResolvedTsc = siteTsc; + IReadOnlyList 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) => Task.CompletedTask; } diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs index 7af5eab..a621019 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs @@ -48,10 +48,11 @@ public class ManualExcelImportServiceTests ws.Cell(2, 28).Value = "CHF"; ws.Cell(2, 29).Value = "DAP"; ws.Cell(2, 30).Value = "Alice"; - ws.Cell(2, 31).Value = "14.04.2026"; - ws.Cell(2, 32).Value = "10.04.2026"; - ws.Cell(2, 33).Value = "Deutschland"; - ws.Cell(2, 34).Value = "Invoice"; + ws.Cell(2, 31).Value = "13.04.2026"; + ws.Cell(2, 32).Value = "14.04.2026"; + ws.Cell(2, 33).Value = "10.04.2026"; + ws.Cell(2, 34).Value = "Deutschland"; + ws.Cell(2, 35).Value = "Invoice"; }); try @@ -78,6 +79,7 @@ public class ManualExcelImportServiceTests Assert.Equal("CHF", row.CompanyCurrency); Assert.Equal("Deutschland", row.Land); 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, 10), row.OrderDate); } @@ -264,6 +266,7 @@ public class ManualExcelImportServiceTests Map(nameof(SalesRecord.CompanyCurrency), "Währung"), Map(nameof(SalesRecord.Incoterms2020), "Versandbedingung"), Map(nameof(SalesRecord.SalesResponsibleEmployee), "AdressNummer_V"), + Map(nameof(SalesRecord.PostingDate), "Belegdatum-Rechnung"), Map(nameof(SalesRecord.InvoiceDate), "Belegdatum-Rechnung"), Map(nameof(SalesRecord.OrderDate), "BelegDatum Auftrag"), Map(nameof(SalesRecord.DocumentType), "=Manual Excel") @@ -287,6 +290,7 @@ public class ManualExcelImportServiceTests Assert.Equal("EUR", row.SalesCurrency); Assert.Equal("EUR", row.DocumentCurrency); 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, 3, 9), row.OrderDate); Assert.Equal("Manual Excel", row.DocumentType); @@ -307,8 +311,8 @@ public class ManualExcelImportServiceTests }; var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); var csv = string.Join(Environment.NewLine, - "\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"", - "\"TRES\";\"Spanien\";\"20241332\";\"20\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"265.000000\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\""); + "\"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-03 00:00:00\";\"2025-01-02 00:00:00\";\"Invoice\""); await File.WriteAllTextAsync(filePath, csv); try @@ -330,6 +334,7 @@ public class ManualExcelImportServiceTests Assert.Equal("EUR", row.SalesCurrency); Assert.Equal("EUR", row.DocumentCurrency); 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("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 + { + 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 fillWorkbook) { var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); @@ -382,6 +441,7 @@ public class ManualExcelImportServiceTests "Company Currency", "Incoterms 2020", "Sales responsible employee", + "posting date", "invoice date", "order date", "Land", diff --git a/TrafagSalesExporter/TrafagSalesExporter.csproj b/TrafagSalesExporter/TrafagSalesExporter.csproj index de73403..e7e9e92 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.csproj +++ b/TrafagSalesExporter/TrafagSalesExporter.csproj @@ -36,12 +36,20 @@ + + + + + + + + diff --git a/TrafagSalesExporter/docs/FINANCE_ENTSCHEIDE.md b/TrafagSalesExporter/docs/FINANCE_ENTSCHEIDE.md new file mode 100644 index 0000000..a2aeabe --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_ENTSCHEIDE.md @@ -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. diff --git a/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md b/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md index 4e58116..2da6684 100644 --- a/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md +++ b/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md @@ -9,6 +9,10 @@ Fuer das Programm bieten sich zwei Diagrammarten an: ## 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` - zeigt Finance, Power User/Admin und IT/SAP als Rollen - 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 - 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 Die Diagramme wurden gegen folgende Codebereiche abgeglichen: @@ -37,9 +46,45 @@ Die Diagramme wurden gegen folgende Codebereiche abgeglichen: Wichtige Praezisierung aus dem Code: - `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. +## 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 Die SVG-Dateien koennen direkt im Browser geoeffnet, in Markdown verlinkt oder in Praesentationen eingefuegt werden. diff --git a/TrafagSalesExporter/docs/finance-land-algorithms.svg b/TrafagSalesExporter/docs/finance-land-algorithms.svg new file mode 100644 index 0000000..ffd7edb --- /dev/null +++ b/TrafagSalesExporter/docs/finance-land-algorithms.svg @@ -0,0 +1,259 @@ + + Finance-Fluss je Land + Blockdiagramm fuer den buchhalterischen Finanzfluss je Land vom Quellsystem bis zum Soll-Ist-Abgleich. + + + + + + + + + Finance-Fluss je Land: was buchhalterisch im Hintergrund passiert + Zielbild fuer Finance: je Land wird der Nettofakturawert in Hauswaehrung pro Position gelesen, per Buchungsdatum abgegrenzt und gegen check.xlsx verglichen. + + + + Globale Finance-Regeln + 1. Datumsabgrenzung: Buchungsdatum. Technischer Fallback, falls Quelle kein Buchungsdatum liefert: Fakturadatum, danach Extraktionsdatum. + 2. Waehrung: Hauswaehrung ist fuehrend. CHF wird nur als eigener Kandidat ueber Budgetkurs gerechnet, nicht mit Tageskurs. + 3. Wertbasis: Nettofakturawert pro Belegposition. Wiederholte B1-Belegkopfwerte werden erkannt, damit DocTotal nicht pro Position multipliziert wird. + 4. Gutschriften/Storno: eigene Beleg-/Positionszeilen bleiben sichtbar und laufen ueber Artikel/Position. + 5. Intercompany: 2nd/3rd Party wird separat ausgewiesen; der Abzug ist Kontrollsicht, nicht stiller Standardabzug. + 6. Sollwert: FinanceReferences/check.xlsx je Jahr; gleiche Logik funktioniert fuer andere Jahre mit passenden Sollwerten und Budgetkursen. + + + + Zentraler Algorithmus im Programm + + + Landesquelle lesen + SAP OData, HANA, Excel/CSV + + + Grafisches Mapping + Zielmodell SalesRecord + + + Zentrale Tabelle + CentralSalesRecords + + + FinanceReconciliationService + Jahr, Hauswaehrung, Netto, IC + + + Vergleich gegen Soll + FinanceReferences / check.xlsx + + + + + Landesspezifische Fluesse + + + + + CH / AT + + SAP ZSCHWEIZ OData + BUKRS 1100=CH, 1200=AT + + + Mapping ZSCHWEIZSet + LAND1/TSC trennt CH und AT + + + NETWR_HC pro Position + Hauswaehrung CHF/EUR, FKDAT aktuell als Datum + + + Soll je Land + CH und AT separat in FinanceReferences + + Offen + echtes SAP-BUDAT noch klaeren/mappen + + + + + FR + + BI1 / SAP B1 HANA + OINV/INV1, ORIN/RIN1 + + + Legacy B1 oder grafisches HANA + DocDate=PostingDate, TaxDate=InvoiceDate + + + Positions-Netto in Hauswaehrung EUR + keine DocTotal-Multiplikation + + + Vergleich Soll FR + bisher nahe an check.xlsx + + IC sichtbar + 2nd/3rd Party Kandidat + + + + + IT + + BI1 / SAP B1 HANA + Italien ist B1 + + + Hauswaehrung EUR + B1-Headerwerte werden erkannt + + + Netto nach Position, IC separat + Trafag/Magnetic Sense usw. als 2nd Party + + + Vergleich Soll IT + mit und ohne IC-Deduction sichtbar + + Kontrollpunkt + IC-Kundenliste finalisieren + + + + + UK + + SharePoint Excel + neueste Datei ddMMyy_TRUK.xlsx + + + Manual Excel Mapper + Hauswaehrung GBP fuehrend + + + Delta/neueste Datei lesen + Netto pro Position, Jahr nach Buchungsdatum + + + Vergleich Soll UK + Reporting in GBP, CHF nur Kandidat + + Quelle bleibt Ordner + Export in gleichen SharePoint-Ort + + + + + IN + + SAGE / HANA + Indien wird als INR gefuehrt + + + Waehrung normalisieren + Hauswaehrung INR statt Belegmix + + + Nettofakturawert INR + Budget-CHF nur Kontrollkandidat + + + Vergleich Soll IN + bisher nahe an check.xlsx + + Kontrollpunkt + keine gemischten Belegwaehrungen summieren + + + + + ES + + SAGE Export Excel/CSV + SharePoint oder lokales File + + + Manual Excel/CSV Mapper + LineRegistrationDate als Buchungsdatum + + + Netto pro Position in EUR + CSV und XLSX technisch erlaubt + + + Vergleich Soll ES + Differenz fachlich klaeren + + Kontrollpunkt + Serien/Gutschriften bestaetigen + + + + + DE + + Alphaplan Excel + Jahresfile von Deutschland + + + Manual Excel Mapper + NettoPreisGesamtX, Belegdatum + + + Netto pro Position in EUR + Sample nicht als Jahres-Ist verwenden + + + Vergleich Soll DE + erst nach finalem Jahresfile + + Offen + vollstaendiger 2025-Export fehlt + + + + + US + + BI1 / SAP B1 HANA + US B1 Schema + + + B1-Positionsdaten + DocDate=PostingDate + + + Netto pro Position in USD + CHF Budget als Kandidat + + + Vergleich Soll US + FinanceReferences je Jahr + + IC sichtbar + gleiche Regel wie B1-Laender + + + + + Stand: 2026-05-11. Abgeleitet aus Services/DataSources, MappedSalesRecordComposer, CentralSalesRecords, DatabaseSeedService und FinanceReconciliationService. Das Diagramm beschreibt die Buchhaltungslogik, nicht einzelne Testzahlen. + diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index fd26858..50a5f2c 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,122 @@ # 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 Umgesetzte Anpassungen: @@ -69,6 +186,32 @@ Verifikation: - `Tools/FinanceProbe` Build erfolgreich. - 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 Umgesetzte Aufraeumarbeiten: @@ -960,3 +1103,72 @@ Ergebnis: - `Meeting Ampel 2025` - `Spain CSV direct 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. diff --git a/powerbi/FLUKTUATION_NACHDOKU_2026-05-12.md b/powerbi/FLUKTUATION_NACHDOKU_2026-05-12.md new file mode 100644 index 0000000..65c2111 --- /dev/null +++ b/powerbi/FLUKTUATION_NACHDOKU_2026-05-12.md @@ -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` diff --git a/powerbi/HANDOFF_2026-05-11.md b/powerbi/HANDOFF_2026-05-11.md new file mode 100644 index 0000000..440dd88 --- /dev/null +++ b/powerbi/HANDOFF_2026-05-11.md @@ -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. + diff --git a/powerbi/REXX_aBSENZEN.txt b/powerbi/REXX_aBSENZEN.txt new file mode 100644 index 0000000..f980592 --- /dev/null +++ b/powerbi/REXX_aBSENZEN.txt @@ -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 \ No newline at end of file diff --git a/powerbi/datenbeschaffung.txt b/powerbi/datenbeschaffung.txt new file mode 100644 index 0000000..4284d79 --- /dev/null +++ b/powerbi/datenbeschaffung.txt @@ -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 diff --git a/powerbi/fluktuation_measures_dax.txt b/powerbi/fluktuation_measures_dax.txt new file mode 100644 index 0000000..17dd848 --- /dev/null +++ b/powerbi/fluktuation_measures_dax.txt @@ -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) diff --git a/powerbi/formeln.docx b/powerbi/formeln.docx new file mode 100644 index 0000000..c3d9a8e Binary files /dev/null and b/powerbi/formeln.docx differ diff --git a/powerbi/hr_kpi_daten_query.txt b/powerbi/hr_kpi_daten_query.txt new file mode 100644 index 0000000..02d33b0 --- /dev/null +++ b/powerbi/hr_kpi_daten_query.txt @@ -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 \ No newline at end of file diff --git a/powerbi/rexx_ausgeschieden.txt b/powerbi/rexx_ausgeschieden.txt new file mode 100644 index 0000000..acedf5a --- /dev/null +++ b/powerbi/rexx_ausgeschieden.txt @@ -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