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
@@ -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(