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