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