Update HR KPI and finance dashboard docs

This commit is contained in:
2026-05-15 10:25:01 +02:00
parent 001e2a73d5
commit e20693243d
16 changed files with 1388 additions and 108 deletions
@@ -604,6 +604,7 @@ static string BuildPage(
var checkCount = rows.Count(r => r.Status == "Pruefen");
var missingCount = rows.Count(r => r.Status == "Keine Daten");
var excelCount = excelReferences.Count;
var financeChiefOverview = BuildFinanceChiefOverview(rows, excelReferences, spainCsv, germanySample);
var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample);
var detailRows = BuildDetailRows(rows, excelReferences, spainCsv);
var coverageRows = BuildCoverageRows(coverage);
@@ -760,6 +761,25 @@ static string BuildPage(
margin: 0 0 10px;
line-height: 1.45;
}
.chief-note {
color: var(--muted);
margin: 0;
line-height: 1.45;
}
.chief-summary {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 10px;
margin: 10px 0;
}
.chief-summary .metric {
margin: 0;
}
.chief-action {
min-width: 240px;
max-width: 380px;
line-height: 1.35;
}
.ampel {
display: inline-flex;
align-items: center;
@@ -800,6 +820,7 @@ static string BuildPage(
<span>Aktualisiert: {{Html(generatedAt)}}</span>
</div>
<nav aria-label="Finance Probe Navigation">
<a href="#chef">Finanzchef Übersicht</a>
<a href="#briefing">Meeting Ampel</a>
<a href="#all-sites">Detail alle Laender</a>
<a href="#coverage">Datenabdeckung</a>
@@ -808,6 +829,7 @@ static string BuildPage(
</nav>
</header>
<main>
{{financeChiefOverview}}
{{executiveBriefing}}
<section class="summary">
<div class="metric"><strong>{{rows.Count}}</strong><span>Standorte</span></div>
@@ -954,6 +976,181 @@ static string BuildDetailRows(
.Select(row => row.Html));
}
static string BuildFinanceChiefOverview(
IReadOnlyList<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences,
SpainSalesCsvProbe? spainCsv,
GermanyExcelProbe? germanySample)
{
var issues = BuildFinanceChiefIssues(rows, excelReferences, spainCsv, germanySample).ToList();
var openIssues = issues
.Where(issue => issue.Status != "OK")
.OrderByDescending(issue => issue.SortValue)
.ThenBy(issue => issue.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
var missingCount = openIssues.Count(issue => issue.Status == "Keine Daten");
var checkCount = openIssues.Count(issue => issue.Status == "Pruefen");
var largestDifference = openIssues
.Where(issue => issue.Difference.HasValue)
.Select(issue => Math.Abs(issue.Difference!.Value))
.DefaultIfEmpty(0m)
.Max();
var tableRows = openIssues.Count == 0
? """
<tr>
<td colspan="7" class="wrap">Keine offenen Abweichungen. Alle vorhandenen Laender passen rechnerisch gegen den Sollwert.</td>
</tr>
"""
: string.Join(Environment.NewLine, openIssues.Select(BuildFinanceChiefIssueRow));
return $$"""
<section id="chef" class="briefing">
<h2>Finanzchef Übersicht</h2>
<p class="chief-note">Kompakte Sicht nur auf offene Soll/Ist-Themen. Detailtabellen bleiben unten fuer Analyse und Nachvollzug.</p>
<div class="chief-summary">
<div class="metric"><strong>{{openIssues.Count}}</strong><span>Offen</span></div>
<div class="metric"><strong>{{checkCount}}</strong><span>Abweichungen</span></div>
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
</div>
<div class="chief-note" style="margin-bottom:10px;">Groesste absolute Abweichung: {{Amount(largestDifference)}}</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Status</th>
<th>Land</th>
<th>Waehrung</th>
<th class="num">Ist</th>
<th class="num">Soll</th>
<th class="num">Abweichung</th>
<th>Was ist zu pruefen</th>
</tr>
</thead>
<tbody>{{tableRows}}</tbody>
</table>
</div>
</section>
""";
}
static IEnumerable<FinanceChiefIssue> BuildFinanceChiefIssues(
IReadOnlyList<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences,
SpainSalesCsvProbe? spainCsv,
GermanyExcelProbe? germanySample)
{
var issues = rows
.Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase))
.Select(row => new FinanceChiefIssue(
row.Status,
row.Label,
row.Key,
BuildFinanceChiefCurrency(row),
row.ActualValue,
row.ReferenceValue,
row.Difference,
BuildFinanceChiefReason(row, germanySample),
row.Difference.HasValue ? Math.Abs(row.Difference.Value) : decimal.MaxValue))
.ToList();
if (spainCsv is not null)
{
var status = Math.Abs(spainCsv.Difference) <= 1m ? "OK" : "Pruefen";
issues.Add(new FinanceChiefIssue(
status,
"Trafag ES",
"ES",
"EUR",
spainCsv.SalesPriceValue,
spainCsv.ReferenceValue,
spainCsv.Difference,
status == "OK"
? "Spain CSV passt rechnerisch gegen check.xlsx."
: "Spain CSV hat Differenz. Periodenabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften pruefen.",
Math.Abs(spainCsv.Difference)));
}
var existingLabels = issues
.Select(issue => issue.Label)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var reference in excelReferences.Values)
{
if (existingLabels.Contains(reference.Label))
continue;
var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue;
issues.Add(new FinanceChiefIssue(
"Keine Daten",
reference.Label,
"check.xlsx",
reference.PowerBiValue.HasValue ? "Sollwert" : "LC",
null,
referenceValue,
null,
"Sollwert ist in check.xlsx vorhanden, aber es gibt keinen belastbaren Ist-Import. Standort, Export oder Aktivierung pruefen.",
decimal.MaxValue));
}
return issues;
}
static string BuildFinanceChiefIssueRow(FinanceChiefIssue issue)
{
var statusClass = issue.Status.Replace(" ", string.Empty);
return $$"""
<tr>
<td><span class="status {{Html(statusClass)}}">{{Html(issue.Status)}}</span></td>
<td><strong>{{Html(issue.Label)}}</strong><div class="small">{{Html(issue.Key)}}</div></td>
<td>{{Html(issue.Currency)}}</td>
<td class="num">{{Amount(issue.ActualValue)}}</td>
<td class="num">{{Amount(issue.ReferenceValue)}}</td>
<td class="num">{{Amount(issue.Difference)}}</td>
<td class="chief-action">{{Html(issue.Reason)}}</td>
</tr>
""";
}
static string BuildFinanceChiefCurrency(NetSalesReferenceRow row)
{
var actualCurrency = string.IsNullOrWhiteSpace(row.ActualCurrency) ? row.Currencies : row.ActualCurrency;
var referenceCurrency = row.ReferenceCurrency;
if (string.IsNullOrWhiteSpace(actualCurrency) && string.IsNullOrWhiteSpace(referenceCurrency))
return "-";
if (string.IsNullOrWhiteSpace(referenceCurrency) ||
referenceCurrency.Equals("Sollwert", StringComparison.OrdinalIgnoreCase) ||
actualCurrency.Equals(referenceCurrency, StringComparison.OrdinalIgnoreCase))
return actualCurrency;
if (string.IsNullOrWhiteSpace(actualCurrency))
return referenceCurrency;
return $"{actualCurrency} / Soll {referenceCurrency}";
}
static string BuildFinanceChiefReason(NetSalesReferenceRow row, GermanyExcelProbe? germanySample)
{
if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase) && germanySample is not null)
return "DE-Beispielfile ist lesbar, aber nur Sample. Finalen Jahresexport/Abgrenzung pruefen.";
if (row.Status == "OK")
return "Passt rechnerisch gegen check.xlsx.";
if (row.Status == "Keine Daten")
return "Kein belastbarer Ist-Import. Standort, Export, Mapping oder Aktivierung pruefen.";
if (row.DifferenceExcludingIntercompany.HasValue &&
Math.Abs(row.DifferenceExcludingIntercompany.Value) <= 1m)
return "Abweichung ist nach 2nd-party/Intercompany-Abzug erklaerbar. IC-Regel fachlich bestaetigen.";
if (row.Candidates.Count > 1)
return "Abweichung offen. Gewaehlter Wert folgt Hauswaehrung/Nettofakturawert; alternative Summen sind in Details sichtbar.";
return "Abweichung offen. Quelle, Periodenabgrenzung, Gutschriften und 2nd-party/3rd-party-Abgrenzung pruefen.";
}
static string BuildExecutiveBriefing(
IReadOnlyList<NetSalesReferenceRow> rows,
IReadOnlyDictionary<string, CheckedExcelReference> excelReferences,
@@ -1302,6 +1499,17 @@ static string Amount(decimal? value)
static string Html(string? value)
=> WebUtility.HtmlEncode(value ?? string.Empty);
sealed record FinanceChiefIssue(
string Status,
string Label,
string Key,
string Currency,
decimal? ActualValue,
decimal? ReferenceValue,
decimal? Difference,
string Reason,
decimal SortValue);
sealed class CheckedExcelReference
{
public string Label { get; set; } = string.Empty;