Commit pending finance and Power BI work
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,4 +9,5 @@ public sealed class DataSourceFetchContext
|
||||
public required ExportSettings Settings { get; init; }
|
||||
public SharePointConfig? SharePointConfig { get; init; }
|
||||
public Action<string>? UpdateStatus { get; init; }
|
||||
public int? PreferredImportYear { get; init; }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
|
||||
string filePath;
|
||||
string? localOutputDirectory = null;
|
||||
string? sharePointUploadFolder = null;
|
||||
string? tempManualImportPath = null;
|
||||
var tempManualImportPaths = new List<string>();
|
||||
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<string>();
|
||||
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<SalesRecord>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,15 +81,15 @@ public class ExportOrchestrationService
|
||||
return await RunConsolidatedExportAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId, int? preferredImportYear = null)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
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<SiteExportResult?> ExportSiteAsync(Site site)
|
||||
private async Task<SiteExportResult?> 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
|
||||
|
||||
@@ -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<NetSalesActualSourceRow> rows,
|
||||
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
|
||||
IReadOnlyList<FinanceIntercompanyRule> 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<NetSalesCandidate>
|
||||
{
|
||||
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<NetSalesActualSourceRow> rows)
|
||||
{
|
||||
var multiLineGroups = rows
|
||||
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(group => group.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (multiLineGroups.Count == 0)
|
||||
return false;
|
||||
|
||||
var repeatedGroups = multiLineGroups.Count(group =>
|
||||
group.Select(row => Math.Round(row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, 2))
|
||||
.Distinct()
|
||||
.Count() == 1);
|
||||
|
||||
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
|
||||
}
|
||||
|
||||
private static decimal ConvertHouseCurrencyNetToBudgetChf(
|
||||
string houseCurrency,
|
||||
NetSalesActualSourceRow row,
|
||||
decimal value,
|
||||
IReadOnlyDictionary<string, decimal> budgetRatesToChf)
|
||||
{
|
||||
var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
|
||||
var currency = !string.IsNullOrWhiteSpace(houseCurrency) && houseCurrency != "-"
|
||||
? houseCurrency.Trim().ToUpperInvariant()
|
||||
: (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
|
||||
}
|
||||
|
||||
private static string ResolveHouseCurrency(string referenceKey, IReadOnlyList<NetSalesActualSourceRow> rows)
|
||||
{
|
||||
var configured = referenceKey.ToUpperInvariant() switch
|
||||
{
|
||||
"CH" => "CHF",
|
||||
"AT" => "EUR",
|
||||
"DE" => "EUR",
|
||||
"ES" => "EUR",
|
||||
"FR" => "EUR",
|
||||
"IN" => "INR",
|
||||
"IT" => "EUR",
|
||||
"UK" => "GBP",
|
||||
"US" => "USD",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return string.IsNullOrWhiteSpace(configured)
|
||||
? ResolveCurrencyLabel(rows.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency))
|
||||
: configured;
|
||||
}
|
||||
|
||||
private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,5 +11,6 @@ public interface ISapCompositionService
|
||||
IReadOnlyList<SapFieldMapping> mappings,
|
||||
string username,
|
||||
string password,
|
||||
int? preferredYear = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ public interface ISapGatewayService
|
||||
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
||||
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
||||
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
|
||||
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
|
||||
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ public interface ISharePointUploadService
|
||||
{
|
||||
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
|
||||
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
|
||||
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc);
|
||||
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
|
||||
Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
|
||||
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace TrafagSalesExporter.Services;
|
||||
|
||||
public interface ISiteExportService
|
||||
{
|
||||
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null);
|
||||
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ public class ManualExcelImportService : IManualExcelImportService
|
||||
["companycurrency"] = nameof(SalesRecord.CompanyCurrency),
|
||||
["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<string, int> headerIndexes, Func<string, string?> readHeader)
|
||||
{
|
||||
if (!expression.Contains('[') || !expression.Contains(']'))
|
||||
return expression;
|
||||
|
||||
var parts = expression.Split('*', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
return expression;
|
||||
|
||||
var left = ResolveExpressionOperand(parts[0], headerIndexes, readHeader);
|
||||
var right = ResolveExpressionOperand(parts[1], headerIndexes, readHeader);
|
||||
return left * right;
|
||||
}
|
||||
|
||||
private static decimal ResolveExpressionOperand(string operand, Dictionary<string, int> headerIndexes, Func<string, string?> readHeader)
|
||||
{
|
||||
var trimmed = operand.Trim();
|
||||
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
|
||||
{
|
||||
var header = trimmed[1..^1].Trim();
|
||||
return ParseDecimal(readHeader(header) ?? string.Empty);
|
||||
}
|
||||
|
||||
return ParseDecimal(trimmed);
|
||||
}
|
||||
|
||||
private static bool IsRowEmpty(IXLRangeRow row)
|
||||
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public class SapCompositionService : ISapCompositionService
|
||||
IReadOnlyList<SapFieldMapping> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,13 @@ public class SapGatewayService : ISapGatewayService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
|
||||
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var client = CreateClient(username, password);
|
||||
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();
|
||||
|
||||
@@ -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<IReadOnlyList<SharePointFileReference>> 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<SharePointFileReference>
|
||||
{
|
||||
new(string.Join("/", folderPath.Trim('/'), annual.Item.Name).Trim('/'), annual.Item.LastModifiedDateTime)
|
||||
};
|
||||
|
||||
if (preferredYear.Value >= DateTime.Today.Year)
|
||||
{
|
||||
var baseDate = annual.SnapshotDate
|
||||
?? annual.Item.LastModifiedDateTime?.UtcDateTime.Date
|
||||
?? new DateTime(preferredYear.Value, 1, 1);
|
||||
|
||||
references.AddRange(allCandidates
|
||||
.Where(x => x.FileDate is not null)
|
||||
.Where(x => x.FileDate!.Value.Year == preferredYear.Value)
|
||||
.Where(x => x.FileDate!.Value.Date > baseDate.Date)
|
||||
.OrderBy(x => x.FileDate)
|
||||
.Select(x => new SharePointFileReference(
|
||||
string.Join("/", folderPath.Trim('/'), x.Item.Name).Trim('/'),
|
||||
x.Item.LastModifiedDateTime)));
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
var candidates = allCandidates
|
||||
.OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
|
||||
.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, @"(?<!\d)(20\d{2})(?!\d)");
|
||||
return match.Success && int.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out year);
|
||||
}
|
||||
|
||||
private static bool TryParseSnapshotDate(string? fileName, out DateTime snapshotDate)
|
||||
{
|
||||
snapshotDate = default;
|
||||
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
|
||||
var match = Regex.Match(nameWithoutExtension, @"(?<!\d)(?<date>20\d{2}[-_.]\d{2}[-_.]\d{2})(?!\d)");
|
||||
return match.Success &&
|
||||
DateTime.TryParseExact(
|
||||
match.Groups["date"].Value.Replace('_', '-').Replace('.', '-'),
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out snapshotDate);
|
||||
}
|
||||
|
||||
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
|
||||
{
|
||||
var maskedSecret = string.IsNullOrEmpty(clientSecret)
|
||||
|
||||
@@ -37,7 +37,7 @@ public class SiteExportService : ISiteExportService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null)
|
||||
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var 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;
|
||||
|
||||
@@ -9,4 +9,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\TrafagSalesExporter.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="verify_probe_out*\**\*.cs" />
|
||||
<Content Remove="verify_probe_out*\**\*" />
|
||||
<EmbeddedResource Remove="verify_probe_out*\**\*" />
|
||||
<None Remove="verify_probe_out*\**\*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<AppDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={databasePath};Default Timeout=60"));
|
||||
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
|
||||
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
|
||||
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
||||
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
|
||||
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
|
||||
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
|
||||
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, PrefixTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationStrategy, NormalizeCurrencyCodeTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ICurrencyExchangeRateService, CurrencyExchangeRateService>();
|
||||
builder.Services.AddSingleton<IExchangeRateImportService, ExchangeRateImportService>();
|
||||
builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>();
|
||||
builder.Services.AddSingleton<IRecordTransformationStrategy, ConvertCurrencyRecordTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
|
||||
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
||||
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
||||
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
||||
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
|
||||
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
|
||||
builder.Services.AddSingleton<IDataSourceAdapter, SapGatewayDataSourceAdapter>();
|
||||
builder.Services.AddSingleton<IDataSourceAdapter, ManualExcelDataSourceAdapter>();
|
||||
builder.Services.AddSingleton<IDataSourceAdapterResolver, DataSourceAdapterResolver>();
|
||||
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
|
||||
builder.Services.AddSingleton<ExportOrchestrationService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -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<AppDbContext> dbFactory) =>
|
||||
{
|
||||
var startedAt = DateTime.Now;
|
||||
await exports.ExportAllAsync();
|
||||
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, "Alle aktiven Standorte exportiert und zentrale Datei erzeugt.");
|
||||
return Results.Content(summary, "text/html; charset=utf-8");
|
||||
});
|
||||
app.MapGet("/run/consolidated", async (ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
|
||||
{
|
||||
var startedAt = DateTime.Now;
|
||||
var path = await exports.ExportConsolidatedOnlyAsync();
|
||||
var message = string.IsNullOrWhiteSpace(path)
|
||||
? "Zentrale Datei wurde nicht erzeugt; vermutlich keine CentralSalesRecords vorhanden oder Export lief bereits."
|
||||
: $"Zentrale Datei erzeugt: {path}";
|
||||
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message);
|
||||
return Results.Content(summary, "text/html; charset=utf-8");
|
||||
});
|
||||
app.MapGet("/run/export/{siteKey}", async (string siteKey, int? year, ExportOrchestrationService exports, IFinanceReconciliationService finance, IDbContextFactory<AppDbContext> dbFactory) =>
|
||||
{
|
||||
var startedAt = DateTime.Now;
|
||||
var site = await ResolveSiteAsync(dbFactory, siteKey);
|
||||
if (site is null)
|
||||
return Results.NotFound($"Standort nicht gefunden: {siteKey}");
|
||||
|
||||
var importYear = year ?? 2025;
|
||||
var result = await exports.ExportSiteByIdAsync(site.Id, importYear);
|
||||
var message = result is null
|
||||
? $"Export wurde nicht gestartet: {site.Land} / {site.TSC}"
|
||||
: $"Export {result.Log.Status}: {site.Land} / {site.TSC}, Jahr={importYear}, Zeilen={result.Log.RowCount}, Datei={result.FilePath ?? "-"}, Fehler={result.Log.ErrorMessage}";
|
||||
var summary = await BuildRunSummaryAsync(finance, dbFactory, startedAt, message);
|
||||
return Results.Content(summary, "text/html; charset=utf-8");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<Site?> ResolveSiteAsync(IDbContextFactory<AppDbContext> dbFactory, string siteKey)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var normalized = siteKey.Trim();
|
||||
if (int.TryParse(normalized, out var siteId))
|
||||
return await db.Sites.AsNoTracking().FirstOrDefaultAsync(s => s.Id == siteId);
|
||||
|
||||
var sites = await db.Sites
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Id)
|
||||
.ToListAsync();
|
||||
return sites.FirstOrDefault(s =>
|
||||
s.TSC.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.Land.Equals(normalized, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static async Task<string> BuildRunSummaryAsync(
|
||||
IFinanceReconciliationService finance,
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
DateTime startedAt,
|
||||
string message)
|
||||
{
|
||||
var rows = await finance.BuildNetSalesReferenceRowsAsync(2025);
|
||||
var coverage = await LoadSiteCoverageAsync(dbFactory, 2025);
|
||||
var recentLogs = await LoadRecentExportLogsAsync(dbFactory, startedAt);
|
||||
var okCount = rows.Count(r => r.Status == "OK");
|
||||
var checkCount = rows.Count(r => r.Status == "Pruefen");
|
||||
var missingCount = rows.Count(r => r.Status == "Keine Daten");
|
||||
var financeRows = string.Join(Environment.NewLine, rows.Select(row => $$"""
|
||||
<tr>
|
||||
<td>{{Html(row.Status)}}</td>
|
||||
<td>{{Html(row.Key)}}</td>
|
||||
<td>{{Html(row.Label)}}</td>
|
||||
<td class="num">{{Amount(row.ActualValue)}}</td>
|
||||
<td class="num">{{Amount(row.ReferenceValue)}}</td>
|
||||
<td class="num">{{Amount(row.Difference)}}</td>
|
||||
<td>{{Html(row.ValueField)}}</td>
|
||||
<td class="num">{{row.RowCount}}</td>
|
||||
</tr>
|
||||
"""));
|
||||
var coverageRows = string.Join(Environment.NewLine, coverage.Select(row => $$"""
|
||||
<tr>
|
||||
<td>{{Html(row.Land)}}<div class="small">{{Html(row.Tsc)}}</div></td>
|
||||
<td>{{Html(row.SourceSystem)}}</td>
|
||||
<td class="num">{{row.RowCount}}</td>
|
||||
<td class="num">{{Amount(row.SalesPriceValue)}}</td>
|
||||
<td>{{Html(row.Currencies)}}</td>
|
||||
<td>{{Html(row.LastExportStatus)}}</td>
|
||||
<td class="wrap">{{Html(row.LastExportError)}}</td>
|
||||
</tr>
|
||||
"""));
|
||||
var logRows = string.Join(Environment.NewLine, recentLogs.Select(log => $$"""
|
||||
<tr>
|
||||
<td>{{Html(log.Timestamp.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}}</td>
|
||||
<td>{{Html(log.Land)}}</td>
|
||||
<td>{{Html(log.TSC)}}</td>
|
||||
<td>{{Html(log.Status)}}</td>
|
||||
<td class="num">{{log.RowCount}}</td>
|
||||
<td>{{Html(log.FileName)}}</td>
|
||||
<td class="wrap">{{Html(log.ErrorMessage)}}</td>
|
||||
</tr>
|
||||
"""));
|
||||
|
||||
return $$"""
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FinanceProbe Run Summary</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", Arial, sans-serif; margin: 24px; background:#f6f7f9; color:#172033; }
|
||||
.panel { background:#fff; border:1px solid #d8dee8; border-radius:6px; padding:14px; margin-bottom:14px; }
|
||||
table { width:100%; border-collapse:collapse; background:#fff; border:1px solid #d8dee8; margin-top:8px; }
|
||||
th { background:#22324a; color:#fff; text-align:left; padding:7px 9px; }
|
||||
td { border-top:1px solid #d8dee8; padding:6px 9px; vertical-align:top; }
|
||||
.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.small { color:#667085; font-size:12px; }
|
||||
.wrap { max-width:520px; }
|
||||
a { color:#1f4f7a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="panel">
|
||||
<h1>FinanceProbe Run Summary</h1>
|
||||
<p>{{Html(message)}}</p>
|
||||
<p class="small">Start: {{Html(startedAt.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")))}} | Ergebnis: OK={{okCount}}, Pruefen={{checkCount}}, Keine Daten={{missingCount}}</p>
|
||||
<p><a href="/finance">Zur Finance-Auswertung</a> | <a href="/run/consolidated">Zentrale Datei erzeugen</a> | <a href="/run/export-all">Alle exportieren</a></p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Neue Exportlogs seit Start</h2>
|
||||
<table><thead><tr><th>Zeit</th><th>Land</th><th>TSC</th><th>Status</th><th class="num">Zeilen</th><th>Datei</th><th>Fehler</th></tr></thead><tbody>{{logRows}}</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Finance-Abgleich</h2>
|
||||
<table><thead><tr><th>Status</th><th>Key</th><th>Label</th><th class="num">Ist</th><th class="num">Soll</th><th class="num">Diff</th><th>Feld</th><th class="num">Zeilen</th></tr></thead><tbody>{{financeRows}}</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Datenabdeckung</h2>
|
||||
<table><thead><tr><th>Standort</th><th>System</th><th class="num">Zeilen</th><th class="num">Sales</th><th>Waehrung</th><th>Letzter Status</th><th>Fehler</th></tr></thead><tbody>{{coverageRows}}</tbody></table>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
static async Task<List<ExportLog>> LoadRecentExportLogsAsync(IDbContextFactory<AppDbContext> dbFactory, DateTime startedAt)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
return await db.ExportLogs
|
||||
.AsNoTracking()
|
||||
.Where(log => log.Timestamp >= startedAt.AddSeconds(-2))
|
||||
.OrderByDescending(log => log.Id)
|
||||
.Take(40)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
static string ResolveDatabasePath(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
@@ -238,12 +424,12 @@ static async Task<List<SiteCoverageRow>> 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
using TrafagSalesExporter.Services;
|
||||
|
||||
namespace TrafagSalesExporter.Tests;
|
||||
|
||||
public class FinanceReconciliationServiceTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly TestDbContextFactory _dbFactory;
|
||||
|
||||
public FinanceReconciliationServiceTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using var db = new AppDbContext(options);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
_dbFactory = new TestDbContextFactory(options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildNetSalesReferenceRowsAsync_Uses_PostingDate_For_Year_Filter()
|
||||
{
|
||||
await using (var db = await _dbFactory.CreateDbContextAsync())
|
||||
{
|
||||
db.Sites.Add(BuildSite());
|
||||
db.FinanceReferences.Add(new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, CheckValue = 100m, IsActive = true });
|
||||
db.CentralSalesRecords.AddRange(
|
||||
BuildCentralRecord("TRDE", "Deutschland", 1, 1, 100m, new DateTime(2025, 1, 5), new DateTime(2024, 12, 31)),
|
||||
BuildCentralRecord("TRDE", "Deutschland", 2, 1, 999m, new DateTime(2024, 12, 31), new DateTime(2025, 1, 5)));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new FinanceReconciliationService(_dbFactory);
|
||||
|
||||
var rows = await service.BuildNetSalesReferenceRowsAsync(2025);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(100m, row.ActualValue);
|
||||
Assert.Equal("OK", row.Status);
|
||||
Assert.Equal("Nettofakturawert Hauswaehrung pro Position", row.ValueField);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildNetSalesReferenceRowsAsync_Does_Not_Multiply_Repeated_Document_Header_Totals()
|
||||
{
|
||||
await using (var db = await _dbFactory.CreateDbContextAsync())
|
||||
{
|
||||
db.Sites.Add(BuildSite());
|
||||
db.FinanceReferences.Add(new FinanceReference { Key = "IT", Label = "Trafag IT", Year = 2025, CheckValue = 90m, IsActive = true });
|
||||
db.CentralSalesRecords.AddRange(
|
||||
BuildCentralRecord("TRIT", "Italien", 10, 1, 100m, new DateTime(2025, 2, 1), new DateTime(2025, 2, 1), vatLocal: 10m, salesPriceValue: 40m),
|
||||
BuildCentralRecord("TRIT", "Italien", 10, 2, 100m, new DateTime(2025, 2, 1), new DateTime(2025, 2, 1), vatLocal: 10m, salesPriceValue: 50m));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new FinanceReconciliationService(_dbFactory);
|
||||
|
||||
var rows = await service.BuildNetSalesReferenceRowsAsync(2025);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(90m, row.ActualValue);
|
||||
Assert.Equal("Positions-Netto (Sales Price/Value)", row.ValueField);
|
||||
Assert.Contains(row.Candidates, c => c.Key == "NetDocumentLocalCurrencyPosition" && c.Value == 180m && !c.IsPreferred);
|
||||
Assert.Contains(row.Candidates, c => c.Key == "NetDocumentLocalCurrencyDocument" && c.Value == 90m && !c.IsPreferred);
|
||||
Assert.Contains(row.Candidates, c => c.Key == "SalesPriceValue" && c.Value == 90m && c.IsPreferred);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildNetSalesReferenceRowsAsync_Reports_India_As_Inr_House_Currency()
|
||||
{
|
||||
await using (var db = await _dbFactory.CreateDbContextAsync())
|
||||
{
|
||||
db.Sites.Add(BuildSite());
|
||||
db.FinanceReferences.Add(new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, CheckValue = 300m, IsActive = true });
|
||||
db.CentralSalesRecords.AddRange(
|
||||
BuildCentralRecord("TRIN", "Indien", 20, 1, 0m, new DateTime(2025, 3, 1), new DateTime(2025, 3, 1), salesPriceValue: 100m, salesCurrency: "USD"),
|
||||
BuildCentralRecord("TRIN", "Indien", 21, 1, 0m, new DateTime(2025, 3, 2), new DateTime(2025, 3, 2), salesPriceValue: 200m, salesCurrency: "EUR"));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new FinanceReconciliationService(_dbFactory);
|
||||
|
||||
var rows = await service.BuildNetSalesReferenceRowsAsync(2025);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(300m, row.ActualValue);
|
||||
Assert.Equal("INR", row.ActualCurrency);
|
||||
Assert.Equal("INR", row.Currencies);
|
||||
Assert.All(row.Candidates, candidate => Assert.NotEqual("EUR, USD", candidate.Currency));
|
||||
}
|
||||
|
||||
private static CentralSalesRecord BuildCentralRecord(
|
||||
string tsc,
|
||||
string land,
|
||||
int documentEntry,
|
||||
int position,
|
||||
decimal documentTotalLocal,
|
||||
DateTime postingDate,
|
||||
DateTime invoiceDate,
|
||||
decimal vatLocal = 0m,
|
||||
decimal? salesPriceValue = null,
|
||||
string salesCurrency = "EUR")
|
||||
=> new()
|
||||
{
|
||||
StoredAtUtc = DateTime.UtcNow,
|
||||
SiteId = 1,
|
||||
SourceSystem = "TEST",
|
||||
ExtractionDate = DateTime.UtcNow,
|
||||
Tsc = tsc,
|
||||
DocumentEntry = documentEntry,
|
||||
InvoiceNumber = documentEntry.ToString(),
|
||||
PositionOnInvoice = position,
|
||||
SalesPriceValue = salesPriceValue ?? documentTotalLocal - vatLocal,
|
||||
SalesCurrency = salesCurrency,
|
||||
DocumentCurrency = salesCurrency,
|
||||
DocumentTotalLocalCurrency = documentTotalLocal,
|
||||
VatSumLocalCurrency = vatLocal,
|
||||
CompanyCurrency = "EUR",
|
||||
PostingDate = postingDate,
|
||||
InvoiceDate = invoiceDate,
|
||||
Land = land,
|
||||
DocumentType = "INV"
|
||||
};
|
||||
|
||||
private static Site BuildSite()
|
||||
=> new()
|
||||
{
|
||||
Id = 1,
|
||||
Schema = "TEST",
|
||||
TSC = "TEST",
|
||||
Land = "Test",
|
||||
SourceSystem = "TEST",
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<AppDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public AppDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new AppDbContext(_options));
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,22 @@ public class ManualExcelDataSourceAdapterTests
|
||||
return Task.FromResult(tempPath);
|
||||
}
|
||||
|
||||
public Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc)
|
||||
public Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null)
|
||||
{
|
||||
LastResolvedTsc = siteTsc;
|
||||
return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null)
|
||||
{
|
||||
LastResolvedTsc = siteTsc;
|
||||
IReadOnlyList<SharePointFileReference> result =
|
||||
[
|
||||
new(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
];
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -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<ManualExcelColumnMapping>
|
||||
{
|
||||
Map(nameof(SalesRecord.InvoiceNumber), "Invoice Number"),
|
||||
Map(nameof(SalesRecord.PositionOnInvoice), "Position on invoice"),
|
||||
Map(nameof(SalesRecord.Quantity), "Quantity"),
|
||||
Map(nameof(SalesRecord.SalesPriceValue), "=[Sales Price/Value]*[Quantity]"),
|
||||
Map(nameof(SalesRecord.SalesCurrency), "=GBP"),
|
||||
Map(nameof(SalesRecord.CompanyCurrency), "=GBP"),
|
||||
Map(nameof(SalesRecord.PostingDate), "invoice date"),
|
||||
Map(nameof(SalesRecord.DocumentType), "=Manual Excel")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var service = new ManualExcelImportService();
|
||||
|
||||
var rows = await service.ReadSalesRecordsAsync(filePath, site, mappings);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(864.15m, row.SalesPriceValue);
|
||||
Assert.Equal(7m, row.Quantity);
|
||||
Assert.Equal("GBP", row.SalesCurrency);
|
||||
Assert.Equal("GBP", row.CompanyCurrency);
|
||||
Assert.Equal(new DateTime(2025, 11, 18), row.PostingDate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateWorkbook(Action<XLWorkbook> fillWorkbook)
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -36,12 +36,20 @@
|
||||
<ItemGroup>
|
||||
<Compile Remove="TrafagSalesExporter.Tests\**\*.cs" />
|
||||
<Compile Remove="Tools\**\*.cs" />
|
||||
<Compile Remove=".tmp_tools\**\*.cs" />
|
||||
<Compile Remove="verify_probe_out*\**\*.cs" />
|
||||
<Content Remove="TrafagSalesExporter.Tests\**\*" />
|
||||
<Content Remove="Tools\**\*" />
|
||||
<Content Remove=".tmp_tools\**\*" />
|
||||
<Content Remove="verify_probe_out*\**\*" />
|
||||
<EmbeddedResource Remove="TrafagSalesExporter.Tests\**\*" />
|
||||
<EmbeddedResource Remove="Tools\**\*" />
|
||||
<EmbeddedResource Remove=".tmp_tools\**\*" />
|
||||
<EmbeddedResource Remove="verify_probe_out*\**\*" />
|
||||
<None Remove="TrafagSalesExporter.Tests\**\*" />
|
||||
<None Remove="Tools\**\*" />
|
||||
<None Remove=".tmp_tools\**\*" />
|
||||
<None Remove="verify_probe_out*\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Finance-Entscheide fuer Net Sales Actuals
|
||||
|
||||
Stand: 2026-05-11
|
||||
|
||||
Dieses Dokument haelt die fachlichen Entscheide fuer den Finance-Abgleich fest. Es ist die verbindliche Grundlage fuer das Testprogramm, die zentrale Tabelle und den Soll/Ist-Abgleich gegen `check.xlsx`.
|
||||
|
||||
## Grundsatz
|
||||
|
||||
Net Sales Actuals werden pro Land aus dem Landessystem gelesen, in der fachlich fuehrenden Hauswaehrung bewertet und gegen die Sollwerte aus `check.xlsx` verglichen.
|
||||
|
||||
Die Logik darf nicht auf einzelne Testzahlen optimiert werden. Sie muss je Jahr gleich funktionieren, sofern Sollwerte und Budgetkurse fuer das jeweilige Jahr gepflegt sind.
|
||||
|
||||
## Entscheide
|
||||
|
||||
| Thema | Entscheid |
|
||||
| --- | --- |
|
||||
| Fuehrende Waehrung | Immer Hauswaehrung des Landessystems. |
|
||||
| CHF-Umrechnung | Nur als separater Kontroll-/Reporting-Kandidat ueber Budgetkurse. Keine SNB-Tageskurse fuer den Standardabgleich. |
|
||||
| Aggregation | Pro Artikel bzw. Belegposition rechnen und summieren. |
|
||||
| Wertbasis | Nettofakturawert. |
|
||||
| Jahresabgrenzung | Buchungsdatum. |
|
||||
| Gutschriften/Storno | Separat als eigene Beleg-/Positionszeilen ausweisen. Immer ueber Artikelnummer/Positionslogik behandeln. |
|
||||
| Intercompany | In einem zweiten Schritt als 2nd-party/IC ausweisen. Nicht still aus dem Standard-Ist entfernen. |
|
||||
|
||||
## Landesspezifische Praezisierungen
|
||||
|
||||
| Land | Entscheid / Regel |
|
||||
| --- | --- |
|
||||
| IN | Immer indische Rupien (`INR`) als Hauswaehrung. Gemischte Belegwaehrungen duerfen nicht als fachliche Summenwaehrung ausgewiesen werden. |
|
||||
| IT | Hauswaehrung verwenden. Intercompany separat ausweisen und weiter fachlich abgrenzen. |
|
||||
| UK | Hauswaehrung `GBP` verwenden. Die aktuell geladene Zahl wirkt wie eine Teilmenge und muss gegen vollstaendige Jahresquelle geprueft werden. |
|
||||
| CH / AT | SAP-ZSCHWEIZ liefert Schweiz und Oesterreich aus gleichem System; Trennung ueber Buchungskreis bzw. Reporting-Land. |
|
||||
| DE | Alphaplan-Excel; finaler Jahresfile erforderlich. Sample darf nicht als Jahres-Ist verwendet werden. |
|
||||
| ES | SAGE-Excel/CSV; Serien, Gutschriften und Datumsbasis bleiben Kontrollpunkte bis fachlich final bestaetigt. |
|
||||
|
||||
## Intercompany / 2nd Party
|
||||
|
||||
Intercompany wird ueber stabile Kundenregeln klassifiziert. Aktuelle fachliche Marker:
|
||||
|
||||
- `TRAFAG`
|
||||
- `MAGNETIC SENSE`
|
||||
- `MAGNETS SENSE`
|
||||
- `GESELLSCHAFT FUER SENSORIK`
|
||||
- `GESELLSCHAFT FUR SENSORIK`
|
||||
|
||||
Weitere Uebersetzungen, Kundennummern oder lokale Schreibweisen muessen bei Bedarf ergaenzt werden.
|
||||
|
||||
Ergebnis im Reporting:
|
||||
|
||||
- Standard-Ist bleibt inklusive aller Positionen.
|
||||
- 2nd-party/IC wird als separater Betrag und als Sicht "ohne 2nd-party" gezeigt.
|
||||
- Finance entscheidet danach, ob und wo IC fuer offizielle Abgrenzungen ausgeschlossen wird.
|
||||
|
||||
## Technische Umsetzung im Programm
|
||||
|
||||
| Regel | Umsetzung |
|
||||
| --- | --- |
|
||||
| Buchungsdatum | `PostingDate` in `SalesRecord` und `CentralSalesRecord`; Finance-Abgleich filtert nach `PostingDate`. |
|
||||
| Fallback Datum | Nur falls Quelle kein Buchungsdatum liefert: `InvoiceDate`, danach `ExtractionDate`. |
|
||||
| Hauswaehrung | Finance-Abgleich weist bekannte Land-Hauswaehrungen aus, z. B. `INR` fuer Indien und `GBP` fuer UK. |
|
||||
| Nettofakturawert | Kandidat `Nettofakturawert Hauswaehrung pro Position`. |
|
||||
| B1-Belegkopfwerte | Wiederholte `DocTotal - VatSum`-Werte werden erkannt, damit Belegkopfwerte nicht pro Position multipliziert werden. |
|
||||
| Budget-CHF | Budgetkurs-Kandidat wird aus Hauswaehrung pro Position gerechnet. |
|
||||
| IC | `FinanceIntercompanyRules` klassifizieren 2nd-party/IC. |
|
||||
|
||||
## Aktuelle Kontrollpunkte
|
||||
|
||||
- UK: Aktuell ca. `395'605.82 GBP` bei `1'881` Zeilen gegen Soll `3'749'865.00`; Ursache ist primaer das fehlende UK-Manual-Mapping, weil `Sales Price/Value` als Stueckpreis statt als Positionswert gelesen wurde.
|
||||
- IN: Anzeige muss fachlich `INR` zeigen, auch wenn Quellzeilen verschiedene Belegwaehrungen enthalten.
|
||||
- IT: IC-Kundenliste final bestaetigen.
|
||||
- CH / AT: echtes SAP-Buchungsdatum pruefen, falls `ZSCHWEIZ` aktuell nur Fakturadatum liefert.
|
||||
- DE: finalen Jahresfile laden.
|
||||
- ES: Serien und Gutschriften fachlich final bestaetigen.
|
||||
|
||||
## Pruefstand 2026-05-11
|
||||
|
||||
Die Finance-Regeln wurden im Code abgesichert und mit Tests geprueft.
|
||||
|
||||
Geprueft:
|
||||
|
||||
- Finance-Abgleich nutzt `PostingDate` fuer die Jahresabgrenzung.
|
||||
- FinanceProbe-Coverage nutzt ebenfalls `PostingDate`.
|
||||
- Indien wird in der Finance-Logik als `INR`-Hauswaehrung ausgewiesen, auch wenn die Quellzeilen verschiedene Belegwaehrungen enthalten.
|
||||
- UK wird als `GBP`-Hauswaehrung ausgewiesen.
|
||||
- Wiederholte B1-Belegkopfwerte werden erkannt, damit `DocTotal - VatSum` nicht pro Position multipliziert wird.
|
||||
- 2nd-party/IC bleibt separat sichtbar.
|
||||
|
||||
Testergebnis:
|
||||
|
||||
```text
|
||||
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
|
||||
```
|
||||
|
||||
Ergebnis:
|
||||
|
||||
```text
|
||||
58/58 Tests gruen
|
||||
```
|
||||
|
||||
Bekannte Warnungen:
|
||||
|
||||
- MudBlazor Analyzer meldet bestehende `Dense`-Attribute in einzelnen Razor-Komponenten.
|
||||
- NuGet-Sicherheitsdaten konnten lokal nicht von `api.nuget.org` geladen werden.
|
||||
|
||||
## UK / England Befund
|
||||
|
||||
England/UK ist im System vorhanden und wird im FinanceProbe-Abgleich angezeigt.
|
||||
|
||||
Aktueller Befund aus der Probe:
|
||||
|
||||
| Kennzahl | Wert |
|
||||
| --- | ---: |
|
||||
| Land | UK / England |
|
||||
| TSC | `TRUK` |
|
||||
| Hauswaehrung | `GBP` |
|
||||
| Geladene Zeilen | `1'881` |
|
||||
| Ist-Wert | `395'605.82 GBP` |
|
||||
| Sollwert check.xlsx | `3'749'865.00` |
|
||||
| Differenz | `-3'354'259.18` |
|
||||
|
||||
Interpretation:
|
||||
|
||||
- Die UK-Zahl ist fachlich nicht plausibel als Jahreswert.
|
||||
- Wahrscheinlich wurde nur eine Teilmenge bzw. eine Monatsdatei geladen.
|
||||
- Der SharePoint-Ordner `Import/Finance/UK_B1` enthaelt Dateien nach Muster `ddMMyy_TRUK.xlsx`, z. B. `010426_TRUK.xlsx` und `010526_TRUK.xlsx`.
|
||||
- Die App soll die neueste passende Datei lesen. Fuer einen Jahresvergleich muss geklaert werden, ob die neueste Datei kumulierte Jahresdaten oder nur Monatsdaten enthaelt.
|
||||
|
||||
Naechster fachlicher Check fuer UK:
|
||||
|
||||
- Bestaetigen, ob `010526_TRUK.xlsx` kumuliert Januar bis Mai oder nur Mai enthaelt.
|
||||
- Falls Monatsdateien geliefert werden, muss Finance entscheiden:
|
||||
- alle Monatsdateien 2025 aufsummieren, oder
|
||||
- nur einen kumulierten Jahresfile lesen.
|
||||
|
||||
## Nachtrag 2026-05-11: UK_B1 Mapping
|
||||
|
||||
Der UK-Befund wurde nachtraeglich technisch untersucht.
|
||||
|
||||
Wichtige Feststellungen:
|
||||
|
||||
- Quelle bleibt `UK_B1`.
|
||||
- Der Standort ist `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`.
|
||||
- Der korrekte SharePoint-Ordner ist:
|
||||
|
||||
```text
|
||||
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1
|
||||
```
|
||||
|
||||
- Lokal war fuer `TRUK` kein grafisches Manual-Excel-Mapping vorhanden.
|
||||
- Dadurch hat der Fallback-Importer `Sales Price/Value` direkt als Positionswert uebernommen.
|
||||
- In der UK-B1-Datei ist `Sales Price/Value` aber ein Stueckpreis.
|
||||
- Der fachliche Positionswert muss pro Belegposition berechnet werden:
|
||||
|
||||
```text
|
||||
Positionswert = [Sales Price/Value] * [Quantity]
|
||||
```
|
||||
|
||||
Technische Probe auf den bereits importierten UK-Zeilen:
|
||||
|
||||
| Variante | Wert |
|
||||
| --- | ---: |
|
||||
| Bisherige Summe `SalesPriceValue` | `395'605.82 GBP` |
|
||||
| Rekonstruierte Summe `SalesPriceValue * Quantity` | `3'533'348.89 GBP` |
|
||||
| Sollwert check.xlsx | `3'749'865.00 GBP` |
|
||||
| Restdifferenz nach Multiplikation | ca. `-216'516.11 GBP` |
|
||||
|
||||
Bewertung:
|
||||
|
||||
- Die grosse UK-Abweichung war hauptsaechlich ein Mapping-Fehler.
|
||||
- Nach korrekter Multiplikation bleibt eine relevante Restdifferenz.
|
||||
- Diese Restdifferenz muss gegen UK-spezifische Netto-/Discount-/Fracht-/Nebenpositionsspalten oder eine andere Abgrenzung im UK-Export geprueft werden.
|
||||
- Die bisherige Interpretation "nur Monatsfile/Teilmenge" ist nicht mehr die wahrscheinlichste Hauptursache, bleibt aber als Datenvollstaendigkeitscheck offen.
|
||||
|
||||
Ziel-Mapping fuer `TRUK`:
|
||||
|
||||
| Zielfeld | Quelle |
|
||||
| --- | --- |
|
||||
| `Tsc` | `TSC` |
|
||||
| `Land` | `Land` |
|
||||
| `InvoiceNumber` | `Invoice Number` |
|
||||
| `PositionOnInvoice` | `Position on invoice` |
|
||||
| `Material` | `Material` |
|
||||
| `Name` | `Name` |
|
||||
| `ProductGroup` | `Product Group` |
|
||||
| `Quantity` | `Quantity` |
|
||||
| `CustomerNumber` | `Customer number` |
|
||||
| `CustomerName` | `Customer name` |
|
||||
| `CustomerCountry` | `Customer country` |
|
||||
| `SalesPriceValue` | `=[Sales Price/Value]*[Quantity]` |
|
||||
| `SalesCurrency` | `=GBP` |
|
||||
| `DocumentCurrency` | `=GBP` |
|
||||
| `CompanyCurrency` | `=GBP` |
|
||||
| `PostingDate` | `invoice date` |
|
||||
| `InvoiceDate` | `invoice date` |
|
||||
| `DocumentType` | `=Manual Excel` |
|
||||
|
||||
Code-Stand dazu:
|
||||
|
||||
- `ManualExcelImportService` unterstuetzt im grafischen Manual-Excel-Mapping einfache Multiplikationsausdruecke mit Excel-Headern:
|
||||
|
||||
```text
|
||||
=[Header A]*[Header B]
|
||||
```
|
||||
|
||||
- Konstanten wie `=GBP` funktionieren unveraendert.
|
||||
- `DatabaseSeedService` repariert den alten/falschen England-Pfad auf `UK_B1` und seedet das `TRUK`-Mapping.
|
||||
- Ein Unit-Test prueft, dass `SalesPriceValue = [Sales Price/Value] * [Quantity]` korrekt gelesen wird.
|
||||
|
||||
Aktueller Verifikationsstand:
|
||||
|
||||
- Die neue UK-Mapping-Logik ist implementiert.
|
||||
- `DatabaseSeedService` seedet das UK-Mapping nur, wenn `ManualExcelColumnMappings` sauber auf `Sites` referenziert.
|
||||
- Damit blockieren alte SQLite-Reparaturreferenzen wie `Sites_repair_old` den Initialisierungslauf nicht mehr.
|
||||
- Der volle Testlauf ist gruen:
|
||||
|
||||
```text
|
||||
59/59 Tests gruen
|
||||
```
|
||||
|
||||
Naechster praktischer Schritt:
|
||||
|
||||
- App oder FinanceProbe starten, damit die lokale DB den Seed/Repair bekommt.
|
||||
- Danach UK per `/run/export/TRUK` gegen SharePoint `UK_B1` neu laden.
|
||||
- Anschliessend `/finance` erneut gegen `check.xlsx` pruefen.
|
||||
|
||||
Praktischer Nachtrag:
|
||||
|
||||
- Lokale DB ist aktualisiert: `TRUK` hat den `UK_B1`-Pfad und `18` aktive Mapping-Zeilen.
|
||||
- FinanceProbe laeuft auf `http://127.0.0.1:5099` und `/finance` antwortet.
|
||||
- Der neue `/run/export/TRUK`-Lauf konnte noch nicht abgeschlossen werden, weil die lokale SharePoint-/Graph-Authentifizierung scheitert:
|
||||
|
||||
```text
|
||||
ClientSecretCredential authentication failed
|
||||
127.0.0.1:9 connection refused
|
||||
```
|
||||
|
||||
- Bis dieser Zugriff funktioniert, bleibt `CentralSalesRecords` fuer UK auf dem alten Importstand.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1450" viewBox="0 0 1800 1450" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Finance-Fluss je Land</title>
|
||||
<desc id="desc">Blockdiagramm fuer den buchhalterischen Finanzfluss je Land vom Quellsystem bis zum Soll-Ist-Abgleich.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #f6f7f9; }
|
||||
.panel { fill: #ffffff; stroke: #d8dee8; stroke-width: 1.4; rx: 8; }
|
||||
.head { fill: #22324a; }
|
||||
.source { fill: #e9f1fb; stroke: #8eb3df; }
|
||||
.mapping { fill: #eef8f0; stroke: #8bc790; }
|
||||
.finance { fill: #fff4e3; stroke: #e1a84d; }
|
||||
.check { fill: #f4eefb; stroke: #b497d6; }
|
||||
.warn { fill: #fff7cc; stroke: #d5b53b; }
|
||||
.line { stroke: #7b8798; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
|
||||
.title { font: 700 28px "Segoe UI", Arial, sans-serif; fill: #17202a; }
|
||||
.subtitle { font: 400 15px "Segoe UI", Arial, sans-serif; fill: #667085; }
|
||||
.h2 { font: 700 17px "Segoe UI", Arial, sans-serif; fill: #ffffff; }
|
||||
.country { font: 700 16px "Segoe UI", Arial, sans-serif; fill: #17202a; }
|
||||
.txt { font: 400 13px "Segoe UI", Arial, sans-serif; fill: #17202a; }
|
||||
.small { font: 400 12px "Segoe UI", Arial, sans-serif; fill: #667085; }
|
||||
.mono { font: 600 12px "Consolas", "Segoe UI", monospace; fill: #17202a; }
|
||||
</style>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,3 L0,6 Z" fill="#7b8798"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" x="0" y="0" width="1800" height="1450"/>
|
||||
<text class="title" x="48" y="58">Finance-Fluss je Land: was buchhalterisch im Hintergrund passiert</text>
|
||||
<text class="subtitle" x="48" y="86">Zielbild fuer Finance: je Land wird der Nettofakturawert in Hauswaehrung pro Position gelesen, per Buchungsdatum abgegrenzt und gegen check.xlsx verglichen.</text>
|
||||
|
||||
<rect class="panel" x="48" y="118" width="1704" height="130"/>
|
||||
<rect class="head" x="48" y="118" width="1704" height="34" rx="8"/>
|
||||
<text class="h2" x="68" y="141">Globale Finance-Regeln</text>
|
||||
<text class="txt" x="70" y="176">1. Datumsabgrenzung: Buchungsdatum. Technischer Fallback, falls Quelle kein Buchungsdatum liefert: Fakturadatum, danach Extraktionsdatum.</text>
|
||||
<text class="txt" x="70" y="200">2. Waehrung: Hauswaehrung ist fuehrend. CHF wird nur als eigener Kandidat ueber Budgetkurs gerechnet, nicht mit Tageskurs.</text>
|
||||
<text class="txt" x="70" y="224">3. Wertbasis: Nettofakturawert pro Belegposition. Wiederholte B1-Belegkopfwerte werden erkannt, damit DocTotal nicht pro Position multipliziert wird.</text>
|
||||
<text class="txt" x="945" y="176">4. Gutschriften/Storno: eigene Beleg-/Positionszeilen bleiben sichtbar und laufen ueber Artikel/Position.</text>
|
||||
<text class="txt" x="945" y="200">5. Intercompany: 2nd/3rd Party wird separat ausgewiesen; der Abzug ist Kontrollsicht, nicht stiller Standardabzug.</text>
|
||||
<text class="txt" x="945" y="224">6. Sollwert: FinanceReferences/check.xlsx je Jahr; gleiche Logik funktioniert fuer andere Jahre mit passenden Sollwerten und Budgetkursen.</text>
|
||||
|
||||
<rect class="panel" x="48" y="278" width="1704" height="148"/>
|
||||
<rect class="head" x="48" y="278" width="1704" height="34" rx="8"/>
|
||||
<text class="h2" x="68" y="301">Zentraler Algorithmus im Programm</text>
|
||||
<g transform="translate(78 332)">
|
||||
<rect class="source" x="0" y="0" width="245" height="64" rx="6"/>
|
||||
<text class="txt" x="16" y="25">Landesquelle lesen</text>
|
||||
<text class="small" x="16" y="46">SAP OData, HANA, Excel/CSV</text>
|
||||
<path class="line" d="M245 32 H305"/>
|
||||
<rect class="mapping" x="305" y="0" width="250" height="64" rx="6"/>
|
||||
<text class="txt" x="321" y="25">Grafisches Mapping</text>
|
||||
<text class="small" x="321" y="46">Zielmodell SalesRecord</text>
|
||||
<path class="line" d="M555 32 H615"/>
|
||||
<rect class="mapping" x="615" y="0" width="250" height="64" rx="6"/>
|
||||
<text class="txt" x="631" y="25">Zentrale Tabelle</text>
|
||||
<text class="small" x="631" y="46">CentralSalesRecords</text>
|
||||
<path class="line" d="M865 32 H925"/>
|
||||
<rect class="finance" x="925" y="0" width="285" height="64" rx="6"/>
|
||||
<text class="txt" x="941" y="25">FinanceReconciliationService</text>
|
||||
<text class="small" x="941" y="46">Jahr, Hauswaehrung, Netto, IC</text>
|
||||
<path class="line" d="M1210 32 H1270"/>
|
||||
<rect class="check" x="1270" y="0" width="285" height="64" rx="6"/>
|
||||
<text class="txt" x="1286" y="25">Vergleich gegen Soll</text>
|
||||
<text class="small" x="1286" y="46">FinanceReferences / check.xlsx</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(48 466)">
|
||||
<rect class="head" x="0" y="0" width="1704" height="34" rx="8"/>
|
||||
<text class="h2" x="20" y="23">Landesspezifische Fluesse</text>
|
||||
|
||||
<!-- row helper coordinates: x source 20, map 310, finance 600, check 965, note 1270 -->
|
||||
<g transform="translate(0 54)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">CH / AT</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">SAP ZSCHWEIZ OData</text>
|
||||
<text class="small" x="151" y="55">BUKRS 1100=CH, 1200=AT</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Mapping ZSCHWEIZSet</text>
|
||||
<text class="small" x="451" y="55">LAND1/TSC trennt CH und AT</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">NETWR_HC pro Position</text>
|
||||
<text class="small" x="761" y="55">Hauswaehrung CHF/EUR, FKDAT aktuell als Datum</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Soll je Land</text>
|
||||
<text class="small" x="1161" y="55">CH und AT separat in FinanceReferences</text>
|
||||
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Offen</text>
|
||||
<text class="small" x="1456" y="55">echtes SAP-BUDAT noch klaeren/mappen</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 158)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">FR</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
|
||||
<text class="small" x="151" y="55">OINV/INV1, ORIN/RIN1</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Legacy B1 oder grafisches HANA</text>
|
||||
<text class="small" x="451" y="55">DocDate=PostingDate, TaxDate=InvoiceDate</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Positions-Netto in Hauswaehrung EUR</text>
|
||||
<text class="small" x="761" y="55">keine DocTotal-Multiplikation</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll FR</text>
|
||||
<text class="small" x="1161" y="55">bisher nahe an check.xlsx</text>
|
||||
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">IC sichtbar</text>
|
||||
<text class="small" x="1456" y="55">2nd/3rd Party Kandidat</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 262)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">IT</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
|
||||
<text class="small" x="151" y="55">Italien ist B1</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Hauswaehrung EUR</text>
|
||||
<text class="small" x="451" y="55">B1-Headerwerte werden erkannt</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Netto nach Position, IC separat</text>
|
||||
<text class="small" x="761" y="55">Trafag/Magnetic Sense usw. als 2nd Party</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll IT</text>
|
||||
<text class="small" x="1161" y="55">mit und ohne IC-Deduction sichtbar</text>
|
||||
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
|
||||
<text class="small" x="1456" y="55">IC-Kundenliste finalisieren</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 366)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">UK</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">SharePoint Excel</text>
|
||||
<text class="small" x="151" y="55">neueste Datei ddMMyy_TRUK.xlsx</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Manual Excel Mapper</text>
|
||||
<text class="small" x="451" y="55">Hauswaehrung GBP fuehrend</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Delta/neueste Datei lesen</text>
|
||||
<text class="small" x="761" y="55">Netto pro Position, Jahr nach Buchungsdatum</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll UK</text>
|
||||
<text class="small" x="1161" y="55">Reporting in GBP, CHF nur Kandidat</text>
|
||||
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Quelle bleibt Ordner</text>
|
||||
<text class="small" x="1456" y="55">Export in gleichen SharePoint-Ort</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 470)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">IN</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">SAGE / HANA</text>
|
||||
<text class="small" x="151" y="55">Indien wird als INR gefuehrt</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Waehrung normalisieren</text>
|
||||
<text class="small" x="451" y="55">Hauswaehrung INR statt Belegmix</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Nettofakturawert INR</text>
|
||||
<text class="small" x="761" y="55">Budget-CHF nur Kontrollkandidat</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll IN</text>
|
||||
<text class="small" x="1161" y="55">bisher nahe an check.xlsx</text>
|
||||
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
|
||||
<text class="small" x="1456" y="55">keine gemischten Belegwaehrungen summieren</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 574)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">ES</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">SAGE Export Excel/CSV</text>
|
||||
<text class="small" x="151" y="55">SharePoint oder lokales File</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Manual Excel/CSV Mapper</text>
|
||||
<text class="small" x="451" y="55">LineRegistrationDate als Buchungsdatum</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Netto pro Position in EUR</text>
|
||||
<text class="small" x="761" y="55">CSV und XLSX technisch erlaubt</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll ES</text>
|
||||
<text class="small" x="1161" y="55">Differenz fachlich klaeren</text>
|
||||
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Kontrollpunkt</text>
|
||||
<text class="small" x="1456" y="55">Serien/Gutschriften bestaetigen</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 678)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">DE</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">Alphaplan Excel</text>
|
||||
<text class="small" x="151" y="55">Jahresfile von Deutschland</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">Manual Excel Mapper</text>
|
||||
<text class="small" x="451" y="55">NettoPreisGesamtX, Belegdatum</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Netto pro Position in EUR</text>
|
||||
<text class="small" x="761" y="55">Sample nicht als Jahres-Ist verwenden</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll DE</text>
|
||||
<text class="small" x="1161" y="55">erst nach finalem Jahresfile</text>
|
||||
<rect class="warn" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">Offen</text>
|
||||
<text class="small" x="1456" y="55">vollstaendiger 2025-Export fehlt</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(0 782)">
|
||||
<rect class="panel" x="0" y="0" width="1704" height="92"/>
|
||||
<text class="country" x="20" y="30">US</text>
|
||||
<rect class="source" x="135" y="14" width="250" height="58" rx="6"/>
|
||||
<text class="txt" x="151" y="35">BI1 / SAP B1 HANA</text>
|
||||
<text class="small" x="151" y="55">US B1 Schema</text>
|
||||
<path class="line" d="M385 43 H435"/>
|
||||
<rect class="mapping" x="435" y="14" width="260" height="58" rx="6"/>
|
||||
<text class="txt" x="451" y="35">B1-Positionsdaten</text>
|
||||
<text class="small" x="451" y="55">DocDate=PostingDate</text>
|
||||
<path class="line" d="M695 43 H745"/>
|
||||
<rect class="finance" x="745" y="14" width="350" height="58" rx="6"/>
|
||||
<text class="txt" x="761" y="35">Netto pro Position in USD</text>
|
||||
<text class="small" x="761" y="55">CHF Budget als Kandidat</text>
|
||||
<path class="line" d="M1095 43 H1145"/>
|
||||
<rect class="check" x="1145" y="14" width="270" height="58" rx="6"/>
|
||||
<text class="txt" x="1161" y="35">Vergleich Soll US</text>
|
||||
<text class="small" x="1161" y="55">FinanceReferences je Jahr</text>
|
||||
<rect class="mapping" x="1440" y="14" width="235" height="58" rx="6"/>
|
||||
<text class="txt" x="1456" y="35">IC sichtbar</text>
|
||||
<text class="small" x="1456" y="55">gleiche Regel wie B1-Laender</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect class="panel" x="48" y="1382" width="1704" height="44"/>
|
||||
<text class="small" x="68" y="1409">Stand: 2026-05-11. Abgeleitet aus Services/DataSources, MappedSalesRecordComposer, CentralSalesRecords, DatabaseSeedService und FinanceReconciliationService. Das Diagramm beschreibt die Buchhaltungslogik, nicht einzelne Testzahlen.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,198 @@
|
||||
// ============================================================
|
||||
// DAX MEASURES: Fluktuation gemaess formeln.docx
|
||||
// ============================================================
|
||||
// Voraussetzung:
|
||||
// - Query/Tabelle: HR_KPI_DATEN_SAP mit aktiven Mitarbeitenden
|
||||
// - Query/Tabelle: Rexx_Ausgeschieden aus rexx_ausgeschieden.txt
|
||||
// - Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] filtert:
|
||||
// nur Arbeitnehmerkuendigungen, keine Aushilfen/Praktikanten/
|
||||
// Werkstudenten/Lehrlinge, keine Pensionierungen, keine befristeten
|
||||
// Vertraege, keine Kuendigungen durch Trafag.
|
||||
//
|
||||
// Hinweis:
|
||||
// HR_KPI_DATEN_SAP ist aktuell eine Stichtags-/Monatstabelle. Falls spaeter
|
||||
// echte Monats-Snapshots geladen werden, funktionieren die Durchschnitts-
|
||||
// Headcount-Measures ueber Monat/Quartal/Jahr genauer.
|
||||
// ============================================================
|
||||
|
||||
Headcount Festangestellt =
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(HR_KPI_DATEN_SAP[Personalnummer]),
|
||||
HR_KPI_DATEN_SAP[Ist_Aktiv] = TRUE(),
|
||||
HR_KPI_DATEN_SAP[Mitarbeitertyp] = "Festangestellt"
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Headcount Aktiv Total =
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(HR_KPI_DATEN_SAP[Personalnummer]),
|
||||
HR_KPI_DATEN_SAP[Ist_Aktiv] = TRUE()
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Austritte Total Rexx =
|
||||
COALESCE(DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]), 0)
|
||||
|
||||
Austritte Arbeitnehmerkuendigung =
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Arbeitnehmerkuendigung] = TRUE()
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Austritte Fluktuationsrelevant =
|
||||
VAR Perioden = VALUES(HR_KPI_DATEN_SAP[Periode])
|
||||
VAR Basis =
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE()
|
||||
)
|
||||
VAR NachPeriode =
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
|
||||
TREATAS(Perioden, Rexx_Ausgeschieden[Austrittsmonat])
|
||||
)
|
||||
RETURN
|
||||
COALESCE(
|
||||
IF(
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) || ISINSCOPE(HR_KPI_DATEN_SAP[Periode]),
|
||||
NachPeriode,
|
||||
Basis
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Austritte Nicht Fluktuationsrelevant =
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = FALSE()
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Fluktuation Monat % =
|
||||
COALESCE(
|
||||
DIVIDE(
|
||||
[Austritte Fluktuationsrelevant],
|
||||
[Headcount Festangestellt]
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Avg Headcount Quartal =
|
||||
COALESCE(
|
||||
AVERAGEX(
|
||||
VALUES(HR_KPI_DATEN_SAP[Periode]),
|
||||
[Headcount Festangestellt]
|
||||
),
|
||||
[Headcount Festangestellt],
|
||||
0
|
||||
)
|
||||
|
||||
Austritte Quartal =
|
||||
VAR HatPeriodenfilter =
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) ||
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Jahr]) ||
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Monat])
|
||||
VAR Auswertungsdatum =
|
||||
IF(
|
||||
HatPeriodenfilter,
|
||||
MAX(HR_KPI_DATEN_SAP[Periode]),
|
||||
MAX(Rexx_Ausgeschieden[Austrittsmonat])
|
||||
)
|
||||
VAR QuartalsStart =
|
||||
DATE(
|
||||
YEAR(Auswertungsdatum),
|
||||
1 + 3 * QUOTIENT(MONTH(Auswertungsdatum) - 1, 3),
|
||||
1
|
||||
)
|
||||
VAR QuartalsEnde = EOMONTH(QuartalsStart, 2)
|
||||
RETURN
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
|
||||
FILTER(
|
||||
ALL(Rexx_Ausgeschieden[Austrittsmonat]),
|
||||
Rexx_Ausgeschieden[Austrittsmonat] >= QuartalsStart &&
|
||||
Rexx_Ausgeschieden[Austrittsmonat] <= QuartalsEnde
|
||||
)
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Fluktuation Quartal % =
|
||||
COALESCE(
|
||||
DIVIDE(
|
||||
[Austritte Quartal],
|
||||
[Avg Headcount Quartal]
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Fluktuation Hochrechnung Jahr % =
|
||||
COALESCE([Fluktuation Quartal %] * 4, 0)
|
||||
|
||||
Avg Headcount Jahr =
|
||||
COALESCE(
|
||||
AVERAGEX(
|
||||
VALUES(HR_KPI_DATEN_SAP[Periode]),
|
||||
[Headcount Festangestellt]
|
||||
),
|
||||
[Headcount Festangestellt],
|
||||
0
|
||||
)
|
||||
|
||||
Austritte Jahr =
|
||||
VAR HatPeriodenfilter =
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Periode]) ||
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Jahr]) ||
|
||||
ISFILTERED(HR_KPI_DATEN_SAP[Monat])
|
||||
VAR Auswertungsdatum =
|
||||
IF(
|
||||
HatPeriodenfilter,
|
||||
MAX(HR_KPI_DATEN_SAP[Periode]),
|
||||
MAX(Rexx_Ausgeschieden[Austrittsmonat])
|
||||
)
|
||||
VAR Auswertungsjahr = YEAR(Auswertungsdatum)
|
||||
RETURN
|
||||
COALESCE(
|
||||
CALCULATE(
|
||||
DISTINCTCOUNT(Rexx_Ausgeschieden[Personalnummer]),
|
||||
Rexx_Ausgeschieden[Ist_Fluktuationsrelevant] = TRUE(),
|
||||
FILTER(
|
||||
ALL(Rexx_Ausgeschieden[Austrittsmonat]),
|
||||
YEAR(Rexx_Ausgeschieden[Austrittsmonat]) = Auswertungsjahr
|
||||
)
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
Fluktuation Jahr Effektiv % =
|
||||
COALESCE(
|
||||
DIVIDE(
|
||||
[Austritte Jahr],
|
||||
[Avg Headcount Jahr]
|
||||
),
|
||||
0
|
||||
)
|
||||
|
||||
BU_Tage_Total =
|
||||
COALESCE(SUM(HR_KPI_DATEN_SAP[BU_Tage]), 0)
|
||||
|
||||
NBU_Tage_Total =
|
||||
COALESCE(SUM(HR_KPI_DATEN_SAP[NBU_Tage]), 0)
|
||||
|
||||
Unfalltage Total =
|
||||
[BU_Tage_Total] + [NBU_Tage_Total]
|
||||
|
||||
Fluktuation Ausschlussgrund Anzahl =
|
||||
COUNTROWS(Rexx_Ausgeschieden)
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user