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
@@ -46,7 +46,7 @@
@HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@MonthlyBars(Result.TurnoverVisuals.MonthlyRelevantLeavers)
@MonthlyBars(Result.TurnoverVisuals)
</MudItem>
</MudGrid>
</MudTabPanel>
@@ -286,7 +286,7 @@
.OrderByDescending(x => x.Count);
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahres-Fluktuation", "Annual turnover")</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@visual.RateTitle</MudText>
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
<div class="hr-gauge-track"></div>
<div class="hr-gauge-needle"></div>
@@ -355,10 +355,10 @@
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> MonthlyBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte pro Monat", "Relevant leavers per month")</MudText>
private RenderFragment<HrTurnoverVisuals> MonthlyBars => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@visual.TimelineTitle</MudText>
<div class="hr-month-bars">
@foreach (var item in items)
@foreach (var item in visual.MonthlyRelevantLeavers)
{
<div class="hr-month">
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
@@ -17,7 +17,12 @@
<MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" />
</MudItem>
<MudItem xs="6" md="2">
<MudNumericField T="int" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Min="2000" Max="2100" />
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
@foreach (var option in _result?.ExitYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
@@ -111,7 +116,7 @@
@code {
private string _dataFolder = HrKpiDataSourceOptions.DefaultFolder;
private int _year = DateTime.Today.Year;
private int? _year;
private DateTime? _fromDate;
private DateTime? _toDate;
private int? _entryYear;
+4 -1
View File
@@ -3,7 +3,7 @@ namespace TrafagSalesExporter.Models;
public sealed class HrKpiOptions
{
public string DataFolder { get; set; } = HrKpiDataSourceOptions.DefaultFolder;
public int Year { get; set; } = DateTime.Today.Year;
public int? Year { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int? EntryYear { get; set; }
@@ -50,6 +50,7 @@ public sealed class HrKpiResult
public List<string> Notices { get; set; } = [];
public List<string> OrganisationOptions { get; set; } = [];
public List<string> KostenstelleOptions { get; set; } = [];
public List<int> ExitYearOptions { get; set; } = [];
public List<int> EntryYearOptions { get; set; } = [];
public List<string> MitarbeitertypOptions { get; set; } = [];
public List<HrKpiMetric> Metrics { get; set; } = [];
@@ -93,10 +94,12 @@ public sealed class HrKpiGroupValue
public sealed class HrTurnoverVisuals
{
public string RateTitle { get; set; } = "Fluktuation Auswahl";
public decimal YearRatePercent { get; set; }
public string YearRateLabel { get; set; } = "0.0%";
public string GaugeColor { get; set; } = "#2e7d32";
public decimal GaugeRotationDegrees { get; set; }
public string TimelineTitle { get; set; } = "Relevante Austritte";
public List<HrKpiGroupValue> FunnelSteps { get; set; } = [];
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
@@ -19,7 +19,7 @@ internal sealed class HrKpiDashboardBuilder
var normalizedOptions = new HrKpiOptions
{
DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? _dataSources.DataFolder : options.DataFolder.Trim(),
Year = options.Year <= 0 ? DateTime.Today.Year : options.Year,
Year = options.Year.HasValue && options.Year.Value > 0 ? options.Year.Value : null,
FromDate = options.FromDate?.Date,
ToDate = options.ToDate?.Date,
EntryYear = options.EntryYear,
@@ -53,6 +53,12 @@ internal sealed class HrKpiDashboardBuilder
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
result.ExitYearOptions = leavers
.Where(x => x.Austrittsjahr.HasValue)
.Select(x => x.Austrittsjahr!.Value)
.Distinct()
.OrderByDescending(x => x)
.ToList();
result.EntryYearOptions = employees
.Where(x => x.Eintrittsdatum.HasValue)
.Select(x => x.Eintrittsdatum!.Value.Year)
@@ -66,7 +72,8 @@ internal sealed class HrKpiDashboardBuilder
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var analysisYear = ResolveAnalysisYear(normalizedOptions);
var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList();
var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList();
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
var filteredEmployeeNumbers = filteredEmployees
.Where(x => x.Personalnummer.HasValue)
@@ -76,21 +83,22 @@ internal sealed class HrKpiDashboardBuilder
employees = filteredEmployees;
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers);
result.Employees = employees;
result.Absences = absences;
result.Leavers = leavers;
result.Metrics = BuildOverviewMetrics(employees, absences, leavers, analysisYear);
result.TurnoverMetrics = BuildTurnoverMetrics(employees, leavers, analysisYear, ResolveTurnoverAnchorDate(normalizedOptions, analysisYear));
result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
result.TurnoverVisuals = BuildTurnoverVisuals(employees, leavers, analysisYear);
result.TurnoverVisuals = BuildTurnoverVisuals(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.HeadcountByOrganisation = employees
.GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
.Select(g => new HrKpiGroupValue
{
Label = g.Key,
Count = g.Select(x => x.Personalnummer).Distinct().Count(),
Count = CountDistinctPersons(g.Select(x => x.Personalnummer)),
Value = g.Sum(x => x.Fte)
})
.OrderByDescending(x => x.Count)
@@ -115,6 +123,8 @@ internal sealed class HrKpiDashboardBuilder
var missingFteCount = employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue);
if (missingFteCount > 0)
result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit.");
if (HasEmployeeOnlyTurnoverFilters(normalizedOptions))
result.Notices.Add("Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende und Absenzen, aber nicht die Fluktuation. Die Austrittsdatei enthaelt diese Felder nicht stabil genug fuer denselben Schnitt.");
if (!context.HasFile(_dataSources.MainFile))
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
if (!context.HasFile(_dataSources.SapFile))
@@ -305,6 +315,7 @@ internal sealed class HrKpiDashboardBuilder
normalizedReason.Contains("befrist", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("pension", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("rente", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("ruhestand", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("trafag", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) ||
normalizedReason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) ||
@@ -349,6 +360,12 @@ internal sealed class HrKpiDashboardBuilder
(!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) &&
MatchesEmployeeSearch(x, options.SearchText));
private static IEnumerable<HrKpiEmployeeRow> ApplyTurnoverEmployeeFilters(IEnumerable<HrKpiEmployeeRow> rows, HrKpiOptions options)
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) &&
(!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) &&
MatchesEmployeeSearch(x, options.SearchText));
private static IEnumerable<HrAbsenceRow> ApplyAbsenceFilters(
IEnumerable<HrAbsenceRow> rows,
HrKpiOptions options,
@@ -362,34 +379,46 @@ internal sealed class HrKpiDashboardBuilder
=> rows.Where(x => MatchesLeaverDateFilter(x, options) &&
MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) &&
(!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) &&
MatchesFluctuationFilter(x, options.FluktuationFilter) &&
MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
private static IEnumerable<HrLeaverRow> ApplyTurnoverHeadcountLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
=> rows.Where(x => MatchesLeaverEmploymentPeriodFilter(x, options) &&
MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) &&
(!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) &&
MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
private static List<HrKpiMetric> BuildOverviewMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences,
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> leavers,
int year)
TurnoverPeriodScope period)
{
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
var fixedCount = CountDistinctPersons(employees
var activeFixedCount = CountDistinctPersons(employees
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Personalnummer));
var turnoverIntervals = BuildTurnoverIntervals(turnoverEmployees, turnoverHeadcountLeavers);
var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period);
var fte = employees.Sum(x => x.Fte);
var sickDays = absences.Sum(x => x.KrankheitstageGesamt);
var absenceRate = activeCount == 0 ? 0 : sickDays / (activeCount * 21m);
var absenceRate = fte <= 0 ? 0 : sickDays / (fte * 21m);
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
var employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
var turnover = fixedCount == 0 ? 0 : relevantLeavers / (decimal)fixedCount;
var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator;
var avgBalance = activeCount == 0 ? 0 : employees.Average(x => x.StundenSaldo);
var redBalance = employees.Count(x => x.GlzAmpel == "Rot");
return
[
new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{fixedCount:N0} festangestellt", Severity = "Normal" },
new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{activeFixedCount:N0} festangestellt", Severity = "Normal" },
new() { Label = "FTE", Value = fte.ToString("N1"), Detail = "Summe Beschaeftigungsgrad", Severity = "Normal" },
new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = $"Fluktuation {year}", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen", Severity = turnover > 0.12m ? "Warning" : "Normal" },
new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote FTE {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = period.ShowPeriodMetrics ? $"Fluktuation {period.Label}" : "Fluktuation Auswahl", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen, Nenner {FormatHeadcount(turnoverDenominator)} HC", Severity = turnover > 0.12m ? "Warning" : "Normal" },
new() { Label = "GLZ Schnitt", Value = avgBalance.ToString("N1"), Detail = $"{redBalance:N0} Personen > 100h absolut", Severity = redBalance > 0 ? "Warning" : "Normal" },
new() { Label = "Unfalltage", Value = employees.Sum(x => x.BuTage + x.NbuTage).ToString("N1"), Detail = $"BU {employees.Sum(x => x.BuTage):N1} / NBU {employees.Sum(x => x.NbuTage):N1}", Severity = "Normal" }
];
@@ -397,13 +426,12 @@ internal sealed class HrKpiDashboardBuilder
private static List<HrKpiMetric> BuildTurnoverMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> leavers,
int year,
DateTime anchorDate)
TurnoverPeriodScope period)
{
var fixedHeadcount = CountDistinctPersons(employees
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Personalnummer));
var turnoverIntervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers);
var selectionHeadcount = ResolveTurnoverDenominator(employees, turnoverIntervals, period);
var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer));
var employeeResignations = leavers
.Where(x => x.IstArbeitnehmerkuendigung)
@@ -420,9 +448,30 @@ internal sealed class HrKpiDashboardBuilder
var employeeResignationCount = CountDistinctPersons(employeeResignations);
var relevantLeaverCount = CountDistinctPersons(relevantLeavers);
var nonRelevantLeaverCount = CountDistinctPersons(nonRelevantLeavers);
var selectionRate = selectionHeadcount == 0 ? 0 : relevantLeaverCount / selectionHeadcount;
var currentMonth = anchorDate.Month;
var metrics = new List<HrKpiMetric>
{
new() { Label = "Headcount Festangestellt", Value = FormatHeadcount(selectionHeadcount), Detail = period.ShowPeriodMetrics ? "Avg Headcount Jahr, nicht FTE" : "Aktueller Headcount, nicht FTE", Severity = "Normal" },
new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Auswahl", Severity = "Normal" },
new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" },
new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" },
new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" },
new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" }
};
if (!period.ShowPeriodMetrics || !period.BreakdownYear.HasValue)
{
metrics.Insert(5, new HrKpiMetric { Label = "Fluktuation Auswahl %", Value = selectionRate.ToString("P1"), Detail = "Auswahl / Headcount, nicht annualisiert", Severity = selectionRate > 0.12m ? "Warning" : "Normal" });
return metrics;
}
var year = period.BreakdownYear.Value;
var currentMonth = period.AnchorDate.Month;
var currentQuarter = ((currentMonth - 1) / 3) + 1;
var monthHeadcount = CalculateMonthlyAverageFixedHeadcount(turnoverIntervals, year, currentMonth);
var quarterHeadcount = CalculateAverageFixedHeadcount(turnoverIntervals, BuildQuarterMonths(currentQuarter).Select(month => (year, month)));
var yearHeadcount = CalculateAverageFixedHeadcount(turnoverIntervals, Enumerable.Range(1, 12).Select(month => (year, month)));
var quarterLeavers = leavers
.Where(x => x.IstFluktuationsrelevant &&
x.Austrittsdatum.HasValue &&
@@ -445,39 +494,44 @@ internal sealed class HrKpiDashboardBuilder
var monthLeaverCount = CountDistinctPersons(monthLeavers);
var yearLeaverCount = CountDistinctPersons(yearLeavers);
var monthRate = fixedHeadcount == 0 ? 0 : monthLeaverCount / (decimal)fixedHeadcount;
var quarterRate = fixedHeadcount == 0 ? 0 : quarterLeaverCount / (decimal)fixedHeadcount;
var monthRate = monthHeadcount == 0 ? 0 : monthLeaverCount / monthHeadcount;
var quarterRate = quarterHeadcount == 0 ? 0 : quarterLeaverCount / quarterHeadcount;
var forecastRate = quarterRate * 4;
var yearRate = fixedHeadcount == 0 ? 0 : yearLeaverCount / (decimal)fixedHeadcount;
var yearRate = yearHeadcount == 0 ? 0 : yearLeaverCount / yearHeadcount;
return
metrics[0] = new HrKpiMetric
{
Label = "Headcount Festangestellt",
Value = FormatHeadcount(yearHeadcount),
Detail = "Avg Headcount Jahr, nicht FTE",
Severity = "Normal"
};
metrics.AddRange(
[
new() { Label = "Headcount Festangestellt", Value = fixedHeadcount.ToString("N0"), Detail = "Nenner fuer Fluktuation", Severity = "Normal" },
new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Rexx", Severity = "Normal" },
new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" },
new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" },
new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" },
new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte im Monat", Severity = monthRate > 0.03m ? "Warning" : "Normal" },
new() { Label = "Avg Headcount Quartal", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" },
new() { Label = "Headcount Monat", Value = FormatHeadcount(monthHeadcount), Detail = $"Monats-HC {currentMonth:N0}/{year}, nicht FTE", Severity = "Normal" },
new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte / HC {FormatHeadcount(monthHeadcount)}", Severity = monthRate > 0.03m ? "Warning" : "Normal" },
new() { Label = "Avg Headcount Quartal", Value = FormatHeadcount(quarterHeadcount), Detail = "Durchschnitt Monats-HC, nicht FTE", Severity = "Normal" },
new() { Label = "Austritte Quartal", Value = quarterLeaverCount.ToString("N0"), Detail = $"Quartal {currentQuarter}/{year}", Severity = "Normal" },
new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Headcount", Severity = quarterRate > 0.08m ? "Warning" : "Normal" },
new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Avg HC Quartal", Severity = quarterRate > 0.08m ? "Warning" : "Normal" },
new() { Label = "Fluktuation Hochrechnung Jahr %", Value = forecastRate.ToString("P1"), Detail = "Quartalsrate x 4", Severity = forecastRate > 0.12m ? "Warning" : "Normal" },
new() { Label = "Avg Headcount Jahr", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" },
new() { Label = "Avg Headcount Jahr", Value = FormatHeadcount(yearHeadcount), Detail = "Durchschnitt Monats-HC, nicht FTE", Severity = "Normal" },
new() { Label = "Austritte Jahr", Value = yearLeaverCount.ToString("N0"), Detail = $"Fluktuationsrelevant {year}", Severity = "Normal" },
new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Headcount", Severity = yearRate > 0.12m ? "Warning" : "Normal" },
new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" }
];
new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Avg HC Jahr", Severity = yearRate > 0.12m ? "Warning" : "Normal" }
]);
return metrics;
}
private static List<HrKpiMetric> BuildAbsenceMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences)
{
var headcount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
var longSick = absences.Sum(x => x.KrankheitstageLang);
var absenceRate = headcount == 0 ? 0 : totalSick / (headcount * 21m);
var fte = employees.Sum(x => x.Fte);
var absenceRate = fte <= 0 ? 0 : totalSick / (fte * 21m);
var bu = employees.Sum(x => x.BuTage);
var nbu = employees.Sum(x => x.NbuTage);
@@ -486,7 +540,7 @@ internal sealed class HrKpiDashboardBuilder
new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" },
new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" },
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / 21 Tage / Headcount", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / (FTE * 21 Tage)", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
new() { Label = "NBU-Tage", Value = nbu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" }
@@ -520,17 +574,17 @@ internal sealed class HrKpiDashboardBuilder
private static HrTurnoverVisuals BuildTurnoverVisuals(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> leavers,
int year)
TurnoverPeriodScope period)
{
var fixedHeadcount = CountDistinctPersons(employees
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Personalnummer));
var turnoverIntervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers);
var fixedHeadcount = ResolveTurnoverDenominator(employees, turnoverIntervals, period);
var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer));
var employeeResignations = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
var notRelevant = Math.Max(0, totalLeavers - relevantLeavers);
var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / (decimal)fixedHeadcount * 100m;
var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / fixedHeadcount * 100m;
var gaugeColor = ratePercent > 12m ? "#c62828" : ratePercent >= 8m ? "#f9a825" : "#2e7d32";
var maxFunnel = Math.Max(totalLeavers, 1);
@@ -540,9 +594,9 @@ internal sealed class HrKpiDashboardBuilder
.Select((g, index) => new HrKpiGroupValue
{
Label = g.Key,
Count = g.Count(),
Value = g.Count(),
Percent = totalLeavers == 0 ? 0 : g.Count() / (decimal)totalLeavers * 100m,
Count = CountDistinctPersons(g.Select(x => x.Personalnummer)),
Value = CountDistinctPersons(g.Select(x => x.Personalnummer)),
Percent = totalLeavers == 0 ? 0 : CountDistinctPersons(g.Select(x => x.Personalnummer)) / (decimal)totalLeavers * 100m,
Color = reasonColors[index % reasonColors.Length]
})
.OrderByDescending(x => x.Count)
@@ -554,9 +608,9 @@ internal sealed class HrKpiDashboardBuilder
.Select(g => new HrKpiGroupValue
{
Label = g.Key,
Count = g.Count(),
Value = g.Count(),
Percent = relevantLeavers == 0 ? 0 : g.Count() / (decimal)relevantLeavers * 100m,
Count = CountDistinctPersons(g.Select(x => x.Personalnummer)),
Value = CountDistinctPersons(g.Select(x => x.Personalnummer)),
Percent = relevantLeavers == 0 ? 0 : CountDistinctPersons(g.Select(x => x.Personalnummer)) / (decimal)relevantLeavers * 100m,
Color = "#1565c0"
})
.OrderByDescending(x => x.Count)
@@ -564,14 +618,44 @@ internal sealed class HrKpiDashboardBuilder
.Take(10)
.ToList();
var monthly = Enumerable.Range(1, 12)
var timeline = period.BreakdownYear.HasValue
? BuildMonthlyTurnoverTimeline(leavers, relevantLeavers, period.BreakdownYear.Value)
: BuildYearlyTurnoverTimeline(leavers, relevantLeavers);
return new HrTurnoverVisuals
{
RateTitle = period.ShowPeriodMetrics ? $"Jahres-Fluktuation {period.Label}" : "Fluktuation Auswahl",
YearRatePercent = ratePercent,
YearRateLabel = (ratePercent / 100m).ToString("P1"),
GaugeColor = gaugeColor,
GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m,
TimelineTitle = period.BreakdownYear.HasValue ? "Relevante Austritte pro Monat" : "Relevante Austritte pro Jahr",
FunnelSteps =
[
new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" },
new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" },
new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" },
new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" }
],
ExclusionReasons = reasons,
RelevantByOrganisation = relevantByOrg,
MonthlyRelevantLeavers = timeline
};
}
private static List<HrKpiGroupValue> BuildMonthlyTurnoverTimeline(
IReadOnlyCollection<HrLeaverRow> leavers,
int relevantLeavers,
int year)
=> Enumerable.Range(1, 12)
.Select(month =>
{
var count = leavers.Count(x =>
x.IstFluktuationsrelevant &&
x.Austrittsdatum.HasValue &&
x.Austrittsdatum.Value.Year == year &&
x.Austrittsdatum.Value.Month == month);
var count = CountDistinctPersons(leavers
.Where(x => x.IstFluktuationsrelevant &&
x.Austrittsdatum.HasValue &&
x.Austrittsdatum.Value.Year == year &&
x.Austrittsdatum.Value.Month == month)
.Select(x => x.Personalnummer));
return new HrKpiGroupValue
{
Label = CultureInfo.GetCultureInfo("de-CH").DateTimeFormat.GetAbbreviatedMonthName(month),
@@ -583,42 +667,178 @@ internal sealed class HrKpiDashboardBuilder
})
.ToList();
return new HrTurnoverVisuals
private static List<HrKpiGroupValue> BuildYearlyTurnoverTimeline(
IReadOnlyCollection<HrLeaverRow> leavers,
int relevantLeavers)
=> leavers
.Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr.HasValue)
.GroupBy(x => x.Austrittsjahr!.Value)
.OrderBy(g => g.Key)
.Select(g =>
{
var count = CountDistinctPersons(g.Select(x => x.Personalnummer));
return new HrKpiGroupValue
{
Label = g.Key.ToString(CultureInfo.InvariantCulture),
Count = count,
Value = count,
Percent = relevantLeavers == 0 ? 0 : count / (decimal)relevantLeavers * 100m,
Color = "#00897b"
};
})
.ToList();
private static decimal ResolveTurnoverDenominator(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<TurnoverEmploymentInterval> intervals,
TurnoverPeriodScope period)
{
if (period.ShowPeriodMetrics && period.BreakdownYear.HasValue)
{
YearRatePercent = ratePercent,
YearRateLabel = (ratePercent / 100m).ToString("P1"),
GaugeColor = gaugeColor,
GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m,
FunnelSteps =
[
new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" },
new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" },
new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" },
new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" }
],
ExclusionReasons = reasons,
RelevantByOrganisation = relevantByOrg,
MonthlyRelevantLeavers = monthly
};
return CalculateAverageFixedHeadcount(
intervals,
Enumerable.Range(1, 12).Select(month => (period.BreakdownYear.Value, month)));
}
return CountCurrentFixedHeadcount(employees);
}
private static int CountCurrentFixedHeadcount(IReadOnlyCollection<HrKpiEmployeeRow> employees)
=> CountDistinctPersons(employees
.Where(x => IsFixedEmployee(x.Mitarbeitertyp))
.Select(x => x.Personalnummer));
private static List<TurnoverEmploymentInterval> BuildTurnoverIntervals(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> leavers)
{
var intervals = new List<TurnoverEmploymentInterval>();
intervals.AddRange(employees
.Where(x => x.Personalnummer.HasValue && IsFixedEmployee(x.Mitarbeitertyp))
.Select(x => new TurnoverEmploymentInterval(x.Personalnummer!.Value, x.Eintrittsdatum?.Date, null)));
intervals.AddRange(leavers
.Where(x => x.Personalnummer.HasValue && IsFixedEmployee(x.Mitarbeitertyp))
.Select(x => new TurnoverEmploymentInterval(x.Personalnummer!.Value, x.Eintrittsdatum?.Date, x.Austrittsdatum?.Date)));
return intervals;
}
private static decimal CalculateMonthlyAverageFixedHeadcount(
IReadOnlyCollection<TurnoverEmploymentInterval> intervals,
int year,
int month)
{
var start = new DateTime(year, month, 1);
var end = start.AddMonths(1).AddDays(-1);
return (CountFixedHeadcountOn(intervals, start) + CountFixedHeadcountOn(intervals, end)) / 2m;
}
private static decimal CalculateAverageFixedHeadcount(
IReadOnlyCollection<TurnoverEmploymentInterval> intervals,
IEnumerable<(int Year, int Month)> months)
{
var monthlyHeadcounts = months
.Select(x => CalculateMonthlyAverageFixedHeadcount(intervals, x.Year, x.Month))
.ToList();
return monthlyHeadcounts.Count == 0 ? 0 : monthlyHeadcounts.Average();
}
private static int CountFixedHeadcountOn(IReadOnlyCollection<TurnoverEmploymentInterval> intervals, DateTime date)
=> intervals
.Where(x => (!x.Eintrittsdatum.HasValue || x.Eintrittsdatum.Value <= date) &&
(!x.Austrittsdatum.HasValue || x.Austrittsdatum.Value >= date))
.Select(x => x.Personalnummer)
.Distinct()
.Count();
private static IEnumerable<int> BuildQuarterMonths(int quarter)
{
var normalizedQuarter = Math.Clamp(quarter, 1, 4);
return Enumerable.Range(((normalizedQuarter - 1) * 3) + 1, 3);
}
private static bool IsFixedEmployee(string employeeType)
=> string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase);
private static string FormatHeadcount(decimal value)
=> decimal.Remainder(value, 1m) == 0 ? value.ToString("N0") : value.ToString("N1");
private static string? NormalizeFilter(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int ResolveAnalysisYear(HrKpiOptions options)
=> (options.ToDate ?? options.FromDate)?.Year ?? options.Year;
private static TurnoverPeriodScope ResolveTurnoverPeriodScope(HrKpiOptions options, IReadOnlyCollection<HrLeaverRow> leavers)
{
var hasRange = options.FromDate.HasValue || options.ToDate.HasValue;
var selectedYears = leavers
.Where(x => x.Austrittsjahr.HasValue)
.Select(x => x.Austrittsjahr!.Value)
.Distinct()
.OrderBy(x => x)
.ToList();
private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int analysisYear)
int? breakdownYear = null;
var showPeriodMetrics = false;
if (!hasRange && options.Year.HasValue)
{
breakdownYear = options.Year.Value;
showPeriodMetrics = true;
}
else if (selectedYears.Count == 1)
{
breakdownYear = selectedYears[0];
}
var anchorDate = ResolveTurnoverAnchorDate(options, breakdownYear, leavers);
var label = showPeriodMetrics && breakdownYear.HasValue
? breakdownYear.Value.ToString(CultureInfo.InvariantCulture)
: BuildTurnoverSelectionLabel(options, selectedYears);
return new TurnoverPeriodScope(breakdownYear, anchorDate, label, showPeriodMetrics);
}
private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int? breakdownYear, IReadOnlyCollection<HrLeaverRow> leavers)
{
if (options.ToDate.HasValue)
return options.ToDate.Value.Date;
if (options.FromDate.HasValue)
return options.FromDate.Value.Date;
return analysisYear == DateTime.Today.Year
if (breakdownYear.HasValue)
{
return breakdownYear.Value == DateTime.Today.Year
? DateTime.Today
: new DateTime(analysisYear, 12, 31);
: new DateTime(breakdownYear.Value, 12, 31);
}
return leavers
.Where(x => x.Austrittsdatum.HasValue)
.Select(x => x.Austrittsdatum!.Value.Date)
.DefaultIfEmpty(DateTime.Today)
.Max();
}
private static string BuildTurnoverSelectionLabel(HrKpiOptions options, IReadOnlyList<int> selectedYears)
{
if (options.FromDate.HasValue || options.ToDate.HasValue)
{
var from = options.FromDate?.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-CH")) ?? "...";
var to = options.ToDate?.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-CH")) ?? "...";
return $"{from} - {to}";
}
if (selectedYears.Count == 1)
return selectedYears[0].ToString(CultureInfo.InvariantCulture);
return "Auswahl";
}
private static bool HasEmployeeOnlyTurnoverFilters(HrKpiOptions options)
=> !string.IsNullOrWhiteSpace(options.KostenstelleText) ||
!string.IsNullOrWhiteSpace(options.GlzAmpel) ||
!string.IsNullOrWhiteSpace(options.RestferienAmpel);
private static bool MatchesLeaverDateFilter(HrLeaverRow row, HrKpiOptions options)
{
var hasRange = options.FromDate.HasValue || options.ToDate.HasValue;
@@ -630,7 +850,34 @@ internal sealed class HrKpiDashboardBuilder
(!options.ToDate.HasValue || row.Austrittsdatum.Value.Date <= options.ToDate.Value);
}
return row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year;
return !options.Year.HasValue ||
(row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year.Value);
}
private static bool MatchesLeaverEmploymentPeriodFilter(HrLeaverRow row, HrKpiOptions options)
{
var period = ResolveEmploymentPeriod(options);
if (!period.HasValue)
return true;
var entry = row.Eintrittsdatum?.Date ?? DateTime.MinValue;
var exit = row.Austrittsdatum?.Date ?? DateTime.MaxValue;
return entry <= period.Value.End && exit >= period.Value.Start;
}
private static (DateTime Start, DateTime End)? ResolveEmploymentPeriod(HrKpiOptions options)
{
if (options.Year.HasValue && !options.FromDate.HasValue && !options.ToDate.HasValue)
{
return (new DateTime(options.Year.Value, 1, 1), new DateTime(options.Year.Value, 12, 31));
}
if (!options.FromDate.HasValue && !options.ToDate.HasValue)
return null;
var start = options.FromDate?.Date ?? new DateTime(options.ToDate!.Value.Year, 1, 1);
var end = options.ToDate?.Date ?? new DateTime(start.Year, 12, 31);
return start <= end ? (start, end) : (end, start);
}
private static int CountDistinctPersons(IEnumerable<int?> personalNumbers)
@@ -768,7 +1015,15 @@ internal sealed class HrKpiDashboardBuilder
private static string NormalizeReason(string value)
{
var normalized = RemoveDiacritics(value).Trim().ToLowerInvariant();
var normalized = value
.Replace("ä", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("ö", "oe", StringComparison.OrdinalIgnoreCase)
.Replace("ü", "ue", StringComparison.OrdinalIgnoreCase)
.Replace("Ä", "Ae", StringComparison.Ordinal)
.Replace("Ö", "Oe", StringComparison.Ordinal)
.Replace("Ü", "Ue", StringComparison.Ordinal)
.Replace("ß", "ss", StringComparison.OrdinalIgnoreCase);
normalized = RemoveDiacritics(normalized).Trim().ToLowerInvariant();
return normalized
.Replace("ä", "ae", StringComparison.OrdinalIgnoreCase)
.Replace("ö", "oe", StringComparison.OrdinalIgnoreCase)
@@ -781,7 +1036,9 @@ internal sealed class HrKpiDashboardBuilder
if (!string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase)) return employeeType;
if (string.IsNullOrWhiteSpace(reason)) return "Austrittsart leer/unklar";
if (reason.Contains("befrist", StringComparison.OrdinalIgnoreCase)) return "Befristeter Vertrag";
if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) || reason.Contains("rente", StringComparison.OrdinalIgnoreCase)) return "Pensionierung";
if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("rente", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("ruhestand", StringComparison.OrdinalIgnoreCase)) return "Pensionierung";
if (reason.Contains("trafag", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) ||
@@ -899,6 +1156,10 @@ internal sealed class HrKpiDashboardBuilder
return builder.ToString().Normalize(NormalizationForm.FormC);
}
private sealed record TurnoverPeriodScope(int? BreakdownYear, DateTime AnchorDate, string Label, bool ShowPeriodMetrics);
private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum);
private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag);
private sealed record SapRow(
@@ -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;
@@ -28,6 +28,12 @@ public sealed class HrKpiServiceTests : IDisposable
Directory.Delete(_folder, recursive: true);
}
[Fact]
public void HrKpiOptions_Default_Exit_Year_Is_Empty()
{
Assert.Null(new HrKpiOptions().Year);
}
[Fact]
public async Task BuildAsync_Applies_Organisation_Filter_To_Absences()
{
@@ -60,6 +66,90 @@ public sealed class HrKpiServiceTests : IDisposable
Assert.DoesNotContain(result.Leavers, row => row.Austrittsdatum?.Year == 2024);
}
[Fact]
public async Task BuildAsync_With_Empty_Exit_Year_Includes_All_Leaver_Years()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = null
});
Assert.Contains(2025, result.ExitYearOptions);
Assert.Contains(2024, result.ExitYearOptions);
Assert.Contains(result.Leavers, row => row.Austrittsdatum?.Year == 2025);
Assert.Contains(result.Leavers, row => row.Austrittsdatum?.Year == 2024);
}
[Fact]
public async Task BuildAsync_Employee_Only_Filters_Do_Not_Distort_Turnover_Denominator()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025,
KostenstelleText = "100 / Org A"
});
var activeHeadcount = Assert.Single(result.Metrics, metric => metric.Label == "Headcount aktiv");
Assert.Equal("1", activeHeadcount.Value);
var turnoverHeadcount = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Headcount Festangestellt");
Assert.Equal(3.0m.ToString("N1"), turnoverHeadcount.Value);
Assert.Contains(result.Notices, notice => notice.Contains("nicht die Fluktuation", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task BuildAsync_Uses_Average_Headcount_For_Turnover_Formulas()
{
RewriteEmployeeRows(
[
[4001, "Stable, Anna", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"],
[4002, "Stable, Bruno", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"],
[4003, "Stable, Carla", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"]
]);
RewriteLeaverRows(
[
[5001, "Leaving, Lea", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 30), new DateTime(2025, 1, 1), "Kündigung AN"]
]);
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025
});
var avgYearHeadcount = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Avg Headcount Jahr");
Assert.Equal(3.5m.ToString("N1"), avgYearHeadcount.Value);
var yearRate = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Fluktuation Jahr Effektiv %");
Assert.Equal((1m / 3.5m).ToString("P1"), yearRate.Value);
Assert.Equal("Austritte Jahr / Avg HC Jahr", yearRate.Detail);
}
[Fact]
public async Task BuildAsync_Uses_Distinct_Persons_In_Turnover_Visuals()
{
AppendLeaverRow(
1001,
"Alpha, Anna",
"Org A",
"Engineer",
new DateTime(2025, 3, 20),
new DateTime(2020, 1, 1),
"Arbeitnehmer Kuendigung");
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025
});
Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers[2].Count);
Assert.Equal(1, result.TurnoverVisuals.RelevantByOrganisation.Single(row => row.Label == "Org A").Count);
Assert.Equal(3, result.TurnoverVisuals.FunnelSteps.Single(row => row.Label == "Austritte Total").Count);
}
[Fact]
public async Task BuildAsync_Excludes_Missing_Personalnummer_From_Distinct_Headcount_And_Uses_Fte_Fallback()
{
@@ -76,6 +166,9 @@ public sealed class HrKpiServiceTests : IDisposable
Assert.Null(fallbackEmployee.BeschaeftigungsgradProzent);
Assert.Equal(0.5m, fallbackEmployee.Fte);
var absenceRate = Assert.Single(result.AbsenceMetrics, metric => metric.Label == "Krankenquote");
Assert.Contains("FTE", absenceRate.Detail);
Assert.Contains(result.Notices, notice => notice.Contains("ohne Personalnummer", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Notices, notice => notice.Contains("FTE-Fallback", StringComparison.OrdinalIgnoreCase));
}
@@ -93,7 +186,29 @@ public sealed class HrKpiServiceTests : IDisposable
Assert.Single(result.Leavers, row => row.IstFluktuationsrelevant);
Assert.Contains(result.Leavers, row => row.FluktuationAusschlussgrund == "Kuendigung durch Trafag");
Assert.Contains(result.Leavers, row => row.FluktuationAusschlussgrund == "Praktikant");
Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers.Single(row => row.Label == "Mär").Count);
Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers[2].Count);
}
[Fact]
public async Task BuildAsync_Recognizes_Rexx_Kuendigung_AN_And_AG()
{
RewriteLeaverRows(
[
[3001, "Employee, Eva", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 1), new DateTime(2020, 1, 1), "Kündigung AN"],
[3002, "Employer, Emil", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 2), new DateTime(2020, 1, 1), "Kündigung AG"],
[3003, "Retired, Rita", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 3), new DateTime(2020, 1, 1), "Ruhestand"]
]);
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025
});
Assert.Equal("1", Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Austritte Arbeitnehmerkuendigung").Value);
Assert.Equal("1", Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Austritte Fluktuationsrelevant").Value);
Assert.Contains(result.Leavers, row => row.Austrittsart == "Kündigung AG" && row.FluktuationAusschlussgrund == "Kuendigung durch Trafag");
Assert.Contains(result.Leavers, row => row.Austrittsart == "Ruhestand" && row.FluktuationAusschlussgrund == "Pensionierung");
}
private static void WriteFixtureFiles(string folder)
@@ -151,10 +266,58 @@ public sealed class HrKpiServiceTests : IDisposable
[
[1001, "Alpha, Anna", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 3, 10), new DateTime(2020, 1, 1), "Arbeitnehmer Kuendigung"],
[1002, "Beta, Bruno", "Org B", "Engineer", "Inaktiv", new DateTime(2025, 4, 5), new DateTime(2024, 2, 1), "Kuendigung Arbeitgeber"],
[2001, "Trainee, Tom", "Org A", "Praktikant", "Inaktiv", new DateTime(2025, 5, 5), new DateTime(2025, 1, 1), "Arbeitnehmer Kuendigung"]
[2001, "Trainee, Tom", "Org A", "Praktikant", "Inaktiv", new DateTime(2025, 5, 5), new DateTime(2025, 1, 1), "Arbeitnehmer Kuendigung"],
[1003, "Fallback, Fiona", "Org B", "Engineer", "Inaktiv", new DateTime(2024, 12, 15), new DateTime(2025, 1, 15), "Arbeitnehmer Kuendigung"]
]);
}
private void AppendLeaverRow(
int personalNumber,
string name,
string organisation,
string position,
DateTime exitDate,
DateTime entryDate,
string exitType)
{
var path = Path.Combine(_folder, "Personalausgeschieden.xlsx");
using var workbook = new XLWorkbook(path);
var sheet = workbook.Worksheets.First();
var row = sheet.LastRowUsed()!.RowNumber() + 1;
sheet.Cell(row, 1).Value = personalNumber;
sheet.Cell(row, 2).Value = name;
sheet.Cell(row, 3).Value = organisation;
sheet.Cell(row, 4).Value = position;
sheet.Cell(row, 5).Value = "Inaktiv";
sheet.Cell(row, 6).Value = exitDate;
sheet.Cell(row, 7).Value = entryDate;
sheet.Cell(row, 8).Value = exitType;
workbook.Save();
}
private void RewriteLeaverRows(object?[][] rows)
{
WriteWorkbook(Path.Combine(_folder, "Personalausgeschieden.xlsx"),
[
"Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation-1", "Stelle-1",
"Personal Status", "Austrittsdatum", "Eintrittsdatum", "Austrittsart"
],
rows);
}
private void RewriteEmployeeRows(object?[][] rows)
{
WriteWorkbook(Path.Combine(_folder, "Saldiperstichdatum.xlsx"),
[
"Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation", "Kostenstelle", "Stelle",
"Leitung j/n", "Eintrittsdatum", "Personal Status", "Stunden Saldo", "Urlaubsanspruch",
"Urlaub Rest", "Ferien ausstehend (Tage)", "Lohn", "Lohn Waehrung"
],
rows);
}
private static void WriteWorkbook(string path, string[] headers, object?[][] rows)
{
using var workbook = new XLWorkbook();
Binary file not shown.
@@ -0,0 +1,31 @@
# Finance Dashboard Todo
Stand: 2026-05-15
Ziel: Aufbau eines modernen, uebersichtlichen Intranet-Dashboards fuer das Group Sales Reporting, zugaenglich fuer alle freigegebenen Benutzer.
## Todo
| Prio | Aufgabe | Status |
| --- | --- | --- |
| 1 | Fuehrendes CFO-Dokument verwenden: `FINANCE_CHEF_SUMMARY_2026-05-15.docx` | Offen |
| 1 | Offene Finance-Entscheide mit Andreas/Finance durchgehen | Offen |
| 1 | Italien-Abweichung klaeren: Berechnungsart, Deduplizierung, Intercompany | Offen |
| 1 | Deutschland: finalen Jahresfile 2025 beschaffen | Offen |
| 2 | UK/England: Jahresvollstaendigkeit und Restdifferenz pruefen | Offen |
| 2 | CH/AT: Sollzuordnung und Trennung final bestaetigen | Offen |
| 2 | Spanien und Oesterreich: kleinere Differenzen klaeren | Offen |
| 2 | Intercompany-/2nd-party-Kundenliste final definieren | Offen |
| 3 | Budgetkurse je Jahr als Quelle festlegen | Offen |
| 3 | Dashboard-Sicht fuer CFO: nur Laender mit Abweichung und Handlungsbedarf anzeigen | In Arbeit |
| 3 | Detailansicht je Land mit Berechnungsart und Pruefspur behalten | In Arbeit |
| 3 | Freigabe-/Berechtigungskonzept fuer Intranet-Dashboard klaeren | Offen |
## Naechster Termin
Ziel im Termin:
- Deutschland und Spanien muss finales Excel schicken (Rohali 2 mal nachgehakt warte auf finales File)
- Grundlogik bestaetigen: Hauswaehrung, Nettofakturawert, Buchungsdatum, Berechnung pro Belegposition.
- Offene Laenderabweichungen priorisieren.
- Pro Land festlegen, welche Datenquelle und Berechnungslogik final gilt.
@@ -0,0 +1,36 @@
# Email an Spanien: Abweichung Net Sales 2025
**Asunto:** Revision diferencia Net Sales 2025 - Espana
Hola,
En la reconciliacion de Net Sales 2025 para Espana hemos identificado una diferencia pendiente de aclarar contra el valor de referencia de Rhino / `check.xlsx`.
Resumen:
- Actual Espana: `3.082.320,18 EUR`
- Referencia Rhino: `3.102.333,61 EUR`
- Diferencia: `-20.013,43 EUR`
La diferencia no parece muy grande, pero antes de cerrar el dato necesitamos confirmar la causa.
Por favor, podeis revisar los siguientes puntos?
1. Periodo incluido en el fichero
Confirmar que el fichero `Spain_Sales_2025.csv` contiene todo el ano 2025 completo.
2. Series incluidas
Confirmar que las series `REG`, `LAT`, `PRO` y `REC` deben incluirse en el calculo de Net Sales 2025.
3. Abonos / Credit Notes
Confirmar que los abonos estan incluidos correctamente y con signo negativo.
4. Criterio de fecha
Confirmar que fecha debe utilizarse para la delimitacion del ano 2025: fecha de factura, fecha contable u otra fecha.
5. Importe correcto
Confirmar si el campo utilizado como importe neto de ventas es el correcto para comparar contra Rhino / `check.xlsx`.
Objetivo: aclarar si la diferencia de `-20.013,43 EUR` se explica por periodo, series, abonos o por el campo de importe utilizado.
Gracias y saludos,
@@ -0,0 +1,39 @@
# Email an Italien: Abweichung Net Sales 2025
**Oggetto:** Verifica differenza Net Sales 2025 - Italia
Ciao,
nella riconciliazione dei Net Sales 2025 per l'Italia abbiamo identificato una differenza significativa rispetto al valore di riferimento Rhino / `check.xlsx`.
Riepilogo:
- Actual Italia: `14.704.336,29 EUR`
- Riferimento Rhino: `7.669.840,00 EUR`
- Differenza: `+7.034.496,29 EUR`
La differenza e molto rilevante. Prima di chiudere il dato dobbiamo confermare quale logica di calcolo e corretta per l'Italia.
Potete per favore verificare i seguenti punti?
1. Base di calcolo corretta
Confermare quale valore deve essere usato per i Net Sales 2025: valore netto per riga/posizione, totale documento deduplicato oppure un'altra logica.
2. Totali documento vs. posizioni
Verificare se i totali documento sono ripetuti su piu righe e quindi devono essere conteggiati una sola volta per documento.
3. Intercompany / 2nd-party
Confermare quali clienti o transazioni devono essere esclusi come Intercompany o 2nd-party.
4. Note di credito / Credit Notes
Confermare che le note di credito sono incluse correttamente e con segno negativo.
5. Criterio data
Confermare quale data deve essere utilizzata per delimitare il 2025: data registrazione, data fattura o altra data.
6. Valuta
Confermare che la valuta di confronto per l'Italia e `EUR`.
Obiettivo: capire se la differenza di `+7.034.496,29 EUR` deriva da doppio conteggio dei documenti, trattamento Intercompany, criteri data o dal campo importo utilizzato.
Grazie e saluti,
@@ -0,0 +1,42 @@
# Email an UK / England: Abweichung Net Sales 2025
**Subject:** Review difference Net Sales 2025 - UK
Hi,
In the Net Sales 2025 reconciliation for UK / England, we identified a difference against the Rhino / `check.xlsx` reference value.
Summary:
- Actual UK: `3,533,710.09 GBP`
- Rhino reference: `3,749,865.00 GBP`
- Difference: `-216,154.91 GBP`
The mapping has already been reviewed technically, but we still need to clarify the remaining difference before closing the 2025 value.
Could you please check the following points?
1. Full-year completeness
Confirm that the UK source file/import contains the full year 2025 and not only a partial period.
2. Period included
We currently see data from approximately `03.01.2025` to `22.12.2025`. Please confirm whether this is complete for 2025 or if transactions outside this range are missing.
3. Credit notes
Confirm that credit notes are included correctly and with the correct negative sign.
4. Net sales field
Confirm which column should be used as the net sales amount for comparison with Rhino / `check.xlsx`.
5. Discounts, freight or additional charges
Please check whether discounts, freight, additional charges or other adjustments are included in the Rhino reference but not in the current import value, or vice versa.
6. 2nd-party / 3rd-party / Intercompany
Confirm whether any customers or transactions should be excluded from the Net Sales 2025 value.
7. Currency
Confirm that the correct comparison currency for UK is `GBP`.
Goal: clarify whether the difference of `-216,154.91 GBP` is caused by an incomplete period, credit notes, a different net sales field, adjustments, or 2nd-party/3rd-party handling.
Thanks and best regards,
@@ -0,0 +1,57 @@
# Finance: Welches Dokument gilt?
Stand: 2026-05-15
## Fuehrendes Dokument
Fuer den CFO-/Finance-Termin gilt:
```text
docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx
```
Dieses Dokument ist die aktuellste CFO-Kurzfassung mit Ampel, Laendertabelle, Pruefquellen, Prioritaeten und empfohlenen Massnahmen.
## Geloeschte alte Fassung
Die alte Fassung `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx` wurde entfernt, weil sie durch die Version vom 2026-05-15 ersetzt ist.
## Entscheidbasis
Die fachlichen Entscheide stehen separat hier:
```text
entscheide.md
docs/FINANCE_ENTSCHEIDE.md
```
Kurzfassung der wichtigsten Entscheide:
| Thema | Entscheid |
| --- | --- |
| Fuehrende Waehrung | Hauswaehrung je Land |
| CHF-Sicht | Nur separat mit Budgetkursen |
| Aggregation | Pro Artikel bzw. Belegposition |
| Wertbasis | Nettofakturawert |
| Jahresabgrenzung 2025 | Buchungsdatum |
| Gutschriften / Storno | Separat ausweisen, positionsbasiert behandeln |
| Intercompany / 2nd-party | Separat klassifizieren und als Auswahl/Sicht fuehren |
| Indien | INR ist fuehrend |
| Italien | Hauswaehrung, Intercompany separat pruefen |
## Wichtig fuer Rueckfragen
Falls im Termin gefragt wird, ob die Berechnungslogik schon entschieden ist:
> Ja. Die Grundlogik ist entschieden: Hauswaehrung, Nettofakturawert, Buchungsdatum und Berechnung pro Belegposition. Offen sind vor allem Datenvollstaendigkeit, Intercompany-Abgrenzung, Budgetkursquelle und die fachliche Freigabe einzelner Laenderabweichungen.
## Was noch nicht final ist
| Thema | Status |
| --- | --- |
| IT | Kritisch; grosse Abweichung, Berechnungsart/IC/Deduplizierung pruefen |
| UK / EN | Hoch; Restdifferenz und Jahresvollstaendigkeit pruefen |
| DE | Hoch; finaler Jahresfile fehlt, Sample nicht verwenden |
| CH / AT | Hoch; Sollzuordnung und Trennung finalisieren |
| ES / AT | Mittel; kleinere Differenzen klaeren |
| FR / IN / US | Rechnerisch freigabefaehig |
@@ -0,0 +1,239 @@
# HR KPI Nachdokumentation 2026-05-13
## Ziel
Das HR KPI Cockpit wurde als separater, fachlich entkoppelter Reiter umgesetzt. Es nutzt PowerBI-M-/DAX-Logik nicht als generischen Interpreter, sondern als fachliche Vorlage, die in nachvollziehbare C#-Logik uebertragen wurde.
Der Reiter ist vom Finance-/Management-Cockpit getrennt. Er nutzt nur gemeinsame technische Infrastruktur wie Blazor, MudBlazor, DI, ClosedXML und bestehende Programmstruktur.
## Eingebaute HR KPI Funktion
Neue Navigation:
- `HR KPI` im Hauptmenue.
- Route: `/hr-kpi`.
Neue zentrale Dateien:
- `Components/Pages/HrKpi.razor`
- `Components/HrKpi/HrKpiDashboardTabs.razor`
- `Models/HrKpiModels.cs`
- `Services/HrKpiService.cs`
- `Services/HrKpi/HrKpiDashboardBuilder.cs`
- `TrafagSalesExporter.Tests/HrKpiServiceTests.cs`
## Datenquellen
Standard-Datenordner:
```text
C:\temp
```
Konfigurierbar ueber `appsettings.json`:
```json
"HrKpi": {
"DataFolder": "C:\\temp",
"MainFile": "Saldiperstichdatum.xlsx",
"TimeFile": "Exportkommengehen.xlsx",
"SapFile": "HR_KPI_Export.xlsx",
"AbsenceFile": "Abwesenheitinstunden.xlsx",
"LeaverFile": "Personalausgeschieden.xlsx"
}
```
Verarbeitete Dateien:
- `Saldiperstichdatum.xlsx`: aktive Mitarbeitende, Saldi, Ferien, Organisation, Kostenstelle.
- `Exportkommengehen.xlsx`: Arbeitszeitmodell, Sollzeit, Geburtsdatum.
- `HR_KPI_Export.xlsx`: SAP-HR-Felder wie Beschaeftigungsgrad, Geschlecht, BU/NBU, Planstelle.
- `Abwesenheitinstunden.xlsx`: Krankheit kurz/lang in Stunden.
- `Personalausgeschieden.xlsx`: Austritte, Austrittsart, Austrittsdatum.
## Dashboard-Reiter
Das Cockpit zeigt folgende Tabs:
- `Ueberblick`
- `Fluktuation`
- `Absenzen`
- `Zeit / Ferien`
- `Mitarbeitende`
- `Datenstatus`
Im Fluktuationsbereich wurden zusaetzliche Visualisierungen ergaenzt:
- Jahres-Fluktuations-Gauge
- Austritts-Funnel
- Donut nach Ausschlussgruenden
- relevante Austritte nach Organisation
- relevante Austritte pro Monat
## Filter
Aktuell vorhandene Filter:
- Datenordner
- Austrittsjahr
- Von Austritt
- Bis Austritt
- Organisation
- Eintrittsjahr
- Suche Name / Personalnummer
- Kostenstelle
- Mitarbeitertyp
- Fluktuation
- GLZ-Ampel
- Restferien-Ampel
## Korrektur Austrittsjahr / Von-Bis
Problem:
- `Austrittsjahr` war als `int` modelliert.
- Dadurch war immer ein Jahr gesetzt.
- Leeren bzw. "alle Austrittsjahre" war nicht moeglich.
- Aus Sicht UI wirkte es so, als ob leere Auswahl nicht uebernommen wird.
Umsetzung:
- `HrKpiOptions.Year` wurde von `int` auf `int?` geaendert.
- `Austrittsjahr` ist in der UI jetzt ein `MudSelect<int?>` mit `Clearable`.
- Die Jahresauswahl wird aus den vorhandenen Austrittsdaten gebaut.
- Neues Result-Feld: `ExitYearOptions`.
- Wenn `Austrittsjahr` leer ist, werden alle Austrittsjahre geladen.
- Wenn `Von Austritt` oder `Bis Austritt` gesetzt ist, hat dieser Zeitraum Vorrang vor `Austrittsjahr`.
Regel:
```text
Von/Bis gesetzt -> Austrittsdatum muss im Zeitraum liegen
Von/Bis leer und Austrittsjahr gesetzt -> Austrittsjahr muss passen
Von/Bis leer und Austrittsjahr leer -> alle Austritte
```
Nach der Architektur-/Formelpruefung wurde zusaetzlich korrigiert:
- `Austrittsjahr` ist auch beim Start leer und wird nicht mehr automatisch mit dem aktuellen Kalenderjahr vorbelegt.
- Bei leerem Austrittsjahr werden keine Jahres-/Quartals-/Monats-Fluktuationskennzahlen als Jahreswerte vorgetaeuscht; die Anzeige wird als `Fluktuation Auswahl` gefuehrt.
- Bei Von/Bis oder Mehrjahresauswahl zeigt die Timeline Jahresgruppen, wenn kein eindeutiges einzelnes Auswertungsjahr vorliegt.
- Die Fluktuationsberechnung nutzt fuer Mitarbeitendenfilter nur Felder, die in Mitarbeitendenbestand und Austrittsdaten vergleichbar sind: Organisation, Mitarbeitertyp, Eintrittsjahr und Suche.
- Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende/Absenzen, aber nicht Fluktuation, weil die Austrittsdatei diese Felder nicht stabil enthaelt. Das Cockpit weist darauf hin.
- Fluktuationsvisuals zaehlen Austritte distinct nach Personalnummer statt Zeilen.
- Fluktuationsraten nutzen Headcount, nicht FTE.
- `Headcount Monat` wird als Durchschnitt aus Monatsanfang und Monatsende berechnet.
- `Avg Headcount Quartal` ist der Durchschnitt der Monats-Headcounts im Quartal.
- `Avg Headcount Jahr` ist der Durchschnitt der Monats-Headcounts im Jahr.
- `Headcount nach Organisation` zaehlt Personalnummern distinct und ignoriert leere Personalnummern.
- Krankenquote nutzt neu `Krankheitstage / (FTE * 21 Tage)` statt `Krankheitstage / (Headcount * 21 Tage)`.
## Fluktuationslogik
Die Fluktuation wird aus den ausgeschiedenen Personen berechnet.
Grundlage gemaess `formeln.docx`:
- Monat: Arbeitnehmerkuendigungen des jeweiligen Monats / Headcount des Monats.
- Quartal: Arbeitnehmerkuendigungen des aktuellen Quartals / durchschnittlicher Headcount des Quartals.
- Hochrechnung Jahr: aktuelle Quartals-Fluktuation x 4.
- Effektiv Jahr: Arbeitnehmerkuendigungen des gesamten Jahres / durchschnittlicher Headcount des Jahres.
- Nenner ist Headcount der Festangestellten, nicht FTE.
Relevant ist ein Austritt, wenn:
- Austrittsart als Arbeitnehmer-/Mitarbeiterkuendigung erkannt wird.
- Mitarbeitertyp nicht ausgeschlossen ist.
- Austrittsgrund nicht als befristet, Pensionierung, Arbeitgeberkuendigung oder anderer Ausschlussgrund erkannt wird.
Ausgeschlossen werden unter anderem:
- Praktikant
- Werkstudent
- Aushilfe
- Lehrling
- befristeter Vertrag
- Pensionierung/Rente
- Kuendigung durch Trafag/Arbeitgeber
Zusaetzlich korrigiert:
- Rexx-Werte mit Umlaut wie `Kuendigung AN` werden trotz Originalschreibweise `Kündigung AN` als Arbeitnehmerkuendigung erkannt.
- `Kuendigung AG` bleibt als Arbeitgeberkuendigung ausgeschlossen.
- `Ruhestand` wird als Pensionierung ausgeschlossen.
## Architektur-Cleanup
Vorher:
- `HrKpiService` enthielt Import, Mapping, Filter, KPI-Berechnung, Visual-Daten und Excel-Parsing in einer grossen Klasse.
- `HrKpi.razor` enthielt Route, Filter, alle Tabs, Tabellen, Visualisierungen und CSS.
Nachher:
- `HrKpiService.cs` ist nur noch DI-/Service-Fassade.
- `HrKpiDashboardBuilder.cs` enthaelt die Build-Pipeline fuer Import, Mapping, Filter und KPI-Berechnung.
- `HrKpi.razor` bleibt fuer Route, Filter und Laden zustaendig.
- `HrKpiDashboardTabs.razor` enthaelt die Tabs, Tabellen, Fluktuationsvisuals und Styles.
- HR-Datenquellen sind ueber `HrKpiDataSourceOptions` konfigurierbar.
## Tests
Neue HR-KPI-Regressionstests:
- Organisation-Filter wirkt auch auf Absenzen.
- Von/Bis-Austrittsdatum hat Vorrang vor Austrittsjahr.
- Leeres Austrittsjahr liefert Austritte aus allen Jahren.
- Austrittsjahr ist standardmaessig leer.
- Employee-only Filter verzerren die Fluktuationsbasis nicht.
- Fluktuationsvisuals zaehlen distinct nach Personalnummer.
- Rexx-Austrittsarten `Kündigung AN`, `Kündigung AG` und `Ruhestand` werden korrekt klassifiziert.
- Fluktuationsraten verwenden durchschnittlichen Headcount statt aktuellen Stichtagsbestand.
- Mitarbeitende ohne Personalnummer werden nicht im Distinct-Headcount gezaehlt.
- FTE-Fallback aus Arbeitszeitmodell/Sollzeit wird verwendet, wenn SAP-Beschaeftigungsgrad fehlt.
- Fluktuationsrelevanz und Visual-Daten werden klassifiziert.
Aktueller Teststand nach der Korrektur:
```text
dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal
```
Ergebnis:
- Build erfolgreich.
- Tests erfolgreich: `69/69`.
Kontrollwert aus `C:\temp\Personalausgeschieden.xlsx`:
- Austritte total: `104`
- `Kündigung AN`: `42`
- `Kündigung AG`: `34`
- Fluktuationsrelevant nach aktueller HR-Logik: `33`
- Avg Headcount 2025 nach Intervalllogik: `211.3`
- Fluktuation Jahr effektiv 2025: `15.6%`
## Offene fachliche Pruefpunkte
Diese Punkte sind nicht automatisch geloest und muessen fachlich von HR bestaetigt werden:
- Ob die Abgrenzung "fluktuationsrelevant" exakt der Trafag-HR-Definition entspricht.
- Ob Arbeitnehmerkuendigungen anhand der vorhandenen Austrittsart-Texte vollstaendig erkannt werden.
- Ob Praktikanten, Werkstudenten, Aushilfen und Lehrlinge immer aus der Fluktuation ausgeschlossen werden sollen.
- Ob FTE-Fallback bei fehlendem SAP-Beschaeftigungsgrad fachlich akzeptiert ist.
- Ob `8.4 Stunden = 1 Krankheitstag` als Standardumrechnung fuer alle relevanten Gruppen korrekt ist.
- Ob GLZ- und Restferien-Ampeln mit den internen HR-Grenzwerten uebereinstimmen.
## Commit-Stand
Bereits erstellt:
- `20be752 Add HR KPI cockpit`
- `1cd0ad9 Refactor HR KPI cockpit architecture`
- `001e2a7 Commit pending finance and Power BI work`
Noch nicht committed zum Zeitpunkt dieser Nachdoku:
- Korrektur `Austrittsjahr` optional / Von-Bis Vorrang.
- Diese Nachdokumentation.
@@ -38,29 +38,29 @@ Die Power-Query-/DAX-Logik wurde nicht als Interpreter umgesetzt, sondern als C#
## Pruefpunkte mit moeglicher Abweichung
### 1. Fluktuationsnenner: Stichtags-Headcount statt Durchschnitt
### 1. Fluktuationsnenner: Durchschnittlicher Headcount
Aktueller Reiter:
- `Headcount Festangestellt` wird aus dem aktuell geladenen Stichtagsbestand gerechnet.
- `Avg Headcount Quartal` und `Avg Headcount Jahr` entsprechen aktuell faktisch ebenfalls diesem Stichtagswert.
- `Headcount Monat` wird als Durchschnitt aus Monatsanfang und Monatsende gerechnet.
- `Avg Headcount Quartal` ist der Durchschnitt der Monats-Headcounts im Quartal.
- `Avg Headcount Jahr` ist der Durchschnitt der Monats-Headcounts im Jahr.
- Die Berechnung nutzt Headcount, nicht FTE, und zaehlt nur Festangestellte.
Best Practice:
- Fluktuation sollte fuer Monat, Quartal und Jahr mit durchschnittlichem Headcount des jeweiligen Zeitraums gerechnet werden.
- Bei stabiler Belegschaft ist der Unterschied klein.
- Bei Wachstum, Abbau oder saisonalen Schwankungen kann der Unterschied relevant sein.
- Das entspricht der Vorgabe aus `formeln.docx`.
Pruefen:
- Liefert Rexx/SAP monatliche Headcount-Snapshots?
- Falls ja: Monatsdurchschnitt fuer Quartal/Jahr berechnen.
- Falls nein: UI klar als `Stichtagsnahe Fluktuation` oder `Naeherung` beschriften.
- Die aktuelle Berechnung rekonstruiert Monats-Headcounts aus Eintritts-/Austrittsdatum der aktiven Mitarbeitenden und der Austrittsdatei.
- Echte historische Monats-Snapshots waeren fuer ein auditierbares Reporting noch genauer.
Status:
- fachlich akzeptabel als erste Naeherung
- fuer offizielles HR-Reporting noch zu bestaetigen
- fachlich deutlich naeher an `formeln.docx`
- als Reporting-Definition mit HR bestaetigen, falls HR echte Monats-Snapshots verlangt
### 2. Freiwillige vs. unfreiwillige Austritte
@@ -85,25 +85,25 @@ Status:
- HR-gepruefte Grundlogik vorhanden
- Mappingliste muss bei neuen Austrittsarten gepflegt/validiert werden
### 3. Fluktuation Quartal/Jahr bei nur einem aktuellen Bestand
### 3. Fluktuation Quartal/Jahr ohne echte Monats-Snapshots
Aktueller Reiter:
- Quartals-/Jahresraten werden ueber Austrittsdatum gefiltert.
- Headcount bleibt aktueller Stichtagsbestand.
- Headcount wird aus Eintritts-/Austrittsintervallen pro Monat rekonstruiert.
Risiko:
- Wenn der aktuelle Bestand z. B. Ende Jahr niedriger/hoeher ist als im Quartal, verzerrt das die historische Rate.
- Wenn historische Korrekturen oder Rueckdatierungen nicht in den Dateien enthalten sind, koennen rekonstruierte Monatswerte von offiziellen HR-Snapshots abweichen.
Pruefen:
- Fuer Quartal/Jahr entweder echte historische Headcounts laden oder die Kennzahl explizit als operative Naeherung fuehren.
- Falls HR monatliche Headcount-Snapshots liefert, diese spaeter als bevorzugte Quelle fuer den Nenner verwenden.
Status:
- Darstellung gut fuer operatives Cockpit
- nicht automatisch als auditierbare Jahreskennzahl verwenden
- operativ passend zur Formel in `formeln.docx`
- fuer Audit/Abschluss mit HR-Snapshot abgleichen
### 4. Absenzenquote: 21 Arbeitstage pauschal
@@ -304,12 +304,11 @@ Status:
## Empfehlung fuer die naechste Umsetzung
Noch keine Formel aendern, bevor die Kontrollwerte protokolliert sind.
Die Fluktuationsformel wurde gemaess `formeln.docx` auf durchschnittlichen Headcount umgestellt. Vor produktiver Nutzung bleiben die Kontrollwerte mit HR/Power BI zu protokollieren.
Sinnvolle naechste technische Erweiterungen:
- Tab `Datenstatus` um Join-Trefferquoten erweitern.
- Tab `Fluktuation` mit Kontrollwerten Power BI/HR anzeigen.
- Absenzenquote optional auf vertragliche Sollzeit/FTE umstellen.
- Kennzahlen mit `Naeherung` markieren, solange nur ein Stichtagsbestand statt historischer Monats-Snapshots vorhanden ist.
- Falls HR echte historische Monats-Snapshots liefert, diese als bevorzugte Quelle fuer den durchschnittlichen Headcount nutzen.
+56
View File
@@ -0,0 +1,56 @@
# Finance Entscheide Soll/Ist 2025
Stand: 2026-05-15
Dieses Dokument extrahiert die erkennbaren Fragen und Entscheide aus der Abstimmung. Es dient als Arbeitsgrundlage fuer die Umsetzung im Finance-Abgleich und fuer die naechste Klaerung mit Andreas/Finance.
## Entscheide
| Thema | Frage | Entscheid |
| --- | --- | --- |
| Fuehrende Waehrung je Land | Welche Waehrung ist im Landessystem je Land fuehrend: Belegwaehrung, Hauswaehrung oder etwas anderes? | Immer Hauswaehrung. Rechnungen werden in der Hauswaehrung ausgewertet. |
| CHF-Umrechnung | Mit welchem Kurs wird nach CHF umgerechnet: Monatskurs, Tageskurs, Jahresdurchschnitt oder SNB-Tageskurs? | Budgetkurse verwenden. Keine SNB-Tageskurse fuer den Standardabgleich. |
| Aggregation | Wird zuerst pro Beleg/Position summiert und danach umgerechnet, oder wird jede Zeile einzeln umgerechnet und danach summiert? | Pro Artikel bzw. Belegposition rechnen. |
| Indien | In Indien kommen CHF, EUR, GBP, INR, JPY und USD vor. Muss vorher nach CHF umgerechnet oder nach Waehrung getrennt werden? | Indien immer in indischen Rupien auswerten. Fuehrend ist INR. |
| Italien / Intercompany | Soll IT mit Intercompany-Abzug gerechnet werden? Falls ja, nach welchen Kunden/Kriterien? | Hauswaehrung verwenden. Intercompany wird separat abgegrenzt und ausgewiesen. |
| Wertbasis | Welche Basis soll fuer Net Sales Actuals je Land verwendet werden? | Nettofakturawert. |
| Jahresabgrenzung | Fuer das Jahr 2025: Nach welchem Datum wird abgegrenzt? | Buchungsdatum. |
| Gutschriften / Storno | Wie werden Gutschriften und Storno behandelt? | Gutschriften separat ausweisen. Sie haben eigene Rechnungsnummern bzw. Rechnungspositionen. Behandlung immer ueber Artikelnummer/Positionslogik, da alles andere zu komplex wird. |
| Intercompany / 2nd Party | Wie werden Intercompany-Kunden abgegrenzt? | Im zweiten Schritt als neues Auswahlfeld fuer Intercompany bzw. 2nd-party-Kunde. Regeln einmalig hinterlegen, weil sie sich kaum aendern. |
## Intercompany-Regeln
Intercompany bzw. 2nd-party soll ueber stabile Kundenmarker erkannt werden.
Aktuell erkennbare Marker:
- `MAGNETS SENSE`
- `MAGNETIC SENSE`
- `TRAFAG`
- `GESELLSCHAFT FUER SENSORIK`
- `GESELLSCHAFT FUR SENSORIK`
Bewertung:
- Treffer auf diese Marker gelten als Intercompany bzw. 2nd-party.
- Alle anderen Kunden gelten standardmaessig als 3rd-party.
- Weitere Uebersetzungen, lokale Schreibweisen oder Kundennummern muessen bei Bedarf ergaenzt werden.
## Umsetzungsfolge
1. Standard-Ist je Land in Hauswaehrung und auf Basis Nettofakturawert berechnen.
2. Jahresfilter 2025 ueber Buchungsdatum anwenden.
3. Werte pro Artikel bzw. Belegposition berechnen und danach summieren.
4. Gutschriften separat sichtbar machen, aber positionsbasiert behandeln.
5. Intercompany/2nd-party als eigenes Auswahlfeld bzw. eigene Sicht ergaenzen.
6. CHF-Sicht nur mit Budgetkursen als separate Kontrollsicht aufbauen.
## Offene Punkte
| Punkt | Klaerung |
| --- | --- |
| Intercompany-Kundenliste | Finale Liste der Kundennummern, Namen und lokalen Schreibweisen je Land bestaetigen. |
| Italien | Abgrenzung mit/ohne Intercompany fachlich gegen Rhino/check.xlsx pruefen. |
| Budgetkurse | Quelle und Gueltigkeit der Budgetkurse je Jahr festlegen. |
| Gutschriften | Sicherstellen, dass alle Quellsysteme Gutschriften mit eigener Rechnungsnummer/Position liefern oder sauber markierbar sind. |
+141
View File
@@ -1172,3 +1172,144 @@ Ergaenzt in `docs/PROGRAMM_DIAGRAMME.md`:
- FinanceProbe-Start und Hinweis zu Console-Logging.
- Hinweis zu DLL-Sperren durch Visual Studio bzw. alte `dotnet`-Prozesse.
## HR KPI Cockpit und Filterkorrektur 2026-05-13
Ergaenzt:
- Separater HR-KPI-Reiter `/hr-kpi`.
- Dashboard-Tabs fuer Ueberblick, Fluktuation, Absenzen, Zeit/Ferien, Mitarbeitende und Datenstatus.
- Fluktuationsvisuals: Gauge, Funnel, Donut, Organisation-Balken und Monatsbalken.
- Architektur-Cleanup: `HrKpiService` als Fassade, Build-Pipeline in `Services/HrKpi/HrKpiDashboardBuilder.cs`, UI-Tabs in `Components/HrKpi/HrKpiDashboardTabs.razor`.
- Konfigurierbare HR-Dateiquellen ueber `HrKpi` in `appsettings.json`.
- HR-KPI-Regressionstests.
Korrigiert:
- `Austrittsjahr` ist jetzt optional.
- Leeres Austrittsjahr bedeutet: alle Austritte.
- Von/Bis-Austritt hat Vorrang vor Austrittsjahr.
- Die Austrittsjahr-Auswahl wird aus den vorhandenen Austrittsdaten aufgebaut.
- `Austrittsjahr` ist beim Start leer statt automatisch aktuelles Jahr.
- Fluktuation nutzt nur vergleichbare Filter auf Mitarbeitenden- und Austrittsdaten.
- Kostenstelle, GLZ und Restferien filtern nicht die Fluktuation, weil die Austrittsdatei diese Felder nicht stabil enthaelt; das Cockpit zeigt dazu einen Hinweis.
- Bei Mehrjahresauswahl wird die Fluktuation als Auswahlwert statt als Jahreswert gefuehrt.
- Fluktuationsvisuals zaehlen distinct nach Personalnummer.
- Fluktuationsraten nutzen nun durchschnittlichen Headcount statt Stichtags-Headcount: Monat, Quartal und Jahr folgen `formeln.docx`.
- Krankenquote nutzt den FTE-Nenner: `Krankheitstage / (FTE * 21 Tage)`.
- Rexx-Austrittsarten mit Umlaut werden korrekt normalisiert: `Kündigung AN` zaehlt als Arbeitnehmerkuendigung, `Kündigung AG` als Arbeitgeberkuendigung-Ausschluss, `Ruhestand` als Pensionierung.
Nachdokumentation:
```text
docs/HR_KPI_NACHDOKU_2026-05-13.md
```
Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_app\ --verbosity minimal`
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_tests\ --verbosity minimal`
- Ergebnis: `69/69` Tests bestanden.
- Kontrollwert `C:\temp\Personalausgeschieden.xlsx`: `104` Austritte total, `42` `Kündigung AN`, `34` `Kündigung AG`, `33` fluktuationsrelevant.
- Kontrollwert neuer Nenner: Avg Headcount 2025 `211.3`, Fluktuation Jahr effektiv `15.6%`.
## FinanceProbe Finanzchef-Uebersicht 2026-05-13
Ergaenzt:
- Neuer Reiter `Finanzchef Uebersicht` in `Tools/FinanceProbe`.
- Kompakte Soll/Ist-Sicht nur fuer offene Laender.
- Spalten reduziert auf Status, Land, Waehrung, Ist, Soll, Abweichung und Pruefgrund.
- Bestehende Detailtabellen bleiben unveraendert fuer Analyse/Nachvollzug.
Verifikation:
- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_financeprobe\ --verbosity minimal`
- Ergebnis: Build erfolgreich, `0` Fehler.
- Hinweis: `NU1900` wegen nicht erreichbarer NuGet-Sicherheitsdaten im eingeschraenkten Netzwerk.
## Finance CFO Word-Kurzbericht 2026-05-13
Erstellt:
- `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx`
- Kurzbericht fuer Finance/CFO mit Kernaussagen und Massnahmen.
- Enthalten: FR, IN, US, AT, ES, UK/EN, DE, CH, IT.
- Ausgeschlossen: GFS und reine 0-/Leer-Faelle ohne operative Aussage.
Inhaltlicher Fokus:
- Freigabefaehige Laender: FR, IN, US.
- Kleine/mittlere Klaerung: AT, ES.
- Hohe Prioritaet: UK/EN, DE, CH.
- Kritisch: IT wegen groesster Abweichung und offener Berechnungsart.
## Finance CFO Word-Kurzbericht Erweiterung 2026-05-15
Ergaenzt:
- Aktuelle Fassung: `docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx`
- Erweiterte Tabellenansicht mit Status, Ist, Soll/Rhino, Abweichung, Pruefquelle, Massnahme und Prioritaet.
- Grafische Ampel-Uebersicht fuer OK/Klaeren/Hoch/Kritisch.
- Prioritaetsgrafik fuer IT, DE, UK/EN, CH, AT/ES.
- Abschnitt `Geprueft gegen` mit Rhino/Andreas `check.xlsx`, FinanceProbe/CentralSalesRecords, Spain CSV, Deutschland-Beispielfile und UK_B1.
Verifikation:
- DOCX enthaelt `word/document.xml`.
- Inhalte `Rhino / Andreas check.xlsx`, `Management-Ampel`, `Prioritaetsgrafik` und `Laendertabelle mit Massnahmen` wurden im Dokumentpaket geprueft.
## Finance Spanien Mailentwurf 2026-05-15
Erstellt:
- `docs/FINANCE_ES_MAIL_ABWEICHUNG_2026-05-15.md`
- Spanischer Mailentwurf zur Abweichung Spanien Net Sales 2025.
- Enthaltene Pruefpunkte: Zeitraum, Serien `REG/LAT/PRO/REC`, Abonos/Credit Notes, Datumslogik und verwendetes Netto-Umsatzfeld.
## Finance IT und UK Mailentwuerfe 2026-05-15
Erstellt:
- `docs/FINANCE_IT_MAIL_ABWEICHUNG_2026-05-15.md`
- `docs/FINANCE_UK_MAIL_ABWEICHUNG_2026-05-15.md`
Inhalt:
- Italien: grosse Abweichung `+7.034.496,29 EUR`, Fokus Berechnungsart, Beleg/Position-Deduplizierung, Intercompany, Credit Notes, Datumslogik und Waehrung.
- UK/England: Restdifferenz `-216,154.91 GBP`, Fokus Jahresvollstaendigkeit, Periodenbereich, Credit Notes, Nettofeld, Discounts/Freight/Charges, 2nd-/3rd-party und Waehrung.
## Finance Entscheide Extraktion 2026-05-15
Erstellt:
- `entscheide.md`
Inhalt:
- Fragen und Entscheide aus der Finance-Abstimmung extrahiert.
- Festgehaltene Kernentscheide: Hauswaehrung je Land, Budgetkurse fuer CHF-Sicht, Berechnung pro Artikel/Belegposition, Nettofakturawert, Buchungsdatum, separate Gutschriftenausweisung und Intercompany/2nd-party als eigenes Auswahlfeld.
- Intercompany-Marker dokumentiert: `MAGNETS SENSE`, `MAGNETIC SENSE`, `TRAFAG`, `GESELLSCHAFT FUER SENSORIK`, `GESELLSCHAFT FUR SENSORIK`.
## Finance Dokumentgueltigkeit 2026-05-15
Erstellt:
- `docs/FINANCE_WELCHES_DOKUMENT_GILT_2026-05-15.md`
Festgelegt:
- Fuehrendes CFO-Dokument: `docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx`
- Alte CFO-Version `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx` entfernt, weil sie durch die Version vom 2026-05-15 ersetzt wurde.
- Entscheidbasis: `entscheide.md` und `docs/FINANCE_ENTSCHEIDE.md`.
## Finance Dashboard Todo 2026-05-15
Erstellt:
- `docs/FINANCE_DASHBOARD_TODO_2026-05-15.md`
Inhalt:
- Todo-Liste fuer Group Sales Reporting Intranet-Dashboard.
- Priorisierte Punkte fuer CFO-Dokument, offene Laenderabweichungen, Intercompany, Budgetkurse und Berechtigungskonzept.