From 819a0231630ffce4fe9ea585fccc0a1b39012917 Mon Sep 17 00:00:00 2001 From: metacube Date: Mon, 11 May 2026 08:43:52 +0200 Subject: [PATCH] Add SharePoint manual source handling and finance status --- .../Components/Pages/Standorte.razor | 2 +- TrafagSalesExporter/HANDOFF_2026-04-15.md | 70 ++++++ TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 41 +++ .../DataSources/DataSourceFetchResult.cs | 6 + .../ManualExcelDataSourceAdapter.cs | 44 +++- .../Services/DatabaseSeedService.cs | 31 +++ .../Services/FinanceReconciliationService.cs | 12 +- .../Services/ISharePointUploadService.cs | 3 + .../Services/SharePointUploadService.cs | 96 +++++++ .../Services/SiteExportService.cs | 19 +- .../Services/StandortePageService.cs | 27 +- .../Tools/FinanceProbe/Program.cs | 183 +++++++++++++- .../ManualExcelDataSourceAdapterTests.cs | 161 ++++++++++++ .../docs/PROGRAMM_DIAGRAMME.md | 11 + .../docs/finance_status_2025.svg | 236 ++++++++++++++++++ TrafagSalesExporter/lastchange.md | 69 +++++ 16 files changed, 983 insertions(+), 28 deletions(-) create mode 100644 TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs create mode 100644 TrafagSalesExporter/docs/finance_status_2025.svg diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index 1b173d2..bd1afc5 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -391,7 +391,7 @@ Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-/CSV-Datei gelesen und in `CentralSalesRecords` übernommen. diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 080695f..e9f6dc9 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,76 @@ Stand: 2026-05-05 +## Nachtrag 2026-05-08 Manual Excel/CSV / SharePoint-Ordner + +Aktueller Stand fuer manuelle Quellen: + +- `MANUAL_EXCEL` ist fachlich Manual Excel/CSV. +- Unterstuetzt werden `.xlsx` und `.csv`; altes `.xls` ist nicht der Zielpfad. +- Lokale Datei als Quelle: + - App liest die Datei. + - App erzeugt eine neue Exportdatei im selben lokalen Ordner. +- SharePoint-Datei als Quelle: + - App laedt die Datei temporaer herunter. + - App erzeugt eine neue Exportdatei und laedt sie in denselben SharePoint-Ordner hoch. +- SharePoint-Ordner als Quelle: + - App waehlt automatisch die neueste passende `.xlsx`/`.csv` fuer den Standort. + - Primaeres Muster: `ddMMyy_TSC.xlsx` oder `ddMMyy_TSC.csv`. + - Fallback: SharePoint `LastModifiedDateTime`. + +England / UK: + +- Standort `England`, `TSC = TRUK`, `SourceSystem = MANUAL_EXCEL`. +- Quelle ist ein SharePoint-Ordner: + +```text +https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1 +``` + +- Beispielauswahl: + - `010526_TRUK.xlsx` ist neuer als `010426_TRUK.xlsx`. +- Exportdateien werden wieder in `Import/Finance/UK_B1` geschrieben. +- Befund am 2026-05-08: England zeigte lokal faelschlich auf die Deutschland-Alphaplan-Datei; lokale DB wurde korrigiert. +- `DatabaseSeedService` repariert kuenftig einen leeren England/TRUK-Manual-Pfad auf den UK_B1-Ordner. + +Spanien / Sage: + +- Spanien nutzt `MANUAL_EXCEL` als technischen Importpfad fuer den Sage-Export. +- Die Datei `Spain_Sales_2025.csv` konnte gelesen werden (`4'341` Zeilen). +- Fehler war danach der Exportpfad: die SharePoint-URL wurde als lokaler Dateipfad interpretiert. +- Fix: SharePoint-Manual-Quellen liefern keinen `ReferenceFilePath` mehr, sondern erzeugen eine neue Exportdatei im Quellordner. + +Deutschland / Alphaplan: + +- Deutschland nutzt `MANUAL_EXCEL` als technischen Importpfad fuer Alphaplan-Excel. +- Grafisches Mapping ist vorhanden. +- Offener Punkt: konkreter Alphaplan-Datei-/SharePoint-Pfad muss im Standort hinterlegt sein, sonst kommt `Standort 'Deutschland' hat keine manuelle Excel-Datei.` + +Verifikation: + +- Tests `55/55` erfolgreich. + +## Nachtrag 2026-05-08 FinanceProbe fuer mehr Laender + +FinanceProbe wurde erweitert: + +- `FinanceReferences` werden vollstaendig angezeigt, nicht nur bei aktivem Standort oder vorhandenen Ist-Daten. +- Dadurch sind alle Soll-Laender aus der Finance-Konfiguration im Meeting sichtbar. +- Neue Sektion `Datenabdeckung je Standort` zeigt je Standort: + - Quelle/System + - Manual-/SharePoint-Pfad + - Aktivstatus + - Anzahl 2025-Zeilen in `CentralSalesRecords` + - Summe `SalesPriceValue` + - Waehrungen und Datumsbereich + - letzter Exportstatus/Fehler +- CH/AT-Erkennung im Finance-Service wurde geschaerft, damit `ZSCHWEIZ`-Zeilen mit Land `AT` Oesterreich zugeordnet werden koennen. + +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-07 Mapper-Konsolidierung / Finance-Konfiguration Architekturstand: diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 733ff6e..7e1dd8f 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,47 @@ Stand: 2026-05-05 +## Nachtrag 2026-05-08 Manual Excel/CSV SharePoint-Automatik + +Erledigt: + +- SharePoint-Ordner koennen bei Manual Excel/CSV als Quelle hinterlegt werden. +- Bei Ordnern wird automatisch die neueste passende `.xlsx`/`.csv` ausgewaehlt. +- Dateinamenmuster fuer bevorzugte Auswahl: `ddMMyy_TSC.xlsx` bzw. `ddMMyy_TSC.csv`. +- Manual-Export schreibt die erzeugte Exportdatei in den Quellordner zurueck: + - lokal: gleicher lokaler Ordner + - SharePoint: gleicher SharePoint-Ordner +- England/TRUK ist lokal auf den SharePoint-Ordner `Import/Finance/UK_B1` korrigiert. +- Spanien-Fehler nach erfolgreichem Einlesen der SharePoint-CSV ist behoben. + +Naechste konkrete Schritte: + +1. App neu starten, damit die Seed-/Repair-Logik aktiv ist. +2. England/TRUK exportieren und pruefen, ob die App `010526_TRUK.xlsx` statt `010426_TRUK.xlsx` auswaehlt. +3. Im SharePoint-Ordner `Import/Finance/UK_B1` pruefen, ob die neue Exportdatei dort wieder abgelegt wird. +4. Deutschland/Alphaplan: im Standort den korrekten Alphaplan-Excel- oder SharePoint-Pfad hinterlegen. +5. Deutschland exportieren und Mapping gegen die Alphaplan-Datei validieren. +6. Falls UK-Dateinamen spaeter ein anderes Muster bekommen, Auswahlregel erweitern. + +## Nachtrag 2026-05-08 FinanceProbe + +Erledigt: + +- FinanceProbe zeigt alle Finance-Referenzen 2025. +- Datenabdeckung je Standort wurde ergaenzt. +- CH/AT-Zuordnung wurde fuer `ZSCHWEIZ` geschaerft. + +Naechste fachliche Schritte: + +1. Nach Export von England, Schweiz/Oesterreich, Spanien und Deutschland die FinanceProbe neu laden. +2. In der Sektion `Datenabdeckung je Standort` pruefen, ob Zeilen 2025 und Periode plausibel sind. +3. Fuer Laender mit `Keine Daten` entscheiden: + - Datenquelle fehlt + - Standort deaktiviert + - Mapping/Export noch nicht gelaufen + - Referenz ist nur zukuenftig relevant +4. Fuer AT/CH nach `ZSCHWEIZ`-Export pruefen, ob `LAND1` korrekt `AT` bzw. `CH` liefert. + ## Nachtrag 2026-05-07 nach Mapper-/Finance-Aufraeumung Erledigt: diff --git a/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs b/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs index d76b13a..d2d354c 100644 --- a/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs +++ b/TrafagSalesExporter/Services/DataSources/DataSourceFetchResult.cs @@ -11,4 +11,10 @@ public sealed class DataSourceFetchResult /// SiteExportService erzeugt dann keine neue Excel-Datei. /// public string? ReferenceFilePath { get; init; } + + public string? LocalOutputDirectoryOverride { get; init; } + + public string? SharePointUploadFolderOverride { get; init; } + + public string? SharePointUploadLandOverride { get; init; } } diff --git a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs index 9d23f5c..0fa4a7b 100644 --- a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs +++ b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs @@ -29,12 +29,15 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter var manualImportPath = site.ManualImportFilePath.Trim(); string filePath; + string? localOutputDirectory = null; + string? sharePointUploadFolder = null; string? tempManualImportPath = null; try { if (File.Exists(manualImportPath)) { filePath = manualImportPath; + localOutputDirectory = Path.GetDirectoryName(Path.GetFullPath(manualImportPath)); } else if (LooksLikeSharePointReference(manualImportPath)) { @@ -55,10 +58,22 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", siteId: site.Id, land: site.Land, details: manualImportPath); + var sharePointFileReference = manualImportPath; + if (LooksLikeSharePointFolderReference(manualImportPath)) + { + var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, + spConfig.SiteUrl, manualImportPath, site.TSC); + sharePointFileReference = latestFile.FileReference; + await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt", + siteId: site.Id, land: site.Land, details: sharePointFileReference); + } + tempManualImportPath = await _sharePointService.DownloadToTempFileAsync( spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, - spConfig.SiteUrl, manualImportPath); - filePath = manualImportPath; + spConfig.SiteUrl, sharePointFileReference); + filePath = sharePointFileReference; + sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl); } else { @@ -75,7 +90,9 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter return new DataSourceFetchResult { Records = records, - ReferenceFilePath = filePath + LocalOutputDirectoryOverride = localOutputDirectory, + SharePointUploadFolderOverride = sharePointUploadFolder, + SharePointUploadLandOverride = sharePointUploadFolder is null ? null : string.Empty }; } finally @@ -90,4 +107,25 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase); + + private static bool LooksLikeSharePointFolderReference(string path) + => LooksLikeSharePointReference(path) && + string.IsNullOrWhiteSpace(Path.GetExtension(path.TrimEnd('/'))); + + private static string ResolveSharePointParentFolder(string fileReference, string siteUrl) + { + var remotePath = fileReference.Trim('/').Trim(); + if (Uri.TryCreate(fileReference, UriKind.Absolute, out var fileUri) && + Uri.TryCreate(siteUrl, UriKind.Absolute, out var siteUri)) + { + var absolutePath = Uri.UnescapeDataString(fileUri.AbsolutePath); + var sitePath = siteUri.AbsolutePath.TrimEnd('/'); + if (absolutePath.StartsWith(sitePath, StringComparison.OrdinalIgnoreCase)) + absolutePath = absolutePath[sitePath.Length..]; + remotePath = absolutePath.Trim('/').Trim(); + } + + var lastSlash = remotePath.LastIndexOf('/'); + return lastSlash <= 0 ? string.Empty : remotePath[..lastSlash]; + } } diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index 0d857a4..1dcb0e1 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -13,6 +13,7 @@ public class DatabaseSeedService : IDatabaseSeedService EnsureSourceSystemDefinitions(db); EnsureCentralHanaServerRecords(db); EnsureSpainManualExcelSite(db); + EnsureUkManualExcelFolder(db); EnsureSapODataDachSite(db); EnsureFinanceReferenceDefaults(db); EnsureBudgetExchangeRateDefaults(db); @@ -287,6 +288,36 @@ public class DatabaseSeedService : IDatabaseSeedService db.SaveChanges(); } + private static void EnsureUkManualExcelFolder(AppDbContext db) + { + var existing = db.Sites + .OrderBy(x => x.Id) + .FirstOrDefault(x => + x.TSC == "TRUK" || + x.Land == "England" || + x.Land == "UK"); + + if (existing is null) + return; + + var changed = false; + if (string.IsNullOrWhiteSpace(existing.SourceSystem)) + { + existing.SourceSystem = "MANUAL_EXCEL"; + changed = true; + } + + if (string.Equals(existing.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(existing.ManualImportFilePath)) + { + existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1"; + changed = true; + } + + if (changed) + db.SaveChanges(); + } + private static void EnsureSapODataDachSite(AppDbContext db) { if (db.Sites.Count() <= 1) diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index 443fb69..647af25 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -60,16 +60,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules), StringComparer.OrdinalIgnoreCase); - var activeSiteKeys = (await db.Sites - .AsNoTracking() - .Where(s => s.IsActive) - .Select(s => new { s.Land, s.TSC }) - .ToListAsync()) - .Select(s => ResolveReferenceKey(s.Land, s.TSC)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - return financeReferences - .Where(reference => activeSiteKeys.Contains(reference.Key) || groupedActuals.ContainsKey(reference.Key)) .Select(reference => BuildReferenceRow(reference, groupedActuals)) .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -282,6 +273,8 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService var normalizedLand = (land ?? string.Empty).Trim().ToUpperInvariant(); var normalizedTsc = (tsc ?? string.Empty).Trim().ToUpperInvariant(); + if (normalizedLand is "AT" or "AUT" || normalizedLand.Contains("OESTER") || normalizedLand.Contains("OSTER") || normalizedLand.Contains("AUSTRIA")) return "AT"; + if (normalizedLand is "CH" or "CHE" || normalizedLand.Contains("SCHWE") || normalizedLand.Contains("SWITZER")) return "CH"; if (normalizedLand.Contains("FRANK") || normalizedTsc.Contains("FR")) return "FR"; if (normalizedLand.Contains("IND") || normalizedTsc.Contains("IN")) return "IN"; if (normalizedLand.Contains("ITAL") || normalizedTsc.Contains("IT")) return "IT"; @@ -289,7 +282,6 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService if (normalizedLand.Contains("USA") || normalizedLand.Contains("UNITED STATES") || normalizedTsc.Contains("US")) return "US"; if (normalizedLand.Contains("DEUT") || normalizedTsc.Contains("DE")) return "DE"; if (normalizedLand.Contains("SPAN") || normalizedTsc is "SE" or "ES") return "ES"; - if (normalizedLand.Contains("SCHWE") || normalizedTsc.Contains("CH")) return "CH"; return normalizedTsc.Replace("TR", string.Empty); } diff --git a/TrafagSalesExporter/Services/ISharePointUploadService.cs b/TrafagSalesExporter/Services/ISharePointUploadService.cs index b6b1731..242af87 100644 --- a/TrafagSalesExporter/Services/ISharePointUploadService.cs +++ b/TrafagSalesExporter/Services/ISharePointUploadService.cs @@ -4,5 +4,8 @@ public interface ISharePointUploadService { Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); Task DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference); + Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc); Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl); } + +public sealed record SharePointFileReference(string FileReference, DateTimeOffset? LastModifiedUtc); diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs index 123cee7..ff920bb 100644 --- a/TrafagSalesExporter/Services/SharePointUploadService.cs +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -1,6 +1,9 @@ using Azure.Core; using Azure.Identity; using Microsoft.Graph; +using Microsoft.Graph.Models; +using System.Globalization; +using System.Text.RegularExpressions; namespace TrafagSalesExporter.Services; @@ -82,6 +85,64 @@ public class SharePointUploadService : ISharePointUploadService return tempPath; } + public async Task ResolveLatestFileInFolderAsync( + string tenantId, + string clientId, + string clientSecret, + string siteUrl, + string folderReference, + string siteTsc) + { + var normalizedTenantId = Normalize(tenantId); + var normalizedClientId = Normalize(clientId); + var normalizedClientSecret = Normalize(clientSecret); + var normalizedSiteUrl = Normalize(siteUrl); + var normalizedReference = Normalize(folderReference); + var normalizedTsc = Normalize(siteTsc).ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(normalizedReference)) + throw new InvalidOperationException("SharePoint-Ordnerreferenz fehlt."); + + var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret); + var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + + var siteUri = new Uri(normalizedSiteUrl); + var sitePath = siteUri.AbsolutePath.TrimEnd('/'); + var site = await graphClient.Sites[$"{siteUri.Host}:{sitePath}"].GetAsync(); + + if (site?.Id is null) + throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); + + var drive = await graphClient.Sites[site.Id].Drive.GetAsync(); + if (drive?.Id is null) + throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden."); + + var folderPath = ResolveRemotePath(normalizedReference, siteUri); + var children = await graphClient.Drives[drive.Id].Root.ItemWithPath(folderPath).Children.GetAsync(); + var candidates = 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 + }) + .OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue) + .ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue) + .ToList() ?? []; + + var selected = candidates.FirstOrDefault() + ?? throw new InvalidOperationException( + string.IsNullOrWhiteSpace(normalizedTsc) + ? $"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); + } + public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) { var normalizedTenantId = Normalize(tenantId); @@ -143,6 +204,41 @@ public class SharePointUploadService : ISharePointUploadService return fileReference.Trim('/').Trim(); } + private static bool IsSupportedManualImportFile(string? fileName) + { + var extension = Path.GetExtension(fileName ?? string.Empty); + return extension.Equals(".xlsx", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".csv", StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesTsc(string? fileName, string normalizedTsc) + { + if (string.IsNullOrWhiteSpace(normalizedTsc)) + return true; + + var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty); + return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate) + { + fileDate = default; + var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty); + var pattern = string.IsNullOrWhiteSpace(normalizedTsc) + ? @"^(?\d{6})_[A-Z0-9]+$" + : $"^(?\\d{{6}})_{Regex.Escape(normalizedTsc)}$"; + var match = Regex.Match(nameWithoutExtension, pattern, RegexOptions.IgnoreCase); + if (!match.Success) + return false; + + return DateTime.TryParseExact( + match.Groups["date"].Value, + "ddMMyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out fileDate); + } + private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl) { var maskedSecret = string.IsNullOrEmpty(clientSecret) diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index 8c6ff2c..38aeeed 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -56,8 +56,6 @@ public class SiteExportService : ISiteExportService details: $"Quelle={sourceSystem} | TSC={site.TSC}"); var (settings, spConfig, sourceDefinition, rules) = await LoadExportConfigAsync(site, sourceSystem); - var outputDir = ResolveSiteOutputDirectory(settings, site); - var adapter = _dataSourceResolver.Resolve(sourceDefinition.ConnectionKind); var fetchResult = await adapter.FetchAsync(new DataSourceFetchContext { @@ -69,6 +67,7 @@ public class SiteExportService : ISiteExportService }); var records = fetchResult.Records; + var outputDir = fetchResult.LocalOutputDirectoryOverride ?? ResolveSiteOutputDirectory(settings, site); updateStatus?.Invoke("Transformationen anwenden..."); await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", @@ -94,7 +93,7 @@ public class SiteExportService : ISiteExportService details: $"Records={records.Count}"); await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus); - await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus); + await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus, fetchResult); sw.Stop(); log.Status = "OK"; @@ -156,7 +155,11 @@ public class SiteExportService : ISiteExportService } private async Task UploadToSharePointIfConfiguredAsync( - Site site, SharePointConfig? spConfig, string filePath, Action? updateStatus) + Site site, + SharePointConfig? spConfig, + string filePath, + Action? updateStatus, + DataSourceFetchResult fetchResult) { if (spConfig is null || string.IsNullOrWhiteSpace(spConfig.TenantId) || @@ -165,12 +168,16 @@ public class SiteExportService : ISiteExportService return; updateStatus?.Invoke("SharePoint Upload..."); + var uploadFolder = string.IsNullOrWhiteSpace(fetchResult.SharePointUploadFolderOverride) + ? spConfig.ExportFolder + : fetchResult.SharePointUploadFolderOverride; + var uploadLand = fetchResult.SharePointUploadLandOverride ?? site.Land; await _appEventLogService.WriteAsync("Export", "SharePoint Upload gestartet", siteId: site.Id, land: site.Land, - details: $"{spConfig.SiteUrl} | {spConfig.ExportFolder}"); + details: $"{spConfig.SiteUrl} | {uploadFolder}"); await _sharePointService.UploadAsync( spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, - spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath); + spConfig.SiteUrl, uploadFolder, uploadLand, filePath); } private static string NormalizeSourceSystem(string? sourceSystem) diff --git a/TrafagSalesExporter/Services/StandortePageService.cs b/TrafagSalesExporter/Services/StandortePageService.cs index ec8cc2d..a90701b 100644 --- a/TrafagSalesExporter/Services/StandortePageService.cs +++ b/TrafagSalesExporter/Services/StandortePageService.cs @@ -409,13 +409,14 @@ public sealed class StandortePageService : IStandortePageService var trimmedPath = manualImportFilePath.Trim(); if (string.IsNullOrWhiteSpace(trimmedPath)) throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen."); - if (!IsSupportedManualImportFile(trimmedPath)) + var isSharePointReference = LooksLikeSharePointReference(trimmedPath); + if (!isSharePointReference && !IsSupportedManualImportFile(trimmedPath)) throw new InvalidOperationException("Bitte eine Excel- oder CSV-Datei mit Endung .xlsx oder .csv angeben."); if (File.Exists(trimmedPath)) return File.GetLastWriteTimeUtc(trimmedPath); - if (!LooksLikeSharePointReference(trimmedPath)) + if (!isSharePointReference) throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {trimmedPath}"); await using var db = await _dbFactory.CreateDbContextAsync(); @@ -429,8 +430,16 @@ public sealed class StandortePageService : IStandortePageService throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); } + var sharePointFileReference = trimmedPath; + if (!IsSupportedManualImportFile(trimmedPath)) + { + var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath, string.Empty); + sharePointFileReference = latestFile.FileReference; + } + var tempPath = await _sharePointService.DownloadToTempFileAsync( - spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath); + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, sharePointFileReference); try { return File.GetLastWriteTimeUtc(tempPath); @@ -448,7 +457,7 @@ public sealed class StandortePageService : IStandortePageService var deleteAfterRead = !string.Equals(filePath, manualImportFilePath?.Trim(), StringComparison.OrdinalIgnoreCase); try { - return string.Equals(Path.GetExtension(manualImportFilePath?.Trim()), ".csv", StringComparison.OrdinalIgnoreCase) + return string.Equals(Path.GetExtension(filePath), ".csv", StringComparison.OrdinalIgnoreCase) ? LoadCsvHeaders(filePath) : LoadExcelHeaders(filePath); } @@ -482,8 +491,16 @@ public sealed class StandortePageService : IStandortePageService throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings."); } + var sharePointFileReference = trimmedPath; + if (!IsSupportedManualImportFile(trimmedPath)) + { + var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync( + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath, string.Empty); + sharePointFileReference = latestFile.FileReference; + } + return await _sharePointService.DownloadToTempFileAsync( - spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath); + spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, sharePointFileReference); } private static void ApplyServer(HanaServer target, HanaServer source) diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs index 18618f1..af88290 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs +++ b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs @@ -16,13 +16,14 @@ builder.Services.AddSingleton Results.Redirect("/finance")); -app.MapGet("/finance", async (IFinanceReconciliationService finance) => +app.MapGet("/finance", async (IFinanceReconciliationService finance, IDbContextFactory dbFactory) => { var rows = await finance.BuildNetSalesReferenceRowsAsync(2025); var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath()); var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath()); var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath()); - return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample), "text/html; charset=utf-8"); + var coverage = await LoadSiteCoverageAsync(dbFactory, 2025); + return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample, coverage), "text/html; charset=utf-8"); }); app.Run(); @@ -215,6 +216,79 @@ static GermanyExcelProbe? LoadGermanyExcelProbe(string? path) }; } +static async Task> LoadSiteCoverageAsync(IDbContextFactory dbFactory, int year) +{ + await using var db = await dbFactory.CreateDbContextAsync(); + var sites = await db.Sites + .AsNoTracking() + .OrderBy(s => s.Land) + .ThenBy(s => s.TSC) + .Select(s => new + { + s.Id, + s.Land, + s.TSC, + s.SourceSystem, + s.ManualImportFilePath, + s.IsActive + }) + .ToListAsync(); + var sourceSystems = await db.SourceSystemDefinitions + .AsNoTracking() + .ToDictionaryAsync(s => s.Code, StringComparer.OrdinalIgnoreCase); + var centralBaseRows = await db.CentralSalesRecords + .AsNoTracking() + .Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year) + .Select(r => new + { + r.SiteId, + r.SalesPriceValue, + Date = r.InvoiceDate ?? r.ExtractionDate, + Currency = string.IsNullOrWhiteSpace(r.CompanyCurrency) ? r.SalesCurrency : r.CompanyCurrency + }) + .ToListAsync(); + var centralRows = centralBaseRows + .GroupBy(r => r.SiteId) + .ToDictionary(g => g.Key, g => new + { + Rows = g.Count(), + Sales = g.Sum(r => r.SalesPriceValue), + MinDate = g.Min(r => r.Date), + MaxDate = g.Max(r => r.Date), + Currencies = g.Select(r => r.Currency).Distinct(StringComparer.OrdinalIgnoreCase).ToList() + }); + var latestLogs = await db.ExportLogs + .AsNoTracking() + .GroupBy(l => l.SiteId) + .Select(g => g.OrderByDescending(l => l.Id).First()) + .ToDictionaryAsync(l => l.SiteId); + + return sites.Select(site => + { + sourceSystems.TryGetValue(site.SourceSystem, out var sourceSystem); + centralRows.TryGetValue(site.Id, out var central); + latestLogs.TryGetValue(site.Id, out var latestLog); + return new SiteCoverageRow + { + Land = site.Land, + Tsc = site.TSC, + SourceSystem = site.SourceSystem, + SourceDisplayName = sourceSystem?.DisplayName ?? site.SourceSystem, + ConnectionKind = sourceSystem?.ConnectionKind ?? string.Empty, + IsActive = site.IsActive, + ManualImportPath = site.ManualImportFilePath, + RowCount = central?.Rows ?? 0, + SalesPriceValue = central?.Sales, + MinDate = central?.MinDate, + MaxDate = central?.MaxDate, + Currencies = central is null ? string.Empty : string.Join(", ", central.Currencies.Where(x => !string.IsNullOrWhiteSpace(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)), + LastExportStatus = latestLog?.Status ?? string.Empty, + LastExportAt = latestLog?.Timestamp, + LastExportError = latestLog?.ErrorMessage ?? string.Empty + }; + }).ToList(); +} + static decimal ReadProbeDecimal(IXLCell cell) { if (cell.TryGetValue(out var decimalValue)) @@ -336,7 +410,8 @@ static string BuildPage( string databasePath, IReadOnlyDictionary excelReferences, SpainSalesCsvProbe? spainCsv, - GermanyExcelProbe? germanySample) + GermanyExcelProbe? germanySample, + IReadOnlyList coverage) { var generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")); var okCount = rows.Count(r => r.Status == "OK"); @@ -345,6 +420,7 @@ static string BuildPage( var excelCount = excelReferences.Count; var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample); var detailRows = BuildDetailRows(rows, excelReferences, spainCsv); + var coverageRows = BuildCoverageRows(coverage); var spainCsvSection = BuildSpainCsvSection(spainCsv); var germanySampleSection = BuildGermanySampleSection(germanySample, excelReferences); @@ -540,6 +616,7 @@ static string BuildPage( @@ -551,6 +628,7 @@ static string BuildPage(
{{okCount}}OK
{{checkCount}}Pruefen
{{missingCount}}Keine Daten
+
{{coverage.Count}}Konfigurierte Standorte
@@ -579,6 +657,7 @@ static string BuildPage(
+ {{coverageRows}} {{germanySampleSection}} {{spainCsvSection}} @@ -587,6 +666,85 @@ static string BuildPage( """; } +static string BuildCoverageRows(IReadOnlyList coverage) +{ + if (coverage.Count == 0) + return string.Empty; + + var rows = string.Join(Environment.NewLine, coverage.Select(row => + { + var sourceDetail = row.ConnectionKind switch + { + "MANUAL_EXCEL" when !string.IsNullOrWhiteSpace(row.ManualImportPath) => row.ManualImportPath, + "MANUAL_EXCEL" => "Kein Manual-Dateipfad hinterlegt", + _ => row.SourceDisplayName + }; + var period = row.RowCount == 0 + ? "-" + : $"{row.MinDate:dd.MM.yyyy} - {row.MaxDate:dd.MM.yyyy}"; + var lastExport = row.LastExportAt.HasValue + ? $"{row.LastExportAt:dd.MM.yyyy HH:mm} / {row.LastExportStatus}" + : "-"; + var issue = BuildCoverageIssue(row); + + return $$""" + + {{Html(row.Land)}}
{{Html(row.Tsc)}}
+ {{Html(row.SourceSystem)}}
{{Html(row.ConnectionKind)}}
+ {{Html(sourceDetail)}} + {{(row.IsActive ? "Ja" : "Nein")}} + {{row.RowCount}} + {{Amount(row.SalesPriceValue)}} + {{Html(row.Currencies)}} + {{Html(period)}} + {{Html(lastExport)}} + {{Html(issue)}} + +"""; + })); + + return $$""" +
+

Datenabdeckung je Standort

+

Diese Tabelle zeigt, welche Standorte in der App konfiguriert sind, welche Quelle sie nutzen und ob fuer 2025 bereits Daten in `CentralSalesRecords` liegen.

+
+ + + + + + + + + + + + + + + + {{rows}} +
StandortSystemQuelle / PfadAktivZeilen 2025SalesPriceValueWaehrungPeriodeLetzter ExportHinweis
+
+
+"""; +} + +static string BuildCoverageIssue(SiteCoverageRow row) +{ + if (!row.IsActive) + return "Standort ist deaktiviert."; + if (row.ConnectionKind == "MANUAL_EXCEL" && string.IsNullOrWhiteSpace(row.ManualImportPath)) + return "Manual Excel/CSV-Pfad fehlt."; + if (!string.IsNullOrWhiteSpace(row.LastExportError)) + return row.LastExportError; + if (row.RowCount == 0) + return "Keine 2025-Daten in CentralSalesRecords. Export pruefen."; + if (!string.IsNullOrWhiteSpace(row.LastExportStatus) && !row.LastExportStatus.Equals("OK", StringComparison.OrdinalIgnoreCase)) + return $"Letzter Exportstatus: {row.LastExportStatus}."; + return "Daten vorhanden."; +} + static string BuildDetailRows( IReadOnlyList rows, IReadOnlyDictionary excelReferences, @@ -989,3 +1147,22 @@ sealed class GermanyExcelProbe public decimal SalesPriceValueIn2025 { get; set; } public string Currencies { get; set; } = string.Empty; } + +sealed class SiteCoverageRow +{ + public string Land { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string SourceSystem { get; set; } = string.Empty; + public string SourceDisplayName { get; set; } = string.Empty; + public string ConnectionKind { get; set; } = string.Empty; + public bool IsActive { get; set; } + public string ManualImportPath { get; set; } = string.Empty; + public int RowCount { get; set; } + public decimal? SalesPriceValue { get; set; } + public DateTime? MinDate { get; set; } + public DateTime? MaxDate { get; set; } + public string Currencies { get; set; } = string.Empty; + public string LastExportStatus { get; set; } = string.Empty; + public DateTime? LastExportAt { get; set; } + public string LastExportError { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs new file mode 100644 index 0000000..c091a70 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs @@ -0,0 +1,161 @@ +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; +using TrafagSalesExporter.Services.DataSources; + +namespace TrafagSalesExporter.Tests; + +public class ManualExcelDataSourceAdapterTests +{ + [Fact] + public async Task FetchAsync_Uses_Local_File_Directory_As_OutputDirectory() + { + var filePath = CreateSpainCsv(); + try + { + var adapter = new ManualExcelDataSourceAdapter( + new FakeSharePointUploadService(filePath), + new ManualExcelImportService(), + new NoopAppEventLogService()); + + var result = await adapter.FetchAsync(CreateContext(filePath)); + + Assert.Single(result.Records); + Assert.Null(result.ReferenceFilePath); + Assert.Equal(Path.GetDirectoryName(Path.GetFullPath(filePath)), result.LocalOutputDirectoryOverride); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task FetchAsync_Uses_SharePoint_Source_Folder_As_UploadFolder() + { + var filePath = CreateSpainCsv(); + try + { + var adapter = new ManualExcelDataSourceAdapter( + new FakeSharePointUploadService(filePath), + new ManualExcelImportService(), + new NoopAppEventLogService()); + + var result = await adapter.FetchAsync(CreateContext("https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/Spanien/Spain_Sales_2025.csv")); + + Assert.Single(result.Records); + Assert.Null(result.ReferenceFilePath); + Assert.Equal("Import/Finance/Spanien", result.SharePointUploadFolderOverride); + Assert.Equal(string.Empty, result.SharePointUploadLandOverride); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task FetchAsync_Uses_Latest_SharePoint_File_When_Path_Is_Folder() + { + var filePath = CreateSpainCsv(); + var sharePointService = new FakeSharePointUploadService( + filePath, + latestFileReference: "Import/Finance/UK_B1/010526_TRUK.xlsx"); + try + { + var adapter = new ManualExcelDataSourceAdapter( + sharePointService, + new ManualExcelImportService(), + new NoopAppEventLogService()); + + var result = await adapter.FetchAsync(CreateContext("https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1", "TRUK", "England")); + + Assert.Single(result.Records); + Assert.Equal("Import/Finance/UK_B1", result.SharePointUploadFolderOverride); + Assert.Equal("Import/Finance/UK_B1/010526_TRUK.xlsx", sharePointService.LastDownloadedReference); + Assert.Equal("TRUK", sharePointService.LastResolvedTsc); + } + finally + { + File.Delete(filePath); + } + } + + private static DataSourceFetchContext CreateContext(string manualImportPath, string tsc = "TRES", string land = "Spanien") => new() + { + Site = new Site + { + Id = 7, + TSC = tsc, + Land = land, + ManualImportFilePath = manualImportPath + }, + SourceDefinition = new SourceSystemDefinition + { + Code = "MANUAL_EXCEL", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel + }, + Settings = new ExportSettings(), + SharePointConfig = new SharePointConfig + { + TenantId = "tenant", + ClientId = "client", + ClientSecret = "secret", + SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform" + } + }; + + private static string CreateSpainCsv() + { + 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\""); + File.WriteAllText(filePath, csv); + return filePath; + } + + private sealed class FakeSharePointUploadService : ISharePointUploadService + { + private readonly string _sourceFilePath; + private readonly string _latestFileReference; + + public FakeSharePointUploadService(string sourceFilePath, string? latestFileReference = null) + { + _sourceFilePath = sourceFilePath; + _latestFileReference = latestFileReference ?? "Import/Finance/Spanien/Spain_Sales_2025.csv"; + } + + public string LastDownloadedReference { get; private set; } = string.Empty; + + public string LastResolvedTsc { get; private set; } = string.Empty; + + public Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath) + => Task.CompletedTask; + + public Task DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference) + { + LastDownloadedReference = fileReference; + var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); + File.Copy(_sourceFilePath, tempPath); + return Task.FromResult(tempPath); + } + + public Task ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc) + { + LastResolvedTsc = siteTsc; + return Task.FromResult(new SharePointFileReference(_latestFileReference, new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero))); + } + + public Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) + => Task.CompletedTask; + } + + private sealed class NoopAppEventLogService : IAppEventLogService + { + public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + + public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null) + => Task.CompletedTask; + } +} diff --git a/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md b/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md index 9458869..4e58116 100644 --- a/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md +++ b/TrafagSalesExporter/docs/PROGRAMM_DIAGRAMME.md @@ -43,3 +43,14 @@ Wichtige Praezisierung aus dem Code: ## Einsatz Die SVG-Dateien koennen direkt im Browser geoeffnet, in Markdown verlinkt oder in Praesentationen eingefuegt werden. + +## Nachtrag Manual Excel/CSV 2026-05-08 + +Die Diagramme zeigen Manual Excel/CSV als Quelle. Die aktuelle Detailregel dazu ist: + +- Eine konkrete lokale Datei wird gelesen; die erzeugte Exportdatei wird im gleichen lokalen Ordner abgelegt. +- Eine konkrete SharePoint-Datei wird gelesen; die erzeugte Exportdatei wird im gleichen SharePoint-Ordner abgelegt. +- Eine SharePoint-Ordnerreferenz wird als dynamische Quelle behandelt. +- Bei SharePoint-Ordnern wird die neueste passende `.xlsx`/`.csv` gesucht. +- Fuer England/TRUK gilt das Dateimuster `ddMMyy_TRUK.xlsx`, z. B. `010526_TRUK.xlsx`. +- Die Ordnerlogik ist generisch fuer Manual-Quellen, nicht hart nur fuer England implementiert. diff --git a/TrafagSalesExporter/docs/finance_status_2025.svg b/TrafagSalesExporter/docs/finance_status_2025.svg new file mode 100644 index 0000000..970f097 --- /dev/null +++ b/TrafagSalesExporter/docs/finance_status_2025.svg @@ -0,0 +1,236 @@ + + Finance-Abgleich 2025: Was passt, was ist offen + Statusuebersicht zum Net-Sales-Abgleich 2025 gegen check.xlsx auf Basis der Financechef-Entscheide. + + + + + + + + + Finance-Abgleich 2025 - was passt, was noch nicht + Stand: letzter lokaler FinanceProbe-Abgleich gegen check.xlsx von Rhino, plus fachliche Entscheide vom Financechef. + + + + Verbindliche Finance-Regeln + + + Fuehrende Waehrung + Immer Hauswaehrung + + + + CHF-Ausweis + Budgetkurse, keine Tageskurse + + + + Summierung + Pro Artikel / Belegposition + + + + Net Sales Basis + Nettofakturawert + + + + Periode 2025 + Buchungsdatum + + + + IC / Gutschriften + Separat sichtbar ausweisen + + + + + + Kurzfazit + + + Passt rechnerisch + Indien, aber Waehrungsfeld pruefen + + + + Daten vorhanden, aber noch nicht fachlich sauber + ES, FR, IT, US brauchen Regel-/Mapping-Klaerung + + + + Noch keine belastbaren Ist-Daten + AT, CH, DE, UK und weitere Referenzlaender + + + + Technische Anpassung offen + FinanceProbe/Export muss strikt Finance-Regeln zeigen + + + + + + Laenderstatus gegen Rhino check.xlsx + + + Status + Land + Ist aktuell + Soll Rhino + Differenz + Was passt + Was noch nicht passt / naechster Schritt + + + + + + + + + + + + + + PASST + IN + 750'936'591.38 + 750'936'591.00 + 0.38 + Zahl passt fast exakt gegen Soll. + Anzeige/Mapping muss INR Hauswaehrung zeigen; aktuell gemischte Waehrungen sichtbar. + + + + + PRUEFEN + ES + 3'082'320.18 + 3'102'334.00 + -20'013.82 + Sage-Datei ist lesbar, 4'341 Zeilen. + Datumsabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften klaeren. + + + + + PRUEFEN + FR + 1'414'138.88 + 1'471'218.00 + -57'079.12 + B1/HANA-Daten vorhanden. + Muss auf Nettofakturawert Hauswaehrung + Buchungsdatum ausgerichtet werden. + + + + + PRUEFEN + IT + 11'866'896.53 + 7'669'840.00 + 4'197'056.53 + Hauswaehrung/EUR-Daten vorhanden. + IC/2nd-party separat markieren; nicht still abziehen. Abgrenzung weiter klaeren. + + + + + PRUEFEN + US + 3'795'763.33 + 3'749'865.00 + 45'898.33 + B1/HANA-Daten vorhanden. + Hauswaehrung, Buchungsdatum und Nettofakturawert gegen Quelle pruefen. + + + + + FEHLT + UK + - + 3'749'865.00 + - + UK_B1 SharePoint-Ordner ist konfiguriert. + Noch keine 2025-Zeilen in CentralSalesRecords; Export/Import neu laufen lassen. + + + + + FEHLT + DE + - + 3'635'923.00 + - + Alphaplan-Mapping ist vorhanden. + Finalen Alphaplan-Exportpfad/Import pruefen; Sample ist nicht Jahresdatei. + + + + + FEHLT + AT / CH + - + AT 3'443'863 + - + ZSCHWEIZ OData-Pfad ist vorbereitet. + Export muss AT/CH nach LAND1 trennen; danach FinanceProbe erneut pruefen. + + + + + FEHLT + CN/CZ/GFS/JP/MS/MSA/PL/RU + - + teils vorhanden + - + Sollwerte/Referenzen sichtbar. + Noch keine Ist-Daten; Quelle, Standort oder Importprozess festlegen. + + + + + + + Konkrete Luecken zur Finance-Regel + + Ist-Wert muss konsequent aus Nettofakturawert Hauswaehrung kommen + Aktuell zeigt die Probe bei einigen Laendern noch alternative technische Kandidaten oder gemischte Waehrungen. + + Buchungsdatum und Belegpositionslogik muessen in Quelle/Mapping sichtbar sein + Gutschriften separat, IC/2nd-party als Klassifikation; 3rd-party ist Default. Keine stille Bereinigung im Hintergrund. + + + Hinweis: Diese SVG ist eine fachliche Statusdoku, keine Codeaenderung. Zahlen stammen aus dem letzten lokalen Abgleich gegen Rhino check.xlsx. + diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index a81030a..fd26858 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,74 @@ # Last Change 2026-05-04 +## Manual Excel/CSV SharePoint-Ordner und Quellordner-Export 2026-05-08 + +Umgesetzte Anpassungen: + +- Manual Excel/CSV Quellen erzeugen nun immer eine neue Exportdatei; die Quelldatei wird nicht als Exportdatei weitergereicht. +- Lokale Manual-Dateien schreiben die neue Exportdatei in denselben lokalen Ordner wie die Quelldatei. +- SharePoint-Manual-Dateien schreiben die neue Exportdatei in denselben SharePoint-Ordner wie die Quelldatei. +- SharePoint-Referenzen ohne Dateiendung werden als Ordner behandelt. +- Bei SharePoint-Ordnern sucht die App die neueste passende Excel-/CSV-Datei fuer den Standort. +- Fuer datierte Dateien wird das Muster `ddMMyy_TSC.xlsx` bzw. `ddMMyy_TSC.csv` ausgewertet. +- Beispiel England/UK: + - Ordner: `https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1` + - `010526_TRUK.xlsx` wird vor `010426_TRUK.xlsx` gewaehlt. + - Falls kein Datum aus dem Dateinamen gelesen werden kann, faellt die Auswahl auf das SharePoint-Aenderungsdatum zurueck. + +Technischer Befund aus den Logs: + +- Spanien konnte die SharePoint-Datei lesen (`4'341` Zeilen), fiel danach aber auf einen ungueltigen lokalen Pfad, weil die URL als lokale Exportdatei behandelt wurde. +- Fehlerpfad war sinngemaess `...\https:\trafagag.sharepoint.com\...\Spain_Sales_2025.csv`. +- Deutschland hatte keinen manuellen Dateipfad hinterlegt. +- England/TRUK zeigte lokal versehentlich auf die Deutschland-Alphaplan-Datei; die lokale DB wurde auf den UK_B1-Ordner korrigiert. + +Codeaenderungen: + +- `DataSourceFetchResult` enthaelt optionale Overrides fuer lokalen Output-Ordner und SharePoint-Zielordner. +- `ManualExcelDataSourceAdapter` erkennt SharePoint-Dateien vs. SharePoint-Ordner und waehlt bei Ordnern die neueste passende Datei. +- `SharePointUploadService` kann den neuesten passenden Datei-Eintrag in einem SharePoint-Ordner aufloesen. +- `SiteExportService` nutzt fuer Manual-Quellen den Quellordner als Zielordner. +- `StandortePageService` erlaubt fuer Manual-Importe nun auch SharePoint-Ordnerreferenzen. +- Standort-UI-Hilfetext wurde entsprechend angepasst. +- `DatabaseSeedService` repariert England/TRUK auf den UK_B1-Ordner, wenn der Manual-Pfad leer ist. + +Letzte technische Verifikation: + +```text +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore --verbosity minimal +``` + +Ergebnis: + +- Tests erfolgreich, `55/55` +- Bekannte MudBlazor-Analyzerwarnungen zu `Dense` bleiben bestehen. + +## FinanceProbe erweitert fuer alle Finance-Referenzen 2026-05-08 + +Umgesetzte Anpassungen: + +- FinanceProbe zeigt nun alle aktiven `FinanceReferences` fuer 2025, auch wenn noch kein aktiver/importierter Standort dazu Daten liefert. +- Damit werden auch Laender wie AT, CH, CN, CZ, GFS, JP, MS, MSA, PL und RU sichtbar als `Keine Daten`, bis Ist-Daten vorhanden sind. +- Zusaetzliche Sektion `Datenabdeckung je Standort`: + - Standort / TSC + - Quellsystem und Anschlussart + - Manual-Datei- oder SharePoint-Pfad + - Aktivstatus + - Anzahl 2025-Zeilen in `CentralSalesRecords` + - Summe `SalesPriceValue` + - Waehrungen + - importierte Periode + - letzter Exportstatus und Hinweis +- Referenzschluessel-Erkennung wurde fuer CH/AT praezisiert: + - `AT`, `AUT`, `Oesterreich`/`Austria` -> `AT` + - `CH`, `CHE`, `Schweiz`/`Switzerland` -> `CH` +- Damit koennen Zeilen aus `ZSCHWEIZ` mit `LAND1 = AT` fachlich Oesterreich zugeordnet werden. + +Verifikation: + +- `Tools/FinanceProbe` Build erfolgreich. +- Haupttests wurden mit separatem Output/Obj-Pfad ausgefuehrt, damit die laufende App nicht stoert. + ## Mapper-/Finance-Konfiguration konsolidiert 2026-05-07 Umgesetzte Aufraeumarbeiten: