From da0f39235cbadedbfc74fcbef2f4bb8eddb73e07 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 28 May 2026 12:51:18 +0200 Subject: [PATCH] Add finance management analysis tabs --- .../Components/Pages/ManagementCockpit.razor | 180 +++++++++++++ .../Models/ManagementCockpitModels.cs | 49 ++++ TrafagSalesExporter/Models/SalesRecord.cs | 1 + .../Services/ManagementCockpitService.cs | 241 +++++++++++++++++- .../ManagementCockpitServiceTests.cs | 58 +++++ TrafagSalesExporter/docs/rag/FINANCE.md | 10 + TrafagSalesExporter/lastchange.md | 20 ++ 7 files changed, 557 insertions(+), 2 deletions(-) diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 578030e..7d6a3ff 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -140,6 +140,150 @@ + + + @T("Finance-Status nach Land", "Finance status by country") + + + @T("Status", "Status") + @T("Land", "Country") + TSC + @T("Quelle", "Source") + @T("Waehrung", "Currency") + @T("Ist", "Actual") + @T("Soll", "Reference") + @T("Differenz", "Difference") + @T("Zeilen", "Rows") + + + @context.Status + @context.CountryKey + @context.Tscs + @context.SourceSystems + @context.Currency + @FormatValue(context.NetSalesActual, context.Currency) + @FormatNullableValue(context.ReferenceValue, context.Currency) + @FormatNullableValue(context.Difference, context.Currency) + @context.IncludedRows.ToString("N0") / @context.ExcludedRows.ToString("N0") + + + @T("Keine Laenderdaten fuer diese Filter.", "No country data for these filters.") + + + + + + + @T("Datenbestand nach Standort", "Data inventory by site") + + + @T("Aktiv", "Active") + @T("Land", "Country") + TSC + @T("Quelle", "Source") + @T("Zentrale Zeilen", "Central rows") + @T("Letzter Export", "Latest export") + @T("Exportstatus", "Export status") + @T("Letzte Speicherung", "Latest stored") + @T("Manual Import", "Manual import") + + + + + + @context.Land + @context.Tsc + @context.SourceSystem + @context.RowCount.ToString("N0") + @FormatDateTime(context.LatestExportAt) + @(string.IsNullOrWhiteSpace(context.LatestExportStatus) ? "-" : context.LatestExportStatus) + @FormatDateTime(context.LatestStoredAtUtc) + @FormatManualImportStatus(context) + + + + + + + @T("Soll/Ist-Abweichungen", "Actual/reference deviations") + + + @T("Status", "Status") + @T("Land", "Country") + @T("Waehrung", "Currency") + @T("Ist", "Actual") + @T("Soll", "Reference") + @T("Differenz", "Difference") + % + + + @context.Status + @context.CountryKey + @context.Currency + @FormatValue(context.NetSalesActual, context.Currency) + @FormatNullableValue(context.ReferenceValue, context.Currency) + @FormatNullableValue(context.Difference, context.Currency) + @FormatPercent(context.DifferencePercent) + + + @T("Keine Sollwerte oder keine Abweichungen fuer diese Filter.", "No reference values or deviations for these filters.") + + + + + + + @T("Gutschriften-Kandidaten", "Credit-note candidates") + + @T("Diese Sicht zeigt technische Kandidaten anhand negativer Werte und erkennbarer Belegtypen/-nummern. Sie ersetzt keine landesspezifische Fachfreigabe.", + "This view shows technical candidates based on negative values and recognizable document types/numbers. It does not replace country-specific business approval.") + + + + @T("Land", "Country") + TSC + @T("Rechnung", "Invoice") + @T("Typ", "Type") + @T("Wert", "Value") + @T("Menge", "Quantity") + @T("Grund", "Reason") + + + @context.CountryKey + @context.Tsc + @context.InvoiceNumber + @context.DocumentType + @FormatValue(context.NetSalesActual, context.Currency) + @context.Quantity.ToString("N2") + @context.Reason + + + @T("Keine Gutschriften-Kandidaten fuer diese Filter.", "No credit-note candidates for these filters.") + + + + + + + @T("Pruefpunkte", "Checkpoints") + + + @T("Status", "Status") + @T("Pruefpunkt", "Checkpoint") + @T("Anzahl", "Count") + + + @context.Severity + @context.Issue + @context.Count.ToString("N0") + + + @T("Keine Datenqualitaetsauffaelligkeiten fuer diese Filter.", "No data-quality findings for these filters.") + + + + @@ -647,6 +791,42 @@ ? value.ToString("N2") : $"{value:N2} {currency}"; + private static string FormatNullableValue(decimal? value, string currency) + => value.HasValue ? FormatValue(value.Value, currency) : "-"; + + private static string FormatPercent(decimal? value) + => value.HasValue ? $"{value.Value:N1}%" : "-"; + + private static string FormatDateTime(DateTime? value) + => value.HasValue ? value.Value.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "-"; + + private static string FormatManualImportStatus(ManagementFinanceDataStatusRow row) + { + if (!string.Equals(row.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase)) + return "-"; + + if (!string.IsNullOrWhiteSpace(row.ManualImportFilePath)) + return row.ManualImportLastUploadedAtUtc.HasValue + ? $"{System.IO.Path.GetFileName(row.ManualImportFilePath)} / {FormatDateTime(row.ManualImportLastUploadedAtUtc)}" + : System.IO.Path.GetFileName(row.ManualImportFilePath); + + return "kein Pfad"; + } + + private static Color StatusColor(string status) => status switch + { + "OK" => Color.Success, + "Pruefen" => Color.Warning, + _ => Color.Default + }; + + private static Color SeverityColor(string severity) => severity switch + { + "Warning" => Color.Warning, + "Error" => Color.Error, + _ => Color.Info + }; + private void SetSelectedCentralAdditionalValueFields(IEnumerable values) { _selectedCentralAdditionalValueFields = values diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs index 6d26a7a..3fea0c5 100644 --- a/TrafagSalesExporter/Models/ManagementCockpitModels.cs +++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs @@ -171,6 +171,50 @@ public class ManagementFinanceSummaryRow public decimal NetSalesActual { get; set; } } +public class ManagementFinanceCountryStatusRow : ManagementFinanceSummaryRow +{ + public string SourceSystems { get; set; } = string.Empty; + public string Tscs { get; set; } = string.Empty; + public decimal? ReferenceValue { get; set; } + public decimal? Difference { get; set; } + public decimal? DifferencePercent { get; set; } + public string Status { get; set; } = string.Empty; +} + +public class ManagementFinanceDataStatusRow +{ + public string Land { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string SourceSystem { get; set; } = string.Empty; + public bool IsActive { get; set; } + public int RowCount { get; set; } + public DateTime? LatestStoredAtUtc { get; set; } + public DateTime? LatestExtractionDate { get; set; } + public DateTime? LatestExportAt { get; set; } + public string LatestExportStatus { get; set; } = string.Empty; + public string ManualImportFilePath { get; set; } = string.Empty; + public DateTime? ManualImportLastUploadedAtUtc { get; set; } +} + +public class ManagementFinanceCreditCandidateRow +{ + public string CountryKey { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string InvoiceNumber { get; set; } = string.Empty; + public string DocumentType { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public decimal NetSalesActual { get; set; } + public decimal Quantity { get; set; } + public string Reason { get; set; } = string.Empty; +} + +public class ManagementFinanceDataQualityRow +{ + public string Issue { get; set; } = string.Empty; + public int Count { get; set; } + public string Severity { get; set; } = "Info"; +} + public class ManagementFinanceSummaryResult { public ManagementFinanceSummaryFilter Filter { get; set; } = new(); @@ -186,4 +230,9 @@ public class ManagementFinanceSummaryResult public int CurrencyCount { get; set; } public decimal NetSalesActual { get; set; } public string DisplayCurrency { get; set; } = string.Empty; + public List CountryRows { get; set; } = []; + public List DeviationRows { get; set; } = []; + public List DataStatusRows { get; set; } = []; + public List CreditCandidates { get; set; } = []; + public List DataQualityRows { get; set; } = []; } diff --git a/TrafagSalesExporter/Models/SalesRecord.cs b/TrafagSalesExporter/Models/SalesRecord.cs index 4ba5696..ffaed3a 100644 --- a/TrafagSalesExporter/Models/SalesRecord.cs +++ b/TrafagSalesExporter/Models/SalesRecord.cs @@ -3,6 +3,7 @@ namespace TrafagSalesExporter.Models; public class SalesRecord { public DateTime ExtractionDate { get; set; } + public string SourceSystem { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty; public int DocumentEntry { get; set; } public string InvoiceNumber { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index dd2ef4b..3871b7a 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -313,6 +313,7 @@ public class ManagementCockpitService : IManagementCockpitService .AsNoTracking() .Select(r => new SalesRecord { + SourceSystem = r.SourceSystem, Land = r.Land, Tsc = r.Tsc, DocumentEntry = r.DocumentEntry, @@ -320,6 +321,7 @@ public class ManagementCockpitService : IManagementCockpitService PositionOnInvoice = r.PositionOnInvoice, Material = r.Material, Name = r.Name, + ProductGroup = r.ProductGroup, Quantity = r.Quantity, SupplierCountry = r.SupplierCountry, CustomerNumber = r.CustomerNumber, @@ -350,9 +352,22 @@ public class ManagementCockpitService : IManagementCockpitService { Year = financeDate.Year, CountryKey = resolvedCountryKey, + Land = record.Land, + Tsc = record.Tsc, + SourceSystem = string.IsNullOrWhiteSpace(record.SourceSystem) ? "-" : record.SourceSystem, Currency = ResolveFinanceCurrency(record), Include = include, - Value = value + Value = value, + RawSalesValue = record.SalesPriceValue, + Quantity = record.Quantity, + InvoiceNumber = record.InvoiceNumber, + DocumentType = record.DocumentType, + Material = record.Material, + ProductGroup = record.ProductGroup, + CustomerName = record.CustomerName, + PostingDate = record.PostingDate, + InvoiceDate = record.InvoiceDate, + ExtractionDate = record.ExtractionDate }; }) .ToList(); @@ -408,6 +423,20 @@ public class ManagementCockpitService : IManagementCockpitService notices.Insert(0, "Fuer die gewaehlten Finance-Filter gibt es keine Datensaetze im aktuellen Zentraldatenbestand."); } + var references = await db.FinanceReferences + .AsNoTracking() + .Where(reference => reference.IsActive && reference.Year == year) + .ToListAsync(); + var referenceByKey = references + .GroupBy(reference => reference.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Select(reference => reference.CheckValue ?? reference.LocalCurrencyValue).FirstOrDefault(value => value.HasValue), + StringComparer.OrdinalIgnoreCase); + + var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db); + var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey); + return new ManagementFinanceSummaryResult { Filter = new ManagementFinanceSummaryFilter @@ -435,10 +464,205 @@ public class ManagementCockpitService : IManagementCockpitService CurrencyCount = resultCurrencies.Count, NetSalesActual = summaryRows.Sum(row => row.NetSalesActual), DisplayCurrency = BuildDisplayCurrencyLabel(resultCurrencies), - Notices = notices + Notices = notices, + CountryRows = countryRows, + DeviationRows = countryRows + .Where(row => row.Difference.HasValue) + .OrderByDescending(row => Math.Abs(row.Difference!.Value)) + .ToList(), + DataStatusRows = dataStatusRows, + CreditCandidates = BuildFinanceCreditCandidates(scopedRows), + DataQualityRows = BuildFinanceDataQualityRows(scopedRows) }; } + private static async Task> BuildFinanceDataStatusRowsAsync(AppDbContext db) + { + var sites = await db.Sites + .AsNoTracking() + .OrderBy(site => site.Land) + .ThenBy(site => site.TSC) + .ToListAsync(); + var records = await db.CentralSalesRecords + .AsNoTracking() + .GroupBy(record => record.Tsc) + .Select(group => new + { + Tsc = group.Key, + RowCount = group.Count(), + LatestStoredAtUtc = group.Max(record => record.StoredAtUtc), + LatestExtractionDate = group.Max(record => record.ExtractionDate) + }) + .ToListAsync(); + var logs = await db.ExportLogs + .AsNoTracking() + .GroupBy(log => log.TSC) + .Select(group => new + { + Tsc = group.Key, + LatestTimestamp = group.Max(log => log.Timestamp) + }) + .ToListAsync(); + var latestLogTimes = logs.ToDictionary(x => x.Tsc, x => x.LatestTimestamp, StringComparer.OrdinalIgnoreCase); + var latestLogs = await db.ExportLogs + .AsNoTracking() + .Where(log => logs.Select(x => x.LatestTimestamp).Contains(log.Timestamp)) + .ToListAsync(); + var recordByTsc = records.ToDictionary(x => x.Tsc, StringComparer.OrdinalIgnoreCase); + var logByTsc = latestLogs + .Where(log => latestLogTimes.TryGetValue(log.TSC, out var timestamp) && log.Timestamp == timestamp) + .GroupBy(log => log.TSC, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.OrderByDescending(log => log.Id).First(), StringComparer.OrdinalIgnoreCase); + + return sites.Select(site => + { + recordByTsc.TryGetValue(site.TSC, out var record); + logByTsc.TryGetValue(site.TSC, out var log); + return new ManagementFinanceDataStatusRow + { + Land = site.Land, + Tsc = site.TSC, + SourceSystem = site.SourceSystem, + IsActive = site.IsActive, + RowCount = record?.RowCount ?? 0, + LatestStoredAtUtc = record?.LatestStoredAtUtc, + LatestExtractionDate = record?.LatestExtractionDate, + LatestExportAt = log?.Timestamp, + LatestExportStatus = log?.Status ?? string.Empty, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc + }; + }).ToList(); + } + + private static List BuildFinanceCountryStatusRows( + IReadOnlyCollection rows, + IReadOnlyDictionary referenceByKey) + => rows + .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 => + { + var rowList = group.ToList(); + referenceByKey.TryGetValue(group.Key.CountryKey, out var referenceValue); + var actual = rowList.Sum(row => row.Value); + var difference = referenceValue.HasValue ? actual - referenceValue.Value : (decimal?)null; + return new ManagementFinanceCountryStatusRow + { + Year = group.Key.Year, + CountryKey = group.Key.CountryKey, + Currency = group.Key.Currency, + IncludedRows = rowList.Count(row => row.Include), + ExcludedRows = rowList.Count(row => !row.Include), + NetSalesActual = actual, + SourceSystems = JoinDistinct(rowList.Select(row => row.SourceSystem)), + Tscs = JoinDistinct(rowList.Select(row => row.Tsc)), + ReferenceValue = referenceValue, + Difference = difference, + DifferencePercent = referenceValue is > 0m && difference.HasValue ? difference.Value / referenceValue.Value * 100m : null, + Status = BuildFinanceStatus(difference) + }; + }) + .ToList(); + + private static List BuildFinanceCreditCandidates(IEnumerable rows) + => rows + .Where(row => row.Value < 0m || row.RawSalesValue < 0m || LooksLikeCreditDocument(row.DocumentType, row.InvoiceNumber)) + .GroupBy(row => new { row.CountryKey, row.Tsc, row.InvoiceNumber, row.DocumentType, row.Currency }) + .Select(group => + { + var rowList = group.ToList(); + return new ManagementFinanceCreditCandidateRow + { + CountryKey = group.Key.CountryKey, + Tsc = group.Key.Tsc, + InvoiceNumber = group.Key.InvoiceNumber, + DocumentType = group.Key.DocumentType, + Currency = group.Key.Currency, + NetSalesActual = rowList.Sum(row => row.Value), + Quantity = rowList.Sum(row => row.Quantity), + Reason = BuildCreditReason(rowList) + }; + }) + .OrderBy(row => row.NetSalesActual) + .Take(100) + .ToList(); + + private static List BuildFinanceDataQualityRows(IReadOnlyCollection rows) + { + var rowCount = rows.Count; + return new List + { + BuildQualityRow("Fehlende Materialnummer", rows.Count(row => string.IsNullOrWhiteSpace(row.Material)), rowCount), + BuildQualityRow("Fehlende ProductGroup", rows.Count(row => string.IsNullOrWhiteSpace(row.ProductGroup)), rowCount), + BuildQualityRow("Fehlende Waehrung", rows.Count(row => string.IsNullOrWhiteSpace(row.Currency) || row.Currency == "-"), rowCount), + BuildQualityRow("Fehlender Kunde", rows.Count(row => string.IsNullOrWhiteSpace(row.CustomerName)), rowCount), + BuildQualityRow("Fehlendes Rechnungsdatum", rows.Count(row => !row.InvoiceDate.HasValue), rowCount), + BuildQualityRow("Fehlendes Buchungsdatum", rows.Count(row => !row.PostingDate.HasValue), rowCount), + BuildQualityRow("Nullwerte im Finance-Wert", rows.Count(row => row.Value == 0m), rowCount), + BuildQualityRow("Ausgeschlossene Zeilen", rows.Count(row => !row.Include), rowCount) + } + .Where(row => row.Count > 0) + .OrderByDescending(row => row.Count) + .ThenBy(row => row.Issue, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static ManagementFinanceDataQualityRow BuildQualityRow(string issue, int count, int totalRows) + { + var share = totalRows == 0 ? 0m : count / (decimal)totalRows; + return new ManagementFinanceDataQualityRow + { + Issue = issue, + Count = count, + Severity = count == 0 ? "Info" : share >= 0.2m ? "Warning" : "Info" + }; + } + + private static string BuildFinanceStatus(decimal? difference) + { + if (!difference.HasValue) + return "Kein Sollwert"; + + return Math.Abs(difference.Value) <= 1m ? "OK" : "Pruefen"; + } + + private static bool LooksLikeCreditDocument(string documentType, string invoiceNumber) + { + var text = $"{documentType} {invoiceNumber}".Trim(); + return text.Contains("credit", StringComparison.OrdinalIgnoreCase) || + text.Contains("gutsch", StringComparison.OrdinalIgnoreCase) || + text.Contains("storno", StringComparison.OrdinalIgnoreCase) || + text.Contains("abono", StringComparison.OrdinalIgnoreCase) || + text.Contains("rec", StringComparison.OrdinalIgnoreCase) || + invoiceNumber.StartsWith("GS", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildCreditReason(IEnumerable rows) + { + var rowList = rows.ToList(); + var reasons = new List(); + if (rowList.Any(row => row.Value < 0m)) + reasons.Add("negativer Finance-Wert"); + if (rowList.Any(row => row.RawSalesValue < 0m)) + reasons.Add("negativer Rohwert"); + if (rowList.Any(row => LooksLikeCreditDocument(row.DocumentType, row.InvoiceNumber))) + reasons.Add("Belegtyp/-nummer"); + return string.Join(", ", reasons.Distinct(StringComparer.OrdinalIgnoreCase)); + } + + private static string JoinDistinct(IEnumerable values) + { + var distinct = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(); + return distinct.Count == 0 ? "-" : string.Join(", ", distinct); + } + private static IEnumerable ApplyCentralDimensionFilters( IEnumerable rows, ManagementCockpitAnalysisOptions? options) @@ -1090,9 +1314,22 @@ public class ManagementCockpitService : IManagementCockpitService { public int Year { get; set; } public string CountryKey { get; set; } = string.Empty; + public string Land { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string SourceSystem { get; set; } = string.Empty; public string Currency { get; set; } = string.Empty; public bool Include { get; set; } public decimal Value { get; set; } + public decimal RawSalesValue { get; set; } + public decimal Quantity { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; + public string DocumentType { get; set; } = string.Empty; + public string Material { get; set; } = string.Empty; + public string ProductGroup { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public DateTime? PostingDate { get; set; } + public DateTime? InvoiceDate { get; set; } + public DateTime ExtractionDate { get; set; } } private sealed record AggregationSelection( diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs index 54df377..7ccc7ea 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -255,6 +255,64 @@ public class ManagementCockpitServiceTests : IDisposable Assert.Contains("DE", result.CountryOptions); } + [Fact] + public async Task AnalyzeFinanceSummaryAsync_Builds_Dashboard_Tab_Data() + { + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.Sites.Add(new Site + { + Id = 2, + HanaServerId = null, + Schema = "de", + TSC = "TRDE", + Land = "Deutschland", + SourceSystem = "MANUAL_EXCEL", + IsActive = true + }); + db.FinanceReferences.RemoveRange(db.FinanceReferences); + db.FinanceReferences.Add(new FinanceReference + { + Key = "DE", + Label = "Trafag DE", + Year = 2025, + LocalCurrencyValue = 120m, + IsActive = true + }); + db.ExportLogs.Add(new ExportLog + { + SiteId = 1, + Timestamp = new DateTime(2025, 1, 20, 10, 0, 0), + Land = "Deutschland", + TSC = "TRDE", + Status = "OK", + RowCount = 2, + FileName = "de.xlsx", + FilePath = "de.xlsx" + }); + await db.SaveChangesAsync(); + } + + await SeedCentralRowsAsync( + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "INV-1", "EUR", 100m, new DateTime(2025, 1, 10)), + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "GS-1", "EUR", -20m, new DateTime(2025, 1, 11), quantity: -1m), + CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "INV-2", "EUR", 0m, new DateTime(2025, 1, 12))); + + var result = await _service.AnalyzeFinanceSummaryAsync(2025, "DE", null); + + var country = Assert.Single(result.CountryRows); + Assert.Equal("DE", country.CountryKey); + Assert.Equal(80m, country.NetSalesActual); + Assert.Equal(120m, country.ReferenceValue); + Assert.Equal(-40m, country.Difference); + Assert.Equal("Pruefen", country.Status); + + Assert.Single(result.DeviationRows); + Assert.Contains(result.DataStatusRows, row => row.Tsc == "TRDE" && row.RowCount == 3 && row.LatestExportStatus == "OK"); + Assert.Contains(result.CreditCandidates, row => row.InvoiceNumber == "GS-1" && row.NetSalesActual == -20m); + Assert.Contains(result.DataQualityRows, row => row.Issue == "Nullwerte im Finance-Wert" && row.Count == 1); + } + private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows) { await using var db = await _dbFactory.CreateDbContextAsync(); diff --git a/TrafagSalesExporter/docs/rag/FINANCE.md b/TrafagSalesExporter/docs/rag/FINANCE.md index 52ea9a3..796de55 100644 --- a/TrafagSalesExporter/docs/rag/FINANCE.md +++ b/TrafagSalesExporter/docs/rag/FINANCE.md @@ -7,6 +7,7 @@ Stand: 2026-05-27 - Fuehrende Sicht: `Finance Summary`. - `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel. - `Management Analyse` bleibt Diagnose-/Plausibilitaetssicht, nicht fuehrende Finance-Zahl. +- `Management Analyse` hat zusaetzliche Finance-Reiter fuer Laender, Datenstatus, Abweichungen, Gutschriften-Kandidaten und Datenqualitaet. - Filter fuer Jahr, Land und Waehrung wirken auf das Finance-Endergebnis. - Standard-Ist bleibt inklusive Positionen; Intercompany/2nd-party wird separat ausgewiesen. @@ -24,6 +25,15 @@ Stand: 2026-05-27 - IT: Nach neuem IT-Export pruefen, ob die vollstaendige `Trafag Italia`-Summe sichtbar wird. - ES: Differenz zu Rhino/check.xlsx bleibt fachlich zu klaeren. +## Management-Analyse-Reiter + +- `Finance Summary`: KPI-Karten und Summen wie im zentralen Excel. +- `Laender`: Ist, Soll, Differenz, Status, Quelle und TSC je Land/Waehrung. +- `Datenstatus`: Standortbestand, letzte Speicherung, letzter Export, Manual-Import-Hinweise. +- `Abweichungen`: Soll/Ist-Abweichungen sortiert nach Betrag. +- `Gutschriften`: technische Kandidaten ueber negative Werte und erkennbare Belegtypen/-nummern. +- `Datenqualitaet`: fehlende Materialnummern, ProductGroup, Waehrung, Kunde, Datum, Nullwerte und ausgeschlossene Zeilen. + ## Land-Kurzindex | Land | Kurzregel | diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 38130fe..664d8f6 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -12,6 +12,26 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert. - Letzte dokumentierte Validierung: Build erfolgreich, Tests `78/78` gruen. - Neu dokumentiert: Produktsparten-Mapping fuer Group Sales Report ueber TR-AG-Artikelstamm und separate Mapping-Tabelle. - Neu dokumentiert: Upgreat-Firewall-Freigabe muss fuer den publizierten Webserver `10.120.1.17` erfolgen, nicht fuer den lokalen Entwicklungs-PC. +- Neu umgesetzt: `Management Analyse` im Finance Cockpit hat zusaetzliche Reiter fuer Laender, Datenstatus, Abweichungen, Gutschriften-Kandidaten und Datenqualitaet. + +## Nachtrag 2026-05-28 Finance Management Analyse Reiter + +Umgesetzt: + +- `Management Analyse` erweitert die bestehende `Finance Summary` um weitere Reiter im Cockpit-Stil. +- Neue Reiter: + - `Laender` + - `Datenstatus` + - `Abweichungen` + - `Gutschriften` + - `Datenqualitaet` +- Grundlage sind vorhandene Daten aus `CentralSalesRecords`, `FinanceReferences`, `Sites` und `ExportLogs`. +- Keine neuen Fachregeln eingefuehrt: + - Gutschriften-Reiter zeigt technische Kandidaten. + - Datenqualitaet zeigt technische Pruefpunkte. + - Produktsparten-/Produktfamilienlogik bleibt bis Kendra-Mapping offen. +- Test ergaenzt: `AnalyzeFinanceSummaryAsync_Builds_Dashboard_Tab_Data`. +- Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `79/79` Tests gruen. ## Nachtrag 2026-05-27 Produktsparten-Mapping