From a1fdea56baa07e4553820054969e52d43881a1b1 Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 20 May 2026 09:52:55 +0200 Subject: [PATCH] Improve keyuser export workflow --- .../Components/Layout/NavMenu.razor | 3 + .../Components/Pages/Dashboard.razor | 32 +++ .../Components/Pages/FinanceComparison.razor | 6 +- .../Components/Pages/ManagementCockpit.razor | 4 + .../Components/Pages/ManualImports.razor | 192 ++++++++++++++++++ TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 17 ++ .../Services/DashboardPageService.cs | 38 +++- .../Services/ExcelExportService.cs | 87 ++++++++ .../Services/ExportOrchestrationService.cs | 13 +- TrafagSalesExporter/lastchange.md | 21 ++ 10 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 TrafagSalesExporter/Components/Pages/ManualImports.razor diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index 72bc637..0613374 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -14,6 +14,9 @@ @T("Soll/Ist Vergleich", "Actual/reference comparison") + + @T("Manuelle Importe", "Manual imports") + diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index 566dbc5..41e1a17 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -37,6 +37,25 @@ +@if (_readinessWarnings.Count > 0) +{ + + @T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:") + @foreach (var warning in _readinessWarnings) + { + @warning + } + +} + +@if (_consolidatedStale) +{ + + @T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.", + "At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.") + +} + @T("Land", "Country") @@ -155,6 +174,8 @@ @code { private List _dashboardRows = new(); private List _consolidatedRows = new(); + private List _readinessWarnings = new(); + private bool _consolidatedStale; private bool _loading = true; private bool _anyRunning; private CancellationTokenSource? _pollingCts; @@ -171,6 +192,8 @@ var state = await DashboardPageActions.LoadAsync(); _dashboardRows = state.DashboardRows; _consolidatedRows = state.ConsolidatedRows; + _readinessWarnings = state.ReadinessWarnings; + _consolidatedStale = state.IsConsolidatedStale; _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); _loading = false; @@ -178,6 +201,12 @@ private async Task ExportAll() { + if (_readinessWarnings.Count > 0) + { + Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.", + "There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning); + } + _anyRunning = true; await LoadDataAsync(); StartPolling(); @@ -260,6 +289,9 @@ { await InvokeAsync(() => Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success)); + await InvokeAsync(() => + Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.", + "The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info)); } else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage)) { diff --git a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor index 550a211..ecbda7d 100644 --- a/TrafagSalesExporter/Components/Pages/FinanceComparison.razor +++ b/TrafagSalesExporter/Components/Pages/FinanceComparison.razor @@ -12,7 +12,7 @@
@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference") - @T("Gleiche Berechnungslogik wie FinanceProbe/Testprogramm", "Same calculation logic as FinanceProbe/test program") + @T("Verbindliche Finance-Sicht aus CentralSalesRecords", "Authoritative finance view from CentralSalesRecords")
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.") + + @T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.", + "This analysis is a plausibility/raw-data view. For the authoritative finance reconciliation, use `Actual/reference comparison` or the `Finance | ...` columns in the final Excel.") + diff --git a/TrafagSalesExporter/Components/Pages/ManualImports.razor b/TrafagSalesExporter/Components/Pages/ManualImports.razor new file mode 100644 index 0000000..312a860 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/ManualImports.razor @@ -0,0 +1,192 @@ +@page "/manual-imports" +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using TrafagSalesExporter.Data +@using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services +@inject IDbContextFactory DbFactory +@inject IStandortePageService StandortePageService +@inject ISnackbar Snackbar +@inject IUiTextService UiText + +@T("Manuelle Importe", "Manual imports") + +@T("Manuelle Importe", "Manual imports") + + + @T("Diese Seite ist fuer Keyuser: Hier werden Excel-/CSV-Dateien fuer manuelle Laender wie DE, UK und ES hinterlegt und aktiviert. Technische Spaltenmappings bleiben in Admin -> Standorte.", + "This page is for key users: Excel/CSV files for manual countries such as DE, UK and ES are maintained and activated here. Technical column mappings remain in Admin -> Sites.") + + + + + @T("Land", "Country") + TSC + @T("Aktiv", "Active") + @T("Datei / SharePoint-Ordner", "File / SharePoint folder") + @T("Letzter Upload", "Last upload") + @T("Aktionen", "Actions") + + + @context.Land + @context.TSC + + + + + @(context.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-") + + + + @T("Pfad pruefen", "Check path") + + + @T("Speichern", "Save") + + + + + + + @T("Keine manuellen Excel-/CSV-Standorte gefunden.", "No manual Excel/CSV sites found.") + + + +@code { + private List _rows = []; + private bool _loading = true; + private int? _busySiteId; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + await using var db = await DbFactory.CreateDbContextAsync(); + var manualSourceCodes = await db.SourceSystemDefinitions + .Where(x => x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel) + .Select(x => x.Code) + .ToListAsync(); + + _rows = await db.Sites + .Where(site => manualSourceCodes.Contains(site.SourceSystem)) + .OrderBy(site => site.Land) + .ThenBy(site => site.TSC) + .Select(site => new ManualImportRow + { + Id = site.Id, + Land = site.Land, + TSC = site.TSC, + IsActive = site.IsActive, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc + }) + .ToListAsync(); + _loading = false; + } + + private async Task SaveAsync(ManualImportRow row) + { + _busySiteId = row.Id; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var site = await db.Sites.FirstAsync(x => x.Id == row.Id); + site.IsActive = row.IsActive; + site.ManualImportFilePath = row.ManualImportFilePath.Trim(); + site.ManualImportLastUploadedAtUtc = row.ManualImportLastUploadedAtUtc; + await db.SaveChangesAsync(); + Snackbar.Add(T("Import-Einstellungen gespeichert.", "Import settings saved."), Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"{T("Speichern fehlgeschlagen", "Save failed")}: {ex.Message}", Severity.Error); + } + finally + { + _busySiteId = null; + } + } + + private async Task ValidatePathAsync(ManualImportRow row) + { + _busySiteId = row.Id; + try + { + row.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(row.ManualImportFilePath); + Snackbar.Add(T("Datei oder SharePoint-Referenz ist erreichbar.", "File or SharePoint reference is reachable."), Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"{T("Pfadpruefung fehlgeschlagen", "Path check failed")}: {ex.Message}", Severity.Error); + } + finally + { + _busySiteId = null; + } + } + + private async Task UploadAsync(ManualImportRow row, InputFileChangeEventArgs args) + { + var file = args.File; + if (file is null) + return; + + _busySiteId = row.Id; + try + { + var extension = Path.GetExtension(file.Name); + if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(T("Bitte eine .xlsx- oder .csv-Datei auswaehlen.", "Please choose a .xlsx or .csv file.")); + } + + var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); + Directory.CreateDirectory(uploadDirectory); + var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name) + .Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_')); + if (string.IsNullOrWhiteSpace(safeBaseName)) + safeBaseName = "manual_import"; + + var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}"); + await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024)) + await using (var targetStream = File.Create(targetPath)) + { + await sourceStream.CopyToAsync(targetStream); + } + + row.ManualImportFilePath = targetPath; + row.ManualImportLastUploadedAtUtc = DateTime.UtcNow; + await SaveAsync(row); + Snackbar.Add(T("Datei hochgeladen.", "File uploaded."), Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"{T("Upload fehlgeschlagen", "Upload failed")}: {ex.Message}", Severity.Error); + } + finally + { + _busySiteId = null; + } + } + + private string T(string german, string english) => UiText.Text(german, english); + + private sealed class ManualImportRow + { + public int Id { get; set; } + public string Land { get; set; } = string.Empty; + public string TSC { get; set; } = string.Empty; + public bool IsActive { get; set; } + public string ManualImportFilePath { get; set; } = string.Empty; + public DateTime? ManualImportLastUploadedAtUtc { get; set; } + } +} diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 0a0f9d4..25205fe 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,23 @@ Stand: 2026-05-20 +## Nachtrag 2026-05-20 Workflow-Fixes nach Review + +Umgesetzt: + +- Dashboard warnt vor aktiven manuellen Standorten ohne Datei. +- Nach Einzelstandortexport wird sichtbar, dass die zentrale Excel neu erzeugt werden muss. +- Dashboard erkennt eine veraltete zentrale Excel nach neuem Standortexport. +- Neuer Menuepunkt `Manuelle Importe` fuer Keyuser. +- Zentrale Excel hat ein Blatt `Finance Summary`. +- `Management Analyse` ist als Rohdaten-/Plausibilitaetssicht markiert. +- `Soll/Ist Vergleich` ist als verbindliche Finance-Sicht markiert. +- Export-Live-Status ist nicht mehr pauschal `HANA Abfrage...`. + +Weiterhin offen: + +- DE Alphaplan-Fachabgrenzung: Kundenlaender/Filter muessen von Munir/Finance bestaetigt werden. + ## Nachtrag 2026-05-20 Keyuser Prozess-SVG Erstellt: diff --git a/TrafagSalesExporter/Services/DashboardPageService.cs b/TrafagSalesExporter/Services/DashboardPageService.cs index cd60a41..42bb010 100644 --- a/TrafagSalesExporter/Services/DashboardPageService.cs +++ b/TrafagSalesExporter/Services/DashboardPageService.cs @@ -62,13 +62,45 @@ public sealed class DashboardPageService : IDashboardPageService }; }).ToList(); + var consolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new()); + var latestSuccessfulSiteRun = logs + .Where(log => log.Status == "OK") + .Select(log => (DateTime?)log.Timestamp) + .OrderByDescending(timestamp => timestamp) + .FirstOrDefault(); + var latestConsolidatedRun = consolidatedRows + .Select(row => row.LastModified) + .OrderByDescending(timestamp => timestamp) + .FirstOrDefault(); + return new DashboardPageState { DashboardRows = rows, - ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new()) + ConsolidatedRows = consolidatedRows, + ReadinessWarnings = BuildReadinessWarnings(sites, sourceSystems), + IsConsolidatedStale = latestSuccessfulSiteRun.HasValue && + (!latestConsolidatedRun.HasValue || latestSuccessfulSiteRun.Value > latestConsolidatedRun.Value), + LatestSuccessfulSiteRun = latestSuccessfulSiteRun, + LatestConsolidatedRun = latestConsolidatedRun }; } + private static List BuildReadinessWarnings(List activeSites, List sourceSystems) + { + var warnings = new List(); + foreach (var site in activeSites.OrderBy(x => x.Land).ThenBy(x => x.TSC)) + { + var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase)); + if (!string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) + warnings.Add($"{site.Land} / {site.TSC}: manuelle Excel-/CSV-Datei fehlt."); + } + + return warnings; + } + private static string ResolveDashboardSapServiceUrl(Site site, List sourceSystems) { if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) @@ -114,6 +146,10 @@ public sealed class DashboardPageState { public List DashboardRows { get; set; } = []; public List ConsolidatedRows { get; set; } = []; + public List ReadinessWarnings { get; set; } = []; + public bool IsConsolidatedStale { get; set; } + public DateTime? LatestSuccessfulSiteRun { get; set; } + public DateTime? LatestConsolidatedRun { get; set; } } public sealed class DashboardRow diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs index 789b6e3..f18bc2c 100644 --- a/TrafagSalesExporter/Services/ExcelExportService.cs +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -146,11 +146,98 @@ public class ExcelExportService : IExcelExportService ws.Columns().AdjustToContents(); if (includeFinanceHelpSheet) + { + AddFinanceSummarySheet(workbook, records); AddFinanceHelpSheet(workbook); + } workbook.SaveAs(fullPath); } + private static void AddFinanceSummarySheet(XLWorkbook workbook, List records) + { + var ws = workbook.Worksheets.Add("Finance Summary"); + ws.Position = 1; + ws.Cell(1, 1).Value = "Finance Summary"; + ws.Cell(1, 1).Style.Font.Bold = true; + ws.Cell(1, 1).Style.Font.FontSize = 14; + ws.Cell(2, 1).Value = "Diese Summen verwenden dieselbe Finance-Sicht wie die Spalten Finance | ... im Blatt Sales."; + + var headers = new[] + { + "Year", + "Country Key", + "Currency", + "Included Rows", + "Net Sales Actual", + "Excluded Rows", + "Hinweis" + }; + + for (var i = 0; i < headers.Length; i++) + { + ws.Cell(4, i + 1).Value = headers[i]; + ws.Cell(4, i + 1).Style.Font.Bold = true; + } + + var italyBlankSupplierCountryRows = new HashSet(StringComparer.OrdinalIgnoreCase); + var summaryRows = records + .Select(record => + { + var financeDate = ResolveFinanceDate(record); + var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc); + var include = ResolveFinanceInclude(record, countryKey, italyBlankSupplierCountryRows) && record.SalesPriceValue != 0m; + return new + { + Year = financeDate.Year, + CountryKey = countryKey, + Currency = ResolveFinanceCurrency(record), + Include = include, + Value = include ? record.SalesPriceValue : 0m + }; + }) + .GroupBy(row => new { row.Year, row.CountryKey, row.Currency }) + .OrderBy(group => group.Key.Year) + .ThenBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase) + .Select(group => new + { + group.Key.Year, + group.Key.CountryKey, + group.Key.Currency, + IncludedRows = group.Count(row => row.Include), + NetSalesActual = group.Sum(row => row.Value), + ExcludedRows = group.Count(row => !row.Include) + }) + .ToList(); + + var rowIndex = 5; + foreach (var row in summaryRows) + { + ws.Cell(rowIndex, 1).Value = row.Year; + ws.Cell(rowIndex, 2).Value = row.CountryKey; + ws.Cell(rowIndex, 3).Value = row.Currency; + ws.Cell(rowIndex, 4).Value = row.IncludedRows; + ws.Cell(rowIndex, 5).Value = row.NetSalesActual; + ws.Cell(rowIndex, 6).Value = row.ExcludedRows; + ws.Cell(rowIndex, 7).Value = BuildFinanceSummaryHint(row.CountryKey); + rowIndex++; + } + + ws.Column(5).Style.NumberFormat.Format = "#,##0.00"; + ws.Columns().AdjustToContents(); + } + + private static string BuildFinanceSummaryHint(string countryKey) + => countryKey.ToUpperInvariant() switch + { + "DE" => "DE Alphaplan ist technisch vorbereitet; Kundenlaender/Filter fachlich noch bestaetigen.", + "IT" => "IT: Trafag Italia ausgeschlossen; doppelte Blank-Supplier-Zeilen nur einmal.", + "UK" => "UK: Sage/Manual Excel, Credit Notes negativ.", + "ES" => "ES: Sage CSV/Manual Excel, REC/Credit Notes negativ.", + _ => string.Empty + }; + private static void AddFinanceHelpSheet(XLWorkbook workbook) { var ws = workbook.Worksheets.Add("Finance Filter Hilfe"); diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs index 3b965c8..c98dd8c 100644 --- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -96,7 +96,7 @@ public class ExportOrchestrationService lock (_lock) { if (_runningExports.ContainsKey(site.Id)) return null; - _runningExports[site.Id] = "HANA Abfrage..."; + _runningExports[site.Id] = BuildInitialExportStatus(site); } NotifyChanged(); @@ -134,6 +134,17 @@ public class ExportOrchestrationService OnExportStatusChanged?.Invoke(); } + private static string BuildInitialExportStatus(Site site) + { + var sourceSystem = (site.SourceSystem ?? string.Empty).Trim().ToUpperInvariant(); + return sourceSystem switch + { + "MANUAL_EXCEL" => "Manuelle Excel/CSV lesen...", + "SAP" => "SAP OData lesen...", + _ => "Quelldaten lesen..." + }; + } + private async Task RunConsolidatedExportAsync() { lock (_lock) diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index ec9a519..8e273cf 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,26 @@ # Last Change 2026-05-04 +## Workflow-Konsistenz fuer Keyuser verbessert 2026-05-20 + +Geaendert: + +- Export Dashboard zeigt jetzt Warnungen, wenn aktive Manual-Excel-Standorte noch keine Datei/Pfad hinterlegt haben. +- Nach einem Einzelstandortexport wird darauf hingewiesen, dass die zentrale Excel separat neu erzeugt werden muss. +- Dashboard markiert, wenn seit der letzten zentralen Excel ein Standortexport gelaufen ist. +- Neuer Keyuser-Menuepunkt `Manuelle Importe` fuer DE/UK/ES-artige Excel-/CSV-Quellen: + - Pfad/SharePoint-Referenz pflegen + - Datei hochladen + - Standort aktiv/inaktiv setzen + - Pfad pruefen +- Live-Status startet nicht mehr pauschal mit `HANA Abfrage...`, sondern quellenneutral bzw. fuer Manual Excel/SAP passender. +- Zentrale Excel enthaelt ein neues Blatt `Finance Summary` mit Summen nach Jahr, Land und Waehrung. +- `Management Analyse` ist klarer als Rohdaten-/Plausibilitaetssicht markiert. +- `Soll/Ist Vergleich` ist klarer als verbindliche Finance-Sicht markiert. + +Bewusst nicht geaendert: + +- DE-Fachregel bleibt offen, bis Munir/Finance bestaetigt, welche Kundenlaender/Filter zum offiziellen DE-Ist gehoeren. + ## Keyuser Prozessdoku SVG 2026-05-20 Erstellt: