Commit pending finance and Power BI work

This commit is contained in:
2026-05-13 07:33:00 +02:00
parent 1cd0ad998f
commit 001e2a73d5
44 changed files with 3210 additions and 104 deletions
+122
View File
@@ -2,6 +2,112 @@
Stand: 2026-05-05
## 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.
+46 -1
View File
@@ -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

+212
View File
@@ -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.