diff --git a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor index 369208e..b0a8c6e 100644 --- a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor +++ b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor @@ -123,6 +123,10 @@ @FileStatusTable(Result.FileStatuses) + + + @GuidePanel() + @code { @@ -279,6 +283,75 @@ ; + private RenderFragment GuidePanel() => @ + + + @T("Ablauf fuer HR", "HR workflow") +
+
+ + 1 + @T("Rexx exportieren", "Export from Rexx") +

@T("Die benoetigten Rexx-Abfragen manuell herunterladen. Excel/XLSX verwenden, nicht PDF.", "Download the required Rexx queries manually. Use Excel/XLSX, not PDF.")

+
+
+ + 2 + @T("Dateien ablegen", "Place files") +

@T("Downloads in den Datenordner kopieren und exakt wie unten benennen.", "Copy downloads into the data folder and name them exactly as listed below.")

+
+
+ + 3 + @T("Cockpit laden", "Load cockpit") +

@T("Im HR-KPI-Cockpit den Datenordner kontrollieren und Laden klicken.", "Check the data folder in the HR KPI cockpit and click Load.")

+
+
+ + 4 + @T("Datenstatus pruefen", "Check data status") +

@T("Im Reiter Datenstatus muessen die erwarteten Dateien gruen erscheinen.", "In the Data status tab, the expected files should be green.")

+
+
+
+
+ + + @T("Datenordner", "Data folder") + @Result.Options.DataFolder + + @T("Der Standardordner ist konfigurierbar. Fuer einen anderen Ordner oben im HR-KPI-Filter den Datenordner anpassen und neu laden.", + "The default folder is configurable. To use another folder, change the data folder in the HR KPI filter above and reload.") + + + @T("HR-Dateien enthalten Personendaten. Nicht per E-Mail weiterleiten und keine Kopien in ungeschuetzten Ordnern liegen lassen.", + "HR files contain personal data. Do not forward them by email and do not leave copies in unprotected folders.") + + + + + + @T("Erwartete Dateien", "Expected files") + + + @T("Inhalt", "Content") + @T("Datei/Pfad", "File/path") + @T("Status", "Status") + + + @context.Label + @context.Path + + + @(context.Exists ? T("gefunden", "found") : T("fehlt", "missing")) + + + + + + +
; + private static IEnumerable BuildLeaverExclusionRows(IReadOnlyList items) => items .GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant") @@ -391,6 +464,51 @@ min-height: 100%; } + .hr-guide-steps { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 12px; + } + + .hr-guide-step { + min-height: 175px; + padding: 16px; + border: 1px solid var(--mud-palette-lines-default); + border-top: 5px solid var(--mud-palette-primary); + display: flex; + flex-direction: column; + gap: 8px; + background: var(--mud-palette-surface); + } + + .hr-guide-step span { + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-grid; + place-items: center; + color: var(--mud-palette-primary-text); + background: var(--mud-palette-primary); + font-weight: 700; + } + + .hr-guide-step p { + margin: 0; + color: var(--mud-palette-text-secondary); + } + + @@media (max-width: 1100px) { + .hr-guide-steps { + grid-template-columns: repeat(2, minmax(150px, 1fr)); + } + } + + @@media (max-width: 700px) { + .hr-guide-steps { + grid-template-columns: 1fr; + } + } + .hr-gauge { --gauge-color: #2e7d32; --gauge-deg: 0deg; diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor index e7d0aef..4a212f0 100644 --- a/TrafagSalesExporter/Components/Pages/HrKpi.razor +++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor @@ -39,7 +39,9 @@ else - + diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index a4c54cd..b117f4f 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -9,6 +9,138 @@ @T("Management Analyse", "Management analysis") + + + + + @foreach (var year in _financeYearOptions) + { + @year + } + + + + + @foreach (var option in _financeCountryOptions) + { + @option + } + + + + + @foreach (var option in _financeCurrencyOptions) + { + @option + } + + + + + @(_analyzingFinance ? T("Lade...", "Loading...") : T("Finance Summary laden", "Load finance summary")) + + + + + +@if (_financeResult is not null) +{ + + + + + + @T("Net Sales Actual", "Net sales actual") + @FormatValue(_financeResult.NetSalesActual, _financeResult.DisplayCurrency) + @T("gefiltertes Endergebnis", "filtered final result") + + + + + @T("Enthaltene Zeilen", "Included rows") + @_financeResult.IncludedRows.ToString("N0") + @T("Finance Include = TRUE", "Finance Include = TRUE") + + + + + @T("Ausgeschlossen", "Excluded") + @_financeResult.ExcludedRows.ToString("N0") + @T("Finance-Regeln", "Finance rules") + + + + + @T("Laender / Waehrungen", "Countries / currencies") + @($"{_financeResult.CountryCount:N0} / {_financeResult.CurrencyCount:N0}") + @($"{_financeResult.Filter.Year}") + + + + + + + + @T("Summen wie im Excel-Blatt Finance Summary", "Totals matching the Finance Summary Excel sheet") + + + @T("Jahr", "Year") + @T("Land", "Country") + @T("Waehrung", "Currency") + @T("Net Sales Actual", "Net sales actual") + @T("Enthalten", "Included") + @T("Ausgeschlossen", "Excluded") + + + @context.Year + @context.CountryKey + @context.Currency + @FormatValue(context.NetSalesActual, context.Currency) + @context.IncludedRows.ToString("N0") + @context.ExcludedRows.ToString("N0") + + + + @T("Keine Finance-Summary-Daten fuer diese Filter.", "No finance summary data for these filters.") + + + + + + + + @T("Hinweise", "Notes") + @foreach (var notice in _financeResult.Notices) + { + @notice + } + + + + + + @T("Jahresvergleich mit aktuellem Filter", "Year comparison with current filter") + + + @T("Jahr", "Year") + @T("Waehrung", "Currency") + @T("Net Sales Actual", "Net sales actual") + @T("Enthalten", "Included") + @T("Ausgeschlossen", "Excluded") + + + @context.Year + @context.Currency + @FormatValue(context.NetSalesActual, context.Currency) + @context.IncludedRows.ToString("N0") + @context.ExcludedRows.ToString("N0") + + + + + + @@ -339,9 +471,16 @@ } + + +} + @code { private List _files = []; private List _centralYears = []; + private List _financeYearOptions = []; + private List _financeCountryOptions = []; + private List _financeCurrencyOptions = []; private List _valueFieldOptions = []; private readonly List _currencyOptions = [ @@ -352,6 +491,10 @@ private string? _selectedFilePath; private ManagementCockpitResult? _result; private ManagementCockpitCentralResult? _centralResult; + private ManagementFinanceSummaryResult? _financeResult; + private int _selectedFinanceYear; + private string? _selectedFinanceCountryKey; + private string? _selectedFinanceCurrency; private int _selectedCentralYear; private int? _selectedCentralMonth; private string? _centralLandFilter; @@ -360,10 +503,11 @@ private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue; private IEnumerable _selectedCentralAdditionalValueFields = []; private string _selectedFileTargetCurrency = ManagementCockpitCurrencyOptions.Eur; - private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Eur; + private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Native; private bool _loadingFiles; private bool _analyzing; private bool _analyzingCentral; + private bool _analyzingFinance; protected override async Task OnInitializedAsync() { @@ -373,6 +517,8 @@ _centralYears = state.CentralYears; _selectedFilePath = state.SelectedFilePath; _selectedCentralYear = state.SelectedCentralYear; + _selectedFinanceYear = _selectedCentralYear; + await AnalyzeFinanceSummary(); } private async Task ReloadFiles() @@ -449,6 +595,31 @@ } } + private async Task AnalyzeFinanceSummary() + { + _analyzingFinance = true; + try + { + _financeResult = await CockpitPageService.AnalyzeFinanceSummaryAsync( + _selectedFinanceYear, + _selectedFinanceCountryKey, + _selectedFinanceCurrency); + + _financeYearOptions = _financeResult.YearOptions; + _financeCountryOptions = _financeResult.CountryOptions; + _financeCurrencyOptions = _financeResult.CurrencyOptions; + _selectedFinanceYear = _financeResult.Filter.Year; + } + catch (Exception ex) + { + Snackbar.Add(string.Format(T("Finance Summary konnte nicht erzeugt werden: {0}", "Could not build finance summary: {0}"), ex.Message), Severity.Error); + } + finally + { + _analyzingFinance = false; + } + } + private void ClearCentralScope() { _centralLandFilter = null; diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs index 63853f8..6d26a7a 100644 --- a/TrafagSalesExporter/Models/ManagementCockpitModels.cs +++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs @@ -153,3 +153,37 @@ public class ManagementCockpitCentralResult public List SourceSystemTotals { get; set; } = []; public List CountryTotals { get; set; } = []; } + +public class ManagementFinanceSummaryFilter +{ + public int Year { get; set; } + public string? CountryKey { get; set; } + public string? Currency { get; set; } +} + +public class ManagementFinanceSummaryRow +{ + public int Year { get; set; } + public string CountryKey { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public int IncludedRows { get; set; } + public int ExcludedRows { get; set; } + public decimal NetSalesActual { get; set; } +} + +public class ManagementFinanceSummaryResult +{ + public ManagementFinanceSummaryFilter Filter { get; set; } = new(); + public List Notices { get; set; } = []; + public List YearOptions { get; set; } = []; + public List CountryOptions { get; set; } = []; + public List CurrencyOptions { get; set; } = []; + public List Rows { get; set; } = []; + public List YearRows { get; set; } = []; + public int IncludedRows { get; set; } + public int ExcludedRows { get; set; } + public int CountryCount { get; set; } + public int CurrencyCount { get; set; } + public decimal NetSalesActual { get; set; } + public string DisplayCurrency { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Services/IManagementCockpitService.cs b/TrafagSalesExporter/Services/IManagementCockpitService.cs index 7973a4d..23eddf4 100644 --- a/TrafagSalesExporter/Services/IManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/IManagementCockpitService.cs @@ -11,4 +11,5 @@ public interface IManagementCockpitService Task> GetAvailableCentralYearsAsync(); Task AnalyzeCentralAsync(int year, int? month); Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options); + Task AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency); } diff --git a/TrafagSalesExporter/Services/ManagementCockpitPageService.cs b/TrafagSalesExporter/Services/ManagementCockpitPageService.cs index ca87c0f..8d37e96 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitPageService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitPageService.cs @@ -9,6 +9,7 @@ public interface IManagementCockpitPageService Task> LoadCentralYearsAsync(); Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions options); Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options); + Task AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency); } public sealed class ManagementCockpitPageService : IManagementCockpitPageService @@ -46,6 +47,9 @@ public sealed class ManagementCockpitPageService : IManagementCockpitPageService public Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options) => _cockpitService.AnalyzeCentralAsync(year, month, options); + + public Task AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency) + => _cockpitService.AnalyzeFinanceSummaryAsync(year, countryKey, currency); } public sealed class ManagementCockpitPageState diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index 6c37c12..dd2ef4b 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -296,6 +296,149 @@ public class ManagementCockpitService : IManagementCockpitService }; } + public async Task AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency) + { + using var db = await _dbFactory.CreateDbContextAsync(); + var financeRules = await db.FinanceRules + .AsNoTracking() + .Where(rule => rule.IsActive) + .OrderBy(rule => rule.SortOrder) + .ThenBy(rule => rule.Id) + .ToListAsync(); + if (financeRules.Count == 0) + financeRules = FinanceRuleEngine.CreateDefaultRules().ToList(); + + var financeRuleEngine = new FinanceRuleEngine(financeRules); + var records = await db.CentralSalesRecords + .AsNoTracking() + .Select(r => new SalesRecord + { + Land = r.Land, + Tsc = r.Tsc, + DocumentEntry = r.DocumentEntry, + InvoiceNumber = r.InvoiceNumber, + PositionOnInvoice = r.PositionOnInvoice, + Material = r.Material, + Name = r.Name, + Quantity = r.Quantity, + SupplierCountry = r.SupplierCountry, + CustomerNumber = r.CustomerNumber, + CustomerName = r.CustomerName, + SalesCurrency = r.SalesCurrency, + DocumentCurrency = r.DocumentCurrency, + CompanyCurrency = r.CompanyCurrency, + SalesPriceValue = r.SalesPriceValue, + DocumentType = r.DocumentType, + PostingDate = r.PostingDate, + InvoiceDate = r.InvoiceDate, + ExtractionDate = r.ExtractionDate + }) + .ToListAsync(); + + if (records.Count == 0) + throw new InvalidOperationException("Die zentrale Tabelle enthaelt noch keine Datensaetze."); + + var allRows = records + .Select(record => + { + var resolvedCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc); + var financeDate = financeRuleEngine.ResolveFinanceDate(record, resolvedCountryKey); + var rawInclude = financeRuleEngine.ShouldInclude(record, resolvedCountryKey); + var value = financeRuleEngine.ResolveNetSalesActual(record, resolvedCountryKey, rawInclude); + var include = rawInclude && value != 0m; + return new FinanceAggregationRow + { + Year = financeDate.Year, + CountryKey = resolvedCountryKey, + Currency = ResolveFinanceCurrency(record), + Include = include, + Value = value + }; + }) + .ToList(); + + var yearOptions = allRows + .Select(row => row.Year) + .Distinct() + .OrderBy(yearValue => yearValue) + .ToList(); + if (year == 0) + year = yearOptions.LastOrDefault(); + + var countryFilter = NormalizeOptionalFilter(countryKey); + var currencyFilter = NormalizeOptionalFilter(currency); + var scopedRows = allRows + .Where(row => row.Year == year) + .Where(row => countryFilter is null || row.CountryKey.Equals(countryFilter, StringComparison.OrdinalIgnoreCase)) + .Where(row => currencyFilter is null || row.Currency.Equals(currencyFilter, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var summaryRows = scopedRows + .GroupBy(row => new { row.Year, row.CountryKey, row.Currency }) + .OrderBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase) + .Select(group => BuildFinanceSummaryRow(group.Key.Year, group.Key.CountryKey, group.Key.Currency, group)) + .ToList(); + + var yearRows = allRows + .Where(row => countryFilter is null || row.CountryKey.Equals(countryFilter, StringComparison.OrdinalIgnoreCase)) + .Where(row => currencyFilter is null || row.Currency.Equals(currencyFilter, StringComparison.OrdinalIgnoreCase)) + .GroupBy(row => new { row.Year, row.Currency }) + .OrderBy(group => group.Key.Year) + .ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase) + .Select(group => BuildFinanceSummaryRow(group.Key.Year, "Alle", group.Key.Currency, group)) + .ToList(); + + var includedRows = scopedRows.Count(row => row.Include); + var excludedRows = scopedRows.Count(row => !row.Include); + var resultCurrencies = summaryRows + .Select(row => row.Currency) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(); + var notices = new List + { + "Diese Sicht verwendet dieselbe FinanceRuleEngine wie das zentrale Excel-Blatt Finance Summary.", + "Jahr, Land und Waehrung werden auf das Endergebnis angewendet.", + "Finance-Jahr basiert auf PostingDate, danach InvoiceDate, danach ExtractionDate; DE-Regeln koennen das Jahr erzwingen.", + "Include/Exclude, Gutschriften-Negierung und IT-Deduplizierung folgen den gepflegten Finance Regeln." + }; + if (scopedRows.Count == 0) + { + notices.Insert(0, "Fuer die gewaehlten Finance-Filter gibt es keine Datensaetze im aktuellen Zentraldatenbestand."); + } + + return new ManagementFinanceSummaryResult + { + Filter = new ManagementFinanceSummaryFilter + { + Year = year, + CountryKey = countryFilter, + Currency = currencyFilter + }, + YearOptions = yearOptions, + CountryOptions = allRows + .Select(row => row.CountryKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(), + CurrencyOptions = allRows + .Select(row => row.Currency) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(), + Rows = summaryRows, + YearRows = yearRows, + IncludedRows = includedRows, + ExcludedRows = excludedRows, + CountryCount = summaryRows.Select(row => row.CountryKey).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + CurrencyCount = resultCurrencies.Count, + NetSalesActual = summaryRows.Sum(row => row.NetSalesActual), + DisplayCurrency = BuildDisplayCurrencyLabel(resultCurrencies), + Notices = notices + }; + } + private static IEnumerable ApplyCentralDimensionFilters( IEnumerable rows, ManagementCockpitAnalysisOptions? options) @@ -308,6 +451,57 @@ public class ManagementCockpitService : IManagementCockpitService (tscFilter is null || string.Equals(row.Tsc, tscFilter, StringComparison.OrdinalIgnoreCase))); } + private static ManagementFinanceSummaryRow BuildFinanceSummaryRow( + int year, + string countryKey, + string currency, + IEnumerable rows) + { + var rowList = rows.ToList(); + return new ManagementFinanceSummaryRow + { + Year = year, + CountryKey = countryKey, + Currency = currency, + IncludedRows = rowList.Count(row => row.Include), + ExcludedRows = rowList.Count(row => !row.Include), + NetSalesActual = rowList.Sum(row => row.Value) + }; + } + + private static string ResolveFinanceCurrency(SalesRecord record) + => ResolveFinanceCountryKey(record.Land, record.Tsc) switch + { + "CH" => "CHF", + "AT" => "EUR", + "DE" => "EUR", + "ES" => "EUR", + "FR" => "EUR", + "IN" => "INR", + "IT" => "EUR", + "UK" => "GBP", + "US" => "USD", + _ => string.IsNullOrWhiteSpace(record.CompanyCurrency) ? record.SalesCurrency : record.CompanyCurrency + }; + + private static string ResolveFinanceCountryKey(string land, string tsc) + { + 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"; + if (normalizedLand.Contains("ENGL") || normalizedLand.Contains("KINGDOM") || normalizedTsc.Contains("UK") || normalizedTsc.Contains("GB")) return "UK"; + 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"; + + return normalizedTsc.Replace("TR", string.Empty); + } + private static IEnumerable GetCandidateDirectories(ExportSettings settings) { yield return Path.Combine(AppContext.BaseDirectory, "output"); @@ -892,6 +1086,15 @@ public class ManagementCockpitService : IManagementCockpitService public Dictionary AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase); } + private class FinanceAggregationRow + { + public int Year { get; set; } + public string CountryKey { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public bool Include { get; set; } + public decimal Value { get; set; } + } + private sealed record AggregationSelection( ValueFieldDefinition ValueField, IReadOnlyList AdditionalValueFields, diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs index 0b0cf4d..54df377 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -238,6 +238,23 @@ public class ManagementCockpitServiceTests : IDisposable Assert.Contains("gewählten Zeitraum", ex.Message); } + [Fact] + public async Task AnalyzeFinanceSummaryAsync_Returns_Empty_Result_For_Filter_With_No_Rows() + { + await SeedCentralRowsAsync( + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "INV-1", "EUR", 100m, new DateTime(2025, 1, 10))); + + var result = await _service.AnalyzeFinanceSummaryAsync(2026, "DE", null); + + Assert.Equal(2026, result.Filter.Year); + Assert.Equal("DE", result.Filter.CountryKey); + Assert.Empty(result.Rows); + Assert.Equal(0m, result.NetSalesActual); + Assert.Contains("keine Datensaetze", result.Notices[0]); + Assert.Contains(2025, result.YearOptions); + Assert.Contains("DE", result.CountryOptions); + } + private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows) { await using var db = await _dbFactory.CreateDbContextAsync();