Add finance management analysis tabs
This commit is contained in:
@@ -140,6 +140,150 @@
|
|||||||
</MudTable>
|
</MudTable>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudTabPanel>
|
</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">
|
<MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats">
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
@@ -647,6 +791,42 @@
|
|||||||
? value.ToString("N2")
|
? value.ToString("N2")
|
||||||
: $"{value:N2} {currency}";
|
: $"{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)
|
private void SetSelectedCentralAdditionalValueFields(IEnumerable<string> values)
|
||||||
{
|
{
|
||||||
_selectedCentralAdditionalValueFields = values
|
_selectedCentralAdditionalValueFields = values
|
||||||
|
|||||||
@@ -171,6 +171,50 @@ public class ManagementFinanceSummaryRow
|
|||||||
public decimal NetSalesActual { get; set; }
|
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 class ManagementFinanceSummaryResult
|
||||||
{
|
{
|
||||||
public ManagementFinanceSummaryFilter Filter { get; set; } = new();
|
public ManagementFinanceSummaryFilter Filter { get; set; } = new();
|
||||||
@@ -186,4 +230,9 @@ public class ManagementFinanceSummaryResult
|
|||||||
public int CurrencyCount { get; set; }
|
public int CurrencyCount { get; set; }
|
||||||
public decimal NetSalesActual { get; set; }
|
public decimal NetSalesActual { get; set; }
|
||||||
public string DisplayCurrency { get; set; } = string.Empty;
|
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 class SalesRecord
|
||||||
{
|
{
|
||||||
public DateTime ExtractionDate { get; set; }
|
public DateTime ExtractionDate { get; set; }
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
public string Tsc { get; set; } = string.Empty;
|
public string Tsc { get; set; } = string.Empty;
|
||||||
public int DocumentEntry { get; set; }
|
public int DocumentEntry { get; set; }
|
||||||
public string InvoiceNumber { get; set; } = string.Empty;
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Select(r => new SalesRecord
|
.Select(r => new SalesRecord
|
||||||
{
|
{
|
||||||
|
SourceSystem = r.SourceSystem,
|
||||||
Land = r.Land,
|
Land = r.Land,
|
||||||
Tsc = r.Tsc,
|
Tsc = r.Tsc,
|
||||||
DocumentEntry = r.DocumentEntry,
|
DocumentEntry = r.DocumentEntry,
|
||||||
@@ -320,6 +321,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
PositionOnInvoice = r.PositionOnInvoice,
|
PositionOnInvoice = r.PositionOnInvoice,
|
||||||
Material = r.Material,
|
Material = r.Material,
|
||||||
Name = r.Name,
|
Name = r.Name,
|
||||||
|
ProductGroup = r.ProductGroup,
|
||||||
Quantity = r.Quantity,
|
Quantity = r.Quantity,
|
||||||
SupplierCountry = r.SupplierCountry,
|
SupplierCountry = r.SupplierCountry,
|
||||||
CustomerNumber = r.CustomerNumber,
|
CustomerNumber = r.CustomerNumber,
|
||||||
@@ -350,9 +352,22 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
{
|
{
|
||||||
Year = financeDate.Year,
|
Year = financeDate.Year,
|
||||||
CountryKey = resolvedCountryKey,
|
CountryKey = resolvedCountryKey,
|
||||||
|
Land = record.Land,
|
||||||
|
Tsc = record.Tsc,
|
||||||
|
SourceSystem = string.IsNullOrWhiteSpace(record.SourceSystem) ? "-" : record.SourceSystem,
|
||||||
Currency = ResolveFinanceCurrency(record),
|
Currency = ResolveFinanceCurrency(record),
|
||||||
Include = include,
|
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();
|
.ToList();
|
||||||
@@ -408,6 +423,20 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
notices.Insert(0, "Fuer die gewaehlten Finance-Filter gibt es keine Datensaetze im aktuellen Zentraldatenbestand.");
|
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
|
return new ManagementFinanceSummaryResult
|
||||||
{
|
{
|
||||||
Filter = new ManagementFinanceSummaryFilter
|
Filter = new ManagementFinanceSummaryFilter
|
||||||
@@ -435,10 +464,205 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
CurrencyCount = resultCurrencies.Count,
|
CurrencyCount = resultCurrencies.Count,
|
||||||
NetSalesActual = summaryRows.Sum(row => row.NetSalesActual),
|
NetSalesActual = summaryRows.Sum(row => row.NetSalesActual),
|
||||||
DisplayCurrency = BuildDisplayCurrencyLabel(resultCurrencies),
|
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(
|
private static IEnumerable<CentralAggregationRow> ApplyCentralDimensionFilters(
|
||||||
IEnumerable<CentralAggregationRow> rows,
|
IEnumerable<CentralAggregationRow> rows,
|
||||||
ManagementCockpitAnalysisOptions? options)
|
ManagementCockpitAnalysisOptions? options)
|
||||||
@@ -1090,9 +1314,22 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
{
|
{
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
public string CountryKey { get; set; } = string.Empty;
|
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 string Currency { get; set; } = string.Empty;
|
||||||
public bool Include { get; set; }
|
public bool Include { get; set; }
|
||||||
public decimal Value { 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(
|
private sealed record AggregationSelection(
|
||||||
|
|||||||
@@ -255,6 +255,64 @@ public class ManagementCockpitServiceTests : IDisposable
|
|||||||
Assert.Contains("DE", result.CountryOptions);
|
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)
|
private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows)
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Stand: 2026-05-27
|
|||||||
- Fuehrende Sicht: `Finance Summary`.
|
- Fuehrende Sicht: `Finance Summary`.
|
||||||
- `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel.
|
- `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel.
|
||||||
- `Management Analyse` bleibt Diagnose-/Plausibilitaetssicht, nicht fuehrende Finance-Zahl.
|
- `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.
|
- Filter fuer Jahr, Land und Waehrung wirken auf das Finance-Endergebnis.
|
||||||
- Standard-Ist bleibt inklusive Positionen; Intercompany/2nd-party wird separat ausgewiesen.
|
- 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.
|
- IT: Nach neuem IT-Export pruefen, ob die vollstaendige `Trafag Italia`-Summe sichtbar wird.
|
||||||
- ES: Differenz zu Rhino/check.xlsx bleibt fachlich zu klaeren.
|
- 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-Kurzindex
|
||||||
|
|
||||||
| Land | Kurzregel |
|
| Land | Kurzregel |
|
||||||
|
|||||||
@@ -12,6 +12,26 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
|
|||||||
- Letzte dokumentierte Validierung: Build erfolgreich, Tests `78/78` gruen.
|
- 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: 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 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
|
## Nachtrag 2026-05-27 Produktsparten-Mapping
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user