Add finance management analysis tabs

This commit is contained in:
2026-05-28 12:51:18 +02:00
parent d0762ec18b
commit da0f39235c
7 changed files with 557 additions and 2 deletions
@@ -140,6 +140,150 @@
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Laender", "Countries")" Icon="@Icons.Material.Filled.Public">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Finance-Status nach Land", "Finance status by country")</MudText>
<MudTable Items="_financeResult.CountryRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Ist", "Actual")</MudTh>
<MudTh>@T("Soll", "Reference")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudChip T="string" Size="Size.Small" Color="@StatusColor(context.Status)" Variant="Variant.Outlined">@context.Status</MudChip></MudTd>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Tscs</MudTd>
<MudTd>@context.SourceSystems</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@FormatNullableValue(context.ReferenceValue, context.Currency)</MudTd>
<MudTd>@FormatNullableValue(context.Difference, context.Currency)</MudTd>
<MudTd>@context.IncludedRows.ToString("N0") / @context.ExcludedRows.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Laenderdaten fuer diese Filter.", "No country data for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenbestand nach Standort", "Data inventory by site")</MudText>
<MudTable Items="_financeResult.DataStatusRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Zentrale Zeilen", "Central rows")</MudTh>
<MudTh>@T("Letzter Export", "Latest export")</MudTh>
<MudTh>@T("Exportstatus", "Export status")</MudTh>
<MudTh>@T("Letzte Speicherung", "Latest stored")</MudTh>
<MudTh>@T("Manual Import", "Manual import")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIcon Icon="@(context.IsActive ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Cancel)"
Color="@(context.IsActive ? Color.Success : Color.Default)" Size="Size.Small" />
</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.Tsc</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@FormatDateTime(context.LatestExportAt)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.LatestExportStatus) ? "-" : context.LatestExportStatus)</MudTd>
<MudTd>@FormatDateTime(context.LatestStoredAtUtc)</MudTd>
<MudTd>@FormatManualImportStatus(context)</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Abweichungen", "Deviations")" Icon="@Icons.Material.Filled.WarningAmber">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Soll/Ist-Abweichungen", "Actual/reference deviations")</MudText>
<MudTable Items="_financeResult.DeviationRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Ist", "Actual")</MudTh>
<MudTh>@T("Soll", "Reference")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>%</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudChip T="string" Size="Size.Small" Color="@StatusColor(context.Status)" Variant="Variant.Outlined">@context.Status</MudChip></MudTd>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@FormatNullableValue(context.ReferenceValue, context.Currency)</MudTd>
<MudTd>@FormatNullableValue(context.Difference, context.Currency)</MudTd>
<MudTd>@FormatPercent(context.DifferencePercent)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Sollwerte oder keine Abweichungen fuer diese Filter.", "No reference values or deviations for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Gutschriften", "Credit notes")" Icon="@Icons.Material.Filled.AssignmentReturn">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Gutschriften-Kandidaten", "Credit-note candidates")</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
@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.")
</MudAlert>
<MudTable Items="_financeResult.CreditCandidates" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Rechnung", "Invoice")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Wert", "Value")</MudTh>
<MudTh>@T("Menge", "Quantity")</MudTh>
<MudTh>@T("Grund", "Reason")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Tsc</MudTd>
<MudTd>@context.InvoiceNumber</MudTd>
<MudTd>@context.DocumentType</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@context.Quantity.ToString("N2")</MudTd>
<MudTd>@context.Reason</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Gutschriften-Kandidaten fuer diese Filter.", "No credit-note candidates for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Datenqualitaet", "Data quality")" Icon="@Icons.Material.Filled.Rule">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Pruefpunkte", "Checkpoints")</MudText>
<MudTable Items="_financeResult.DataQualityRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Pruefpunkt", "Checkpoint")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudChip T="string" Size="Size.Small" Color="@SeverityColor(context.Severity)" Variant="Variant.Outlined">@context.Severity</MudChip></MudTd>
<MudTd>@context.Issue</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Datenqualitaetsauffaelligkeiten fuer diese Filter.", "No data-quality findings for these filters.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats">
<MudPaper Class="pa-4 mb-4" Elevation="1">
@@ -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<string> values)
{
_selectedCentralAdditionalValueFields = values
@@ -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<ManagementFinanceCountryStatusRow> CountryRows { get; set; } = [];
public List<ManagementFinanceCountryStatusRow> DeviationRows { get; set; } = [];
public List<ManagementFinanceDataStatusRow> DataStatusRows { get; set; } = [];
public List<ManagementFinanceCreditCandidateRow> CreditCandidates { get; set; } = [];
public List<ManagementFinanceDataQualityRow> DataQualityRows { get; set; } = [];
}
@@ -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;
@@ -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<List<ManagementFinanceDataStatusRow>> 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<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows(
IReadOnlyCollection<FinanceAggregationRow> rows,
IReadOnlyDictionary<string, decimal?> 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<ManagementFinanceCreditCandidateRow> BuildFinanceCreditCandidates(IEnumerable<FinanceAggregationRow> 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<ManagementFinanceDataQualityRow> BuildFinanceDataQualityRows(IReadOnlyCollection<FinanceAggregationRow> rows)
{
var rowCount = rows.Count;
return new List<ManagementFinanceDataQualityRow>
{
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<FinanceAggregationRow> rows)
{
var rowList = rows.ToList();
var reasons = new List<string>();
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<string> 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<CentralAggregationRow> ApplyCentralDimensionFilters(
IEnumerable<CentralAggregationRow> 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(
@@ -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();
+10
View File
@@ -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 |
+20
View File
@@ -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