From 20be752adcc4dd6b01776a0896a65161bbcc4748 Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 13 May 2026 07:10:13 +0200 Subject: [PATCH] Add HR KPI cockpit --- .../Components/Layout/NavMenu.razor | 3 + .../Components/Pages/HrKpi.razor | 746 +++++++++++++ TrafagSalesExporter/Models/HrKpiModels.cs | 159 +++ TrafagSalesExporter/Program.cs | 1 + TrafagSalesExporter/Services/HrKpiService.cs | 992 ++++++++++++++++++ .../HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.docx | Bin 0 -> 6501 bytes .../HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md | 315 ++++++ 7 files changed, 2216 insertions(+) create mode 100644 TrafagSalesExporter/Components/Pages/HrKpi.razor create mode 100644 TrafagSalesExporter/Models/HrKpiModels.cs create mode 100644 TrafagSalesExporter/Services/HrKpiService.cs create mode 100644 TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.docx create mode 100644 TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index d801fe2..77eaa78 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -18,6 +18,9 @@ @T("Management Cockpit", "Management Cockpit") + + @T("HR KPI", "HR KPI") + diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor new file mode 100644 index 0000000..6fb6ad8 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor @@ -0,0 +1,746 @@ +@page "/hr-kpi" +@using TrafagSalesExporter.Services +@inject IHrKpiService HrKpiService +@inject ISnackbar Snackbar +@inject IUiTextService UiText + +@T("HR KPI", "HR KPI") + +@T("HR KPI", "HR KPI") + + + + + + + + + + + + @foreach (var option in _result?.OrganisationOptions ?? []) + { + @option + } + + + + + @(_loading ? T("Lade...", "Loading...") : T("Laden", "Load")) + + + + + + + + + + + @foreach (var option in _result?.EntryYearOptions ?? []) + { + @option + } + + + + + + + + @foreach (var option in _result?.KostenstelleOptions ?? []) + { + @option + } + + + + + @foreach (var option in _result?.MitarbeitertypOptions ?? []) + { + @option + } + + + + + @foreach (var option in _fluktuationOptions) + { + @option.Label + } + + + + + @foreach (var option in _ampelOptions) + { + @option + } + + + + + @foreach (var option in _restferienOptions) + { + @option + } + + + + + +@if (_result is not null) +{ + @if (_result.Notices.Count > 0) + { + + @foreach (var notice in _result.Notices) + { + @notice + } + + } + + + + @MetricGrid(_result.Metrics) + + + + @HeadcountByOrganisationTable(_result.HeadcountByOrganisation) + + + @CriticalBalancesTable(_result.CriticalTimeBalances) + + + + + + @MetricGrid(_result.TurnoverMetrics) + + + + @TurnoverRelevantTable(_result.FluctuationRelevantLeavers) + + + @LeaverExclusionTable(_result.Leavers) + + + + + + @TurnoverGauge(_result.TurnoverVisuals) + + + @TurnoverFunnel(_result.TurnoverVisuals.FunnelSteps) + + + @TurnoverDonut(_result.TurnoverVisuals.ExclusionReasons) + + + + + + @HorizontalBars(_result.TurnoverVisuals.RelevantByOrganisation) + + + @MonthlyBars(_result.TurnoverVisuals.MonthlyRelevantLeavers) + + + + + + @MetricGrid(_result.AbsenceMetrics) + + @T("Absenzen je Mitarbeiter", "Absences by employee") + + + @T("Personalnr.", "Personnel no.") + @T("Name", "Name") + @T("Organisation", "Organisation") + @T("Kurz", "Short") + @T("Lang", "Long") + @T("Gesamt", "Total") + @T("Quote", "Rate") + + + @context.Personalnummer + @context.Name + @context.Organisationseinheit + @context.KrankheitstageKurz.ToString("N1") + @context.KrankheitstageLang.ToString("N1") + @context.KrankheitstageGesamt.ToString("N1") + @context.KrankenquoteMa.ToString("P1") + + + + + + + + + + @MetricGrid(_result.TimeVacationMetrics) + + + + @CriticalBalancesTable(_result.CriticalTimeBalances) + + + + @T("Kritische Restferien", "Critical vacation balance") + + + @T("Name", "Name") + @T("Organisation", "Organisation") + @T("Rest", "Left") + @T("Ausstehend", "Open") + @T("Ampel", "Status") + + + @context.NameVoll + @context.Organisationseinheit + @context.UrlaubRest.ToString("N1") + @context.FerienAusstehend.ToString("N1") + + + @context.RestferienAmpel + + + + + + + + + + + @EmployeesTable(_result.Employees) + + + + @FileStatusTable(_result.FileStatuses) + + +} + +@code { + private string _dataFolder = @"C:\temp"; + private int _year = DateTime.Today.Year; + private DateTime? _fromDate; + private DateTime? _toDate; + private int? _entryYear; + private string? _organisation; + private string? _kostenstelle; + private string? _mitarbeitertyp; + private string _fluktuationFilter = "Alle"; + private string? _glzAmpel; + private string? _restferienAmpel; + private string? _searchText; + private bool _loading; + private HrKpiResult? _result; + private readonly List<(string Key, string Label)> _fluktuationOptions = + [ + ("Alle", "Alle"), + ("Fluktuationsrelevant", "Relevant"), + ("Arbeitnehmerkuendigung", "Arbeitnehmerkuendigung"), + ("Ausgeschlossen", "Ausgeschlossen") + ]; + private readonly List _ampelOptions = ["Gruen", "Gelb", "Rot"]; + private readonly List _restferienOptions = ["Gruen", "Rot"]; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + try + { + _result = await HrKpiService.BuildAsync(new HrKpiOptions + { + DataFolder = _dataFolder, + Year = _year, + FromDate = _fromDate, + ToDate = _toDate, + EntryYear = _entryYear, + Organisationseinheit = _organisation, + KostenstelleText = _kostenstelle, + Mitarbeitertyp = _mitarbeitertyp, + FluktuationFilter = _fluktuationFilter, + GlzAmpel = _glzAmpel, + RestferienAmpel = _restferienAmpel, + SearchText = _searchText + }); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + finally + { + _loading = false; + } + } + + private string T(string german, string english) => UiText.Text(german, english); + + private static Color MetricColor(string severity) + => severity == "Warning" ? Color.Warning : Color.Default; + + private static Color TrafficLightColor(string value) + => value switch + { + "Rot" => Color.Error, + "Gelb" => Color.Warning, + _ => Color.Success + }; + + private static string FormatDate(DateTime? value) + => value?.ToString("dd.MM.yyyy") ?? "-"; +} + +@code { + private RenderFragment> MetricGrid => metrics => @ + @foreach (var metric in metrics) + { + + + @metric.Label + @metric.Value + @metric.Detail + + + } + ; + + private RenderFragment> HeadcountByOrganisationTable => items => @ + @T("Headcount nach Organisation", "Headcount by organisation") + + + @T("Organisation", "Organisation") + @T("Headcount", "Headcount") + FTE + + + @context.Label + @context.Count.ToString("N0") + @context.Value.ToString("N1") + + + ; + + private RenderFragment> CriticalBalancesTable => items => @ + @T("Kritische GLZ-Saldi", "Critical time balances") + + + @T("Name", "Name") + @T("Organisation", "Organisation") + @T("Saldo", "Balance") + @T("Ampel", "Status") + + + @context.NameVoll + @context.Organisationseinheit + @context.StundenSaldo.ToString("N1") + + + @context.GlzAmpel + + + + + ; + + private RenderFragment> TurnoverRelevantTable => items => @ + @T("Fluktuationsrelevante Austritte", "Turnover relevant leavers") + + + @T("Name", "Name") + @T("Austritt", "Exit") + @T("Organisation", "Organisation") + @T("Austrittsart", "Exit type") + + + @context.NameVoll + @FormatDate(context.Austrittsdatum) + @context.Organisationseinheit + @context.Austrittsart + + + ; + + private RenderFragment> LeaverExclusionTable => items => @ + @T("Ausschlussgruende", "Exclusion reasons") + + + @T("Grund", "Reason") + @T("Anzahl", "Count") + + + @context.Label + @context.Count.ToString("N0") + + + ; + + private RenderFragment> EmployeesTable => items => @ + @T("Mitarbeitende", "Employees") + + + @T("Personalnr.", "Personnel no.") + @T("Name", "Name") + @T("Organisation", "Organisation") + @T("Kostenstelle", "Cost center") + FTE + @T("Alter", "Age") + @T("Dienstjahre", "Service years") + @T("Typ", "Type") + + + @context.Personalnummer + @context.NameVoll + @context.Organisationseinheit + @context.KostenstelleText + @context.Fte.ToString("N2") + @context.AlterJahre + @context.Dienstjahre + @context.Mitarbeitertyp + + + + + + ; + + private RenderFragment> FileStatusTable => items => @ + @T("Dateistatus", "File status") + + + @T("Quelle", "Source") + @T("Status", "Status") + @T("Zeilen", "Rows") + + + + @context.Label + @context.Path + + + + @(context.Message ?? "-") + + + @context.RowCount.ToString("N0") + + + ; + + private static IEnumerable BuildLeaverExclusionRows(IReadOnlyList items) + => items + .GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant") + .Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count() }) + .OrderByDescending(x => x.Count); + + private RenderFragment TurnoverGauge => visual => @ + @T("Jahres-Fluktuation", "Annual turnover") +
+
+
+
+
@visual.YearRateLabel
+
0-20%
+
+
+
+ 0% + 8% + 12% + 20%+ +
+
; + + private RenderFragment> TurnoverFunnel => items => @ + @T("Austritts-Funnel", "Leaver funnel") +
+ @foreach (var item in items) + { +
+
@item.Label
+
+
+ @item.Count.ToString("N0") +
+
+
+ } +
+
; + + private RenderFragment> TurnoverDonut => items => @ + @T("Ausschlussgruende", "Exclusion reasons") +
+
+
@items.Sum(x => x.Count).ToString("N0")
+
+
+ @foreach (var item in items.Take(7)) + { +
+ + @item.Label + @item.Count.ToString("N0") +
+ } +
+
+
; + + private RenderFragment> HorizontalBars => items => @ + @T("Relevante Austritte nach Organisation", "Relevant leavers by organisation") +
+ @foreach (var item in items) + { +
+
@item.Label
+
+
+
+
@item.Count.ToString("N0")
+
+ } +
+
; + + private RenderFragment> MonthlyBars => items => @ + @T("Relevante Austritte pro Monat", "Relevant leavers per month") +
+ @foreach (var item in items) + { +
+
0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
+
@item.Count
+
@item.Label
+
+ } +
+
; + + private static string BuildDonutStyle(IReadOnlyList items) + { + var total = items.Sum(x => x.Count); + if (total <= 0) + return "background:#e0e0e0"; + var current = 0m; + var segments = new List(); + foreach (var item in items) + { + var start = current; + current += item.Count / (decimal)total * 100m; + segments.Add($"{item.Color} {start.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}% {current.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%"); + } + return $"background:conic-gradient({string.Join(", ", segments)})"; + } +} + + diff --git a/TrafagSalesExporter/Models/HrKpiModels.cs b/TrafagSalesExporter/Models/HrKpiModels.cs new file mode 100644 index 0000000..c13ae91 --- /dev/null +++ b/TrafagSalesExporter/Models/HrKpiModels.cs @@ -0,0 +1,159 @@ +namespace TrafagSalesExporter.Models; + +public sealed class HrKpiOptions +{ + public string DataFolder { get; set; } = @"C:\temp"; + public int Year { get; set; } = DateTime.Today.Year; + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public int? EntryYear { get; set; } + public string? Organisationseinheit { get; set; } + public string? KostenstelleText { get; set; } + public string? Mitarbeitertyp { get; set; } + public string? FluktuationFilter { get; set; } + public string? GlzAmpel { get; set; } + public string? RestferienAmpel { get; set; } + public string? SearchText { get; set; } +} + +public sealed class HrKpiResult +{ + public HrKpiOptions Options { get; set; } = new(); + public List FileStatuses { get; set; } = []; + public List Notices { get; set; } = []; + public List OrganisationOptions { get; set; } = []; + public List KostenstelleOptions { get; set; } = []; + public List EntryYearOptions { get; set; } = []; + public List MitarbeitertypOptions { get; set; } = []; + public List Metrics { get; set; } = []; + public List TurnoverMetrics { get; set; } = []; + public List AbsenceMetrics { get; set; } = []; + public List TimeVacationMetrics { get; set; } = []; + public List Employees { get; set; } = []; + public List Absences { get; set; } = []; + public List Leavers { get; set; } = []; + public List HeadcountByOrganisation { get; set; } = []; + public List CriticalTimeBalances { get; set; } = []; + public List FluctuationRelevantLeavers { get; set; } = []; + public HrTurnoverVisuals TurnoverVisuals { get; set; } = new(); +} + +public sealed class HrKpiFileStatus +{ + public string Label { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public bool Exists { get; set; } + public int RowCount { get; set; } + public string? Message { get; set; } +} + +public sealed class HrKpiMetric +{ + public string Label { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Detail { get; set; } = string.Empty; + public string Severity { get; set; } = "Normal"; +} + +public sealed class HrKpiGroupValue +{ + public string Label { get; set; } = string.Empty; + public decimal Value { get; set; } + public int Count { get; set; } + public string Color { get; set; } = "#607d8b"; + public decimal Percent { get; set; } +} + +public sealed class HrTurnoverVisuals +{ + public decimal YearRatePercent { get; set; } + public string YearRateLabel { get; set; } = "0.0%"; + public string GaugeColor { get; set; } = "#2e7d32"; + public decimal GaugeRotationDegrees { get; set; } + public List FunnelSteps { get; set; } = []; + public List ExclusionReasons { get; set; } = []; + public List RelevantByOrganisation { get; set; } = []; + public List MonthlyRelevantLeavers { get; set; } = []; +} + +public sealed class HrKpiEmployeeRow +{ + public int? Personalnummer { get; set; } + public string NameVoll { get; set; } = string.Empty; + public string Vorname { get; set; } = string.Empty; + public string Nachname { get; set; } = string.Empty; + public string Organisationseinheit { get; set; } = string.Empty; + public string KostenstelleText { get; set; } = string.Empty; + public int? Kostenstelle { get; set; } + public string Stelle { get; set; } = string.Empty; + public string Leitung { get; set; } = string.Empty; + public DateTime? Eintrittsdatum { get; set; } + public DateTime? Geburtsdatum { get; set; } + public int? AlterJahre { get; set; } + public string Altersgruppe { get; set; } = "Unbekannt"; + public string GeschlechtText { get; set; } = "Unbekannt"; + public decimal? BeschaeftigungsgradProzent { get; set; } + public decimal Fte { get; set; } + public bool IstTeilzeit { get; set; } + public int? Dienstjahre { get; set; } + public bool IstAktiv { get; set; } + public string Mitarbeitertyp { get; set; } = "Festangestellt"; + public decimal StundenSaldo { get; set; } + public string GlzAmpel { get; set; } = "Gruen"; + public decimal UrlaubRest { get; set; } + public decimal Urlaubsanspruch { get; set; } + public decimal FerienAusstehend { get; set; } + public decimal Ferientage { get; set; } + public string RestferienAmpel { get; set; } = "Gruen"; + public decimal Bruttolohn { get; set; } + public string LohnWaehrung { get; set; } = string.Empty; + public decimal BuTage { get; set; } + public decimal NbuTage { get; set; } + public string Buchungskreis { get; set; } = string.Empty; + public string Personalbereich { get; set; } = string.Empty; + public string Personalteilbereich { get; set; } = string.Empty; + public string Mitarbeitergruppe { get; set; } = string.Empty; + public string Mitarbeiterkreis { get; set; } = string.Empty; + public string Planstelle { get; set; } = string.Empty; + public string SollStelle { get; set; } = string.Empty; + public DateTime Periode { get; set; } = new(DateTime.Today.Year, DateTime.Today.Month, 1); +} + +public sealed class HrAbsenceRow +{ + public int? Personalnummer { get; set; } + public string Name { get; set; } = string.Empty; + public string Organisationseinheit { get; set; } = string.Empty; + public string Stelle { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal KrankheitKurzStd { get; set; } + public decimal KrankheitLangStd { get; set; } + public decimal KrankheitGesamtStd { get; set; } + public decimal KrankheitstageGesamt { get; set; } + public decimal KrankheitstageKurz { get; set; } + public decimal KrankheitstageLang { get; set; } + public decimal KrankenquoteMa { get; set; } +} + +public sealed class HrLeaverRow +{ + public int? Personalnummer { get; set; } + public string NameVoll { get; set; } = string.Empty; + public string Vorname { get; set; } = string.Empty; + public string Nachname { get; set; } = string.Empty; + public string Organisationseinheit { get; set; } = string.Empty; + public string Stelle { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime? Austrittsdatum { get; set; } + public DateTime? Eintrittsdatum { get; set; } + public decimal? VerweildauerMonate { get; set; } + public string Austrittsart { get; set; } = string.Empty; + public string AustrittsartNormalisiert { get; set; } = string.Empty; + public string Mitarbeitertyp { get; set; } = "Festangestellt"; + public bool IstArbeitnehmerkuendigung { get; set; } + public bool IstFluktuationAusgeschlossen { get; set; } + public bool IstFluktuationsrelevant { get; set; } + public string? FluktuationAusschlussgrund { get; set; } + public DateTime? Austrittsmonat { get; set; } + public int? Austrittsjahr { get; set; } +} diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 44710ee..7707548 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -70,6 +70,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/HrKpiService.cs b/TrafagSalesExporter/Services/HrKpiService.cs new file mode 100644 index 0000000..3d0e857 --- /dev/null +++ b/TrafagSalesExporter/Services/HrKpiService.cs @@ -0,0 +1,992 @@ +using System.Globalization; +using System.Text; +using ClosedXML.Excel; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IHrKpiService +{ + Task BuildAsync(HrKpiOptions options); +} + +public sealed class HrKpiService : IHrKpiService +{ + private const string MainFile = "Saldiperstichdatum.xlsx"; + private const string TimeFile = "Exportkommengehen.xlsx"; + private const string SapFile = "HR_KPI_Export.xlsx"; + private const string AbsenceFile = "Abwesenheitinstunden.xlsx"; + private const string LeaverFile = "Personalausgeschieden.xlsx"; + + public Task BuildAsync(HrKpiOptions options) + { + var normalizedOptions = new HrKpiOptions + { + DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? @"C:\temp" : options.DataFolder.Trim(), + Year = options.Year <= 0 ? DateTime.Today.Year : options.Year, + FromDate = options.FromDate?.Date, + ToDate = options.ToDate?.Date, + EntryYear = options.EntryYear, + Organisationseinheit = NormalizeFilter(options.Organisationseinheit), + KostenstelleText = NormalizeFilter(options.KostenstelleText), + Mitarbeitertyp = NormalizeFilter(options.Mitarbeitertyp), + FluktuationFilter = NormalizeFilter(options.FluktuationFilter), + GlzAmpel = NormalizeFilter(options.GlzAmpel), + RestferienAmpel = NormalizeFilter(options.RestferienAmpel), + SearchText = NormalizeFilter(options.SearchText) + }; + + var result = new HrKpiResult { Options = normalizedOptions }; + var context = new ImportContext(result, normalizedOptions.DataFolder); + + var timeRows = LoadTimeRows(context); + var sapRows = LoadSapRows(context); + var employees = LoadEmployees(context, timeRows, sapRows); + var absences = LoadAbsences(context); + var leavers = LoadLeavers(context); + + result.OrganisationOptions = employees + .Select(x => x.Organisationseinheit) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + result.KostenstelleOptions = employees + .Select(x => x.KostenstelleText) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + result.EntryYearOptions = employees + .Where(x => x.Eintrittsdatum.HasValue) + .Select(x => x.Eintrittsdatum!.Value.Year) + .Distinct() + .OrderByDescending(x => x) + .ToList(); + result.MitarbeitertypOptions = employees + .Select(x => x.Mitarbeitertyp) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var analysisYear = ResolveAnalysisYear(normalizedOptions); + var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList(); + var filteredEmployeeNumbers = filteredEmployees + .Where(x => x.Personalnummer.HasValue) + .Select(x => x.Personalnummer!.Value) + .ToHashSet(); + + employees = filteredEmployees; + absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList(); + leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList(); + + 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.AbsenceMetrics = BuildAbsenceMetrics(employees, absences); + result.TimeVacationMetrics = BuildTimeVacationMetrics(employees); + result.TurnoverVisuals = BuildTurnoverVisuals(employees, leavers, analysisYear); + 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(), + Value = g.Sum(x => x.Fte) + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .Take(12) + .ToList(); + result.CriticalTimeBalances = employees + .OrderByDescending(x => Math.Abs(x.StundenSaldo)) + .Take(25) + .ToList(); + result.FluctuationRelevantLeavers = leavers + .Where(x => x.IstFluktuationsrelevant) + .OrderByDescending(x => x.Austrittsdatum) + .Take(25) + .ToList(); + + if (employees.Count == 0) + result.Notices.Add("Keine aktiven Mitarbeitenden geladen. Pruefe Saldiperstichdatum.xlsx und die Filter."); + var missingEmployeeNumberCount = employees.Count(x => !x.Personalnummer.HasValue); + if (missingEmployeeNumberCount > 0) + result.Notices.Add($"{missingEmployeeNumberCount:N0} aktive Mitarbeitendenzeilen ohne Personalnummer werden in Headcount-Distinct-Kennzahlen nicht mitgezaehlt."); + 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 (!context.HasFile(MainFile)) + result.Notices.Add("Hauptdatei fehlt: Saldiperstichdatum.xlsx. Ohne diese Datei sind keine HR-KPIs moeglich."); + if (!context.HasFile(SapFile)) + result.Notices.Add("SAP-Datei HR_KPI_Export.xlsx fehlt. SAP-only Felder wie Geschlecht, Beschaeftigungsgrad, BU/NBU und Planstelle bleiben leer."); + if (!context.HasFile(AbsenceFile)) + result.Notices.Add("Rexx-Absenzen fehlen. Absenzquote und Krankheitstage bleiben 0."); + if (!context.HasFile(LeaverFile)) + result.Notices.Add("Rexx-Austritte fehlen. Fluktuationskennzahlen bleiben 0."); + + return Task.FromResult(result); + } + + private static List LoadEmployees( + ImportContext context, + IReadOnlyDictionary timeRows, + IReadOnlyDictionary sapRows) + { + return context.ReadRows(MainFile, "Rexx #757 Saldi", (row, headers) => + { + var personalnummer = ReadInt(row, headers, "Personalnummer"); + var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Rexx"); + var key = BuildPersonalKey(personalnummer); + timeRows.TryGetValue(NormalizeKey(name), out var time); + if (!sapRows.TryGetValue(key, out var sap) && personalnummer.HasValue) + sapRows.TryGetValue(personalnummer.Value.ToString(CultureInfo.InvariantCulture), out sap); + + var entryDate = ReadDate(row, headers, "Eintrittsdatum"); + var birthDate = time?.Geburtsdatum; + var status = ReadString(row, headers, "Personal Status", "Personal_Status"); + var rawBalance = ReadString(row, headers, "Stunden Saldo", "Stunden_Saldo_Raw"); + var balance = ParseTimeBalance(rawBalance); + var percent = sap?.BeschaeftigungsgradProzent; + var arbeitzeitmodell = time?.Arbeitszeitmodell ?? string.Empty; + var fte = ResolveFte(percent, arbeitzeitmodell, time?.AvgSollzeitTag); + + var nameParts = SplitName(name); + var urlaubsanspruch = ReadDecimal(row, headers, "Urlaubsanspruch", "Urlaubsanspruch_Raw"); + var urlaubRest = ReadDecimal(row, headers, "Urlaub Rest", "Urlaub_Rest_Raw"); + var ferienAusstehend = ReadDecimal(row, headers, "Ferien ausstehend (Tage)", "Ferien_Ausstehend_Raw"); + var ferienBezogen = urlaubsanspruch - urlaubRest - ferienAusstehend; + + return new HrKpiEmployeeRow + { + Personalnummer = personalnummer, + NameVoll = name, + Vorname = nameParts.Vorname, + Nachname = nameParts.Nachname, + Organisationseinheit = ReadString(row, headers, "Organisation", "Organisation_Text"), + KostenstelleText = ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx"), + Kostenstelle = ParseCostCenter(ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx")), + Stelle = ReadString(row, headers, "Stelle", "Stelle_Rexx"), + Leitung = ReadString(row, headers, "Leitung j/n", "Leitung"), + Eintrittsdatum = entryDate, + Geburtsdatum = birthDate, + AlterJahre = YearsSince(birthDate), + Altersgruppe = BuildAgeGroup(YearsSince(birthDate)), + GeschlechtText = MapGender(sap?.Geschlecht), + BeschaeftigungsgradProzent = percent, + Fte = fte, + IstTeilzeit = percent.HasValue && percent.Value > 0 + ? percent.Value < 100 + : string.Equals(arbeitzeitmodell, "Teilzeit", StringComparison.OrdinalIgnoreCase), + Dienstjahre = YearsSince(entryDate), + IstAktiv = string.Equals(status, "Aktiv", StringComparison.OrdinalIgnoreCase), + Mitarbeitertyp = BuildEmployeeType(ReadString(row, headers, "Stelle", "Stelle_Rexx")), + StundenSaldo = balance, + GlzAmpel = BuildTrafficLight(balance), + UrlaubRest = urlaubRest, + Urlaubsanspruch = urlaubsanspruch, + FerienAusstehend = ferienAusstehend, + Ferientage = ferienBezogen < 0 ? 0 : ferienBezogen, + RestferienAmpel = urlaubRest <= 5 ? "Gruen" : "Rot", + Bruttolohn = ReadDecimal(row, headers, "Lohn", "Lohn_Raw"), + LohnWaehrung = ReadString(row, headers, "Lohn Waehrung", "Lohn Währung"), + BuTage = sap?.BuTage ?? 0, + NbuTage = sap?.NbuTage ?? 0, + Buchungskreis = sap?.Buchungskreis ?? string.Empty, + Personalbereich = sap?.Personalbereich ?? string.Empty, + Personalteilbereich = sap?.Personalteilbereich ?? string.Empty, + Mitarbeitergruppe = sap?.Mitarbeitergruppe ?? string.Empty, + Mitarbeiterkreis = sap?.Mitarbeiterkreis ?? string.Empty, + Planstelle = sap?.Planstelle ?? string.Empty, + SollStelle = sap?.SollStelle ?? string.Empty, + Periode = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1) + }; + }) + .Where(x => x.IstAktiv) + .OrderBy(x => x.Personalnummer ?? int.MaxValue) + .ToList(); + } + + private static Dictionary LoadTimeRows(ImportContext context) + { + var rows = context.ReadRows(TimeFile, "Rexx #732 Kommen/Gehen", (row, headers) => + { + var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)"); + return new TimeRow( + NormalizeKey(name), + ReadDate(row, headers, "Geburtsdatum"), + ReadString(row, headers, "Arbeitszeitmodell"), + ReadDecimal(row, headers, "O taegliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)")); + }); + + return rows + .Where(x => !string.IsNullOrWhiteSpace(x.NameKey)) + .GroupBy(x => x.NameKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + } + + private static Dictionary LoadSapRows(ImportContext context) + { + var rows = context.ReadRows(SapFile, "SAP HR KPI", (row, headers) => + { + var pernr = ReadInt(row, headers, "Personalnummer"); + return new SapRow( + BuildPersonalKey(pernr), + ReadString(row, headers, "Buchungskreis"), + ReadString(row, headers, "Personalbereich"), + ReadString(row, headers, "Personalteilbereich"), + ReadString(row, headers, "Mitarbeitergruppe"), + ReadString(row, headers, "Mitarbeiterkreis"), + ReadString(row, headers, "Teilzeitkraft", "Teilzeitkennzeichen"), + ReadDecimalNullable(row, headers, "Beschaeftigungsgrad %", "Beschäftigungsgrad %", "Beschäftigungsgrad %", "Beschaeftigungsgrad_Prozent"), + ReadInt(row, headers, "Geschlecht"), + ReadString(row, headers, "Planstelle"), + ReadString(row, headers, "Stellenschluessel", "Stellenschlüssel", "Stellenschlüssel", "Soll_Stelle"), + ReadDecimal(row, headers, "Nichtberufsunfall Tage", "NBU_Tage"), + ReadDecimal(row, headers, "Berufsunfall Tage", "BU_Tage"), + ReadString(row, headers, "Abrechnungskreis")); + }); + + return rows + .Where(x => !string.IsNullOrWhiteSpace(x.PersonalKey)) + .GroupBy(x => x.PersonalKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + } + + private static List LoadAbsences(ImportContext context) + { + return context.ReadRows(AbsenceFile, "Rexx #744 Absenzen", (row, headers) => + { + var kurz = ReadDecimal(row, headers, "Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std"); + var lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std"); + var gesamt = kurz + lang; + var tage = Math.Round(gesamt / 8.4m, 1); + return new HrAbsenceRow + { + Personalnummer = ReadInt(row, headers, "Personalnummer"), + Name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name"), + Organisationseinheit = ReadString(row, headers, "Organisation"), + Stelle = ReadString(row, headers, "Stelle"), + Status = ReadString(row, headers, "Personal Status", "Status"), + KrankheitKurzStd = kurz, + KrankheitLangStd = lang, + KrankheitGesamtStd = gesamt, + KrankheitstageGesamt = tage, + KrankheitstageKurz = Math.Round(kurz / 8.4m, 1), + KrankheitstageLang = Math.Round(lang / 8.4m, 1), + KrankenquoteMa = tage == 0 ? 0 : tage / 21m + }; + }) + .Where(x => string.Equals(x.Status, "Aktiv", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static List LoadLeavers(ImportContext context) + { + return context.ReadRows(LeaverFile, "Rexx #381 Ausgeschieden", (row, headers) => + { + var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Voll"); + var nameParts = SplitName(name); + var austritt = ReadDate(row, headers, "Austrittsdatum"); + var eintritt = ReadDate(row, headers, "Eintrittsdatum"); + var stelle = ReadString(row, headers, "Stelle-1", "Stelle"); + var type = BuildEmployeeType(stelle); + var reason = ReadString(row, headers, "Austrittsart"); + var normalizedReason = NormalizeReason(reason); + var isEmployeeResignation = + normalizedReason.Contains("arbeitnehmer", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("mitarbeiter", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("kuendigung an", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("an kuendigung", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("eigenkuendigung", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("kuendigung ma", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("kuendigung durch ma", StringComparison.OrdinalIgnoreCase); + var isExcluded = + !string.Equals(type, "Festangestellt", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("befrist", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("pension", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("rente", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("trafag", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase); + var isRelevant = isEmployeeResignation && !isExcluded; + + return new HrLeaverRow + { + Personalnummer = ReadInt(row, headers, "Personalnummer"), + NameVoll = name, + Vorname = nameParts.Vorname, + Nachname = nameParts.Nachname, + Organisationseinheit = ReadString(row, headers, "Organisation-1", "Organisationseinheit"), + Stelle = stelle, + Status = ReadString(row, headers, "Personal Status", "Status"), + Austrittsdatum = austritt, + Eintrittsdatum = eintritt, + VerweildauerMonate = austritt.HasValue && eintritt.HasValue + ? Math.Round((decimal)(austritt.Value - eintritt.Value).TotalDays / 30.44m, 1) + : null, + Austrittsart = reason, + AustrittsartNormalisiert = normalizedReason, + Mitarbeitertyp = type, + IstArbeitnehmerkuendigung = isEmployeeResignation, + IstFluktuationAusgeschlossen = isExcluded, + IstFluktuationsrelevant = isRelevant, + FluktuationAusschlussgrund = isRelevant ? null : BuildExclusionReason(type, normalizedReason, isEmployeeResignation), + Austrittsmonat = austritt.HasValue ? new DateTime(austritt.Value.Year, austritt.Value.Month, 1) : null, + Austrittsjahr = austritt?.Year + }; + }) + .ToList(); + } + + private static IEnumerable ApplyEmployeeFilters(IEnumerable rows, HrKpiOptions options) + => rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && + MatchesFilter(x.KostenstelleText, options.KostenstelleText) && + MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && + MatchesFilter(x.GlzAmpel, options.GlzAmpel) && + MatchesFilter(x.RestferienAmpel, options.RestferienAmpel) && + (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && + MatchesEmployeeSearch(x, options.SearchText)); + + private static IEnumerable ApplyAbsenceFilters( + IEnumerable rows, + HrKpiOptions options, + IReadOnlySet filteredEmployeeNumbers) + => rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && + x.Personalnummer.HasValue && + filteredEmployeeNumbers.Contains(x.Personalnummer.Value) && + MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); + + private static IEnumerable ApplyLeaverFilters(IEnumerable rows, HrKpiOptions options) + => rows.Where(x => MatchesLeaverDateFilter(x, options) && + MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && + MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && + MatchesFluctuationFilter(x, options.FluktuationFilter) && + MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); + + private static List BuildOverviewMetrics( + IReadOnlyCollection employees, + IReadOnlyCollection absences, + IReadOnlyCollection leavers, + int year) + { + var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer)); + var fixedCount = CountDistinctPersons(employees + .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Personalnummer)); + var fte = employees.Sum(x => x.Fte); + var sickDays = absences.Sum(x => x.KrankheitstageGesamt); + var absenceRate = activeCount == 0 ? 0 : sickDays / (activeCount * 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 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 = "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 = "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" } + ]; + } + + private static List BuildTurnoverMetrics( + IReadOnlyCollection employees, + IReadOnlyCollection leavers, + int year, + DateTime anchorDate) + { + var fixedHeadcount = CountDistinctPersons(employees + .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Personalnummer)); + var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer)); + var employeeResignations = leavers + .Where(x => x.IstArbeitnehmerkuendigung) + .Select(x => x.Personalnummer) + .ToList(); + var relevantLeavers = leavers + .Where(x => x.IstFluktuationsrelevant) + .Select(x => x.Personalnummer) + .ToList(); + var nonRelevantLeavers = leavers + .Where(x => !x.IstFluktuationsrelevant) + .Select(x => x.Personalnummer) + .ToList(); + var employeeResignationCount = CountDistinctPersons(employeeResignations); + var relevantLeaverCount = CountDistinctPersons(relevantLeavers); + var nonRelevantLeaverCount = CountDistinctPersons(nonRelevantLeavers); + + var currentMonth = anchorDate.Month; + var currentQuarter = ((currentMonth - 1) / 3) + 1; + var quarterLeavers = leavers + .Where(x => x.IstFluktuationsrelevant && + x.Austrittsdatum.HasValue && + x.Austrittsdatum.Value.Year == year && + ((x.Austrittsdatum.Value.Month - 1) / 3) + 1 == currentQuarter) + .Select(x => x.Personalnummer) + .ToList(); + var monthLeavers = leavers + .Where(x => x.IstFluktuationsrelevant && + x.Austrittsdatum.HasValue && + x.Austrittsdatum.Value.Year == year && + x.Austrittsdatum.Value.Month == currentMonth) + .Select(x => x.Personalnummer) + .ToList(); + var yearLeavers = leavers + .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == year) + .Select(x => x.Personalnummer) + .ToList(); + var quarterLeaverCount = CountDistinctPersons(quarterLeavers); + 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 forecastRate = quarterRate * 4; + var yearRate = fixedHeadcount == 0 ? 0 : yearLeaverCount / (decimal)fixedHeadcount; + + return + [ + 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 = "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 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 = "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" } + ]; + } + + private static List BuildAbsenceMetrics( + IReadOnlyCollection employees, + IReadOnlyCollection 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 bu = employees.Sum(x => x.BuTage); + var nbu = employees.Sum(x => x.NbuTage); + + return + [ + 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 = "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" } + ]; + } + + private static List BuildTimeVacationMetrics(IReadOnlyCollection employees) + { + var headcount = employees.Count; + var avgBalance = headcount == 0 ? 0 : employees.Average(x => x.StundenSaldo); + var red = employees.Count(x => x.GlzAmpel == "Rot"); + var yellow = employees.Count(x => x.GlzAmpel == "Gelb"); + var vacationEntitlement = employees.Sum(x => x.Urlaubsanspruch); + var vacationUsed = employees.Sum(x => x.Ferientage); + var vacationLeft = employees.Sum(x => x.UrlaubRest); + var vacationOpen = employees.Sum(x => x.FerienAusstehend); + var restVacationRed = employees.Count(x => x.RestferienAmpel == "Rot"); + + return + [ + new() { Label = "GLZ-Saldo Schnitt", Value = avgBalance.ToString("N1"), Detail = "Stunden pro Mitarbeiter", Severity = Math.Abs(avgBalance) > 50 ? "Warning" : "Normal" }, + new() { Label = "GLZ Gelb", Value = yellow.ToString("N0"), Detail = "51-100h absolut", Severity = yellow > 0 ? "Warning" : "Normal" }, + new() { Label = "GLZ Rot", Value = red.ToString("N0"), Detail = ">100h absolut", Severity = red > 0 ? "Warning" : "Normal" }, + new() { Label = "Ferienanspruch", Value = vacationEntitlement.ToString("N1"), Detail = "Summe Tage", Severity = "Normal" }, + new() { Label = "Ferien bezogen", Value = vacationUsed.ToString("N1"), Detail = "Anspruch - Rest - ausstehend", Severity = "Normal" }, + new() { Label = "Ferien Rest", Value = vacationLeft.ToString("N1"), Detail = "Rexx Urlaub Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" }, + new() { Label = "Ferien ausstehend", Value = vacationOpen.ToString("N1"), Detail = "Rexx ausstehend", Severity = "Normal" }, + new() { Label = "Restferien Rot", Value = restVacationRed.ToString("N0"), Detail = ">5 Tage Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" } + ]; + } + + private static HrTurnoverVisuals BuildTurnoverVisuals( + IReadOnlyCollection employees, + IReadOnlyCollection leavers, + int year) + { + var fixedHeadcount = CountDistinctPersons(employees + .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Personalnummer)); + 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 gaugeColor = ratePercent > 12m ? "#c62828" : ratePercent >= 8m ? "#f9a825" : "#2e7d32"; + + var maxFunnel = Math.Max(totalLeavers, 1); + var reasonColors = new[] { "#455a64", "#7b1fa2", "#0277bd", "#ef6c00", "#8d6e63", "#ad1457", "#558b2f" }; + var reasons = leavers + .GroupBy(x => x.FluktuationAusschlussgrund ?? "Fluktuationsrelevant", StringComparer.OrdinalIgnoreCase) + .Select((g, index) => new HrKpiGroupValue + { + Label = g.Key, + Count = g.Count(), + Value = g.Count(), + Percent = totalLeavers == 0 ? 0 : g.Count() / (decimal)totalLeavers * 100m, + Color = reasonColors[index % reasonColors.Length] + }) + .OrderByDescending(x => x.Count) + .ToList(); + + var relevantByOrg = leavers + .Where(x => x.IstFluktuationsrelevant) + .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) + .Select(g => new HrKpiGroupValue + { + Label = g.Key, + Count = g.Count(), + Value = g.Count(), + Percent = relevantLeavers == 0 ? 0 : g.Count() / (decimal)relevantLeavers * 100m, + Color = "#1565c0" + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(); + + var monthly = 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); + return new HrKpiGroupValue + { + Label = CultureInfo.GetCultureInfo("de-CH").DateTimeFormat.GetAbbreviatedMonthName(month), + Count = count, + Value = count, + Percent = relevantLeavers == 0 ? 0 : count / (decimal)relevantLeavers * 100m, + Color = "#00897b" + }; + }) + .ToList(); + + return new HrTurnoverVisuals + { + 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 + }; + } + + 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 DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int analysisYear) + { + if (options.ToDate.HasValue) + return options.ToDate.Value.Date; + if (options.FromDate.HasValue) + return options.FromDate.Value.Date; + return analysisYear == DateTime.Today.Year + ? DateTime.Today + : new DateTime(analysisYear, 12, 31); + } + + private static bool MatchesLeaverDateFilter(HrLeaverRow row, HrKpiOptions options) + { + var hasRange = options.FromDate.HasValue || options.ToDate.HasValue; + if (hasRange) + { + if (!row.Austrittsdatum.HasValue) + return false; + return (!options.FromDate.HasValue || row.Austrittsdatum.Value.Date >= options.FromDate.Value) && + (!options.ToDate.HasValue || row.Austrittsdatum.Value.Date <= options.ToDate.Value); + } + + return row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year; + } + + private static int CountDistinctPersons(IEnumerable personalNumbers) + => personalNumbers + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .Count(); + + private static decimal ResolveFte(decimal? employmentPercent, string workingTimeModel, decimal? averageHoursPerDay) + { + if (employmentPercent.HasValue && employmentPercent.Value > 0) + return employmentPercent.Value / 100m; + + if (averageHoursPerDay.HasValue && averageHoursPerDay.Value > 0) + return Math.Clamp(averageHoursPerDay.Value / 8.4m, 0.1m, 1.2m); + + if (string.Equals(workingTimeModel, "Vollzeit", StringComparison.OrdinalIgnoreCase)) + return 1m; + + if (string.Equals(workingTimeModel, "Teilzeit", StringComparison.OrdinalIgnoreCase)) + return 0.5m; + + return 0m; + } + + private static bool MatchesFilter(string value, string? filter) + => string.IsNullOrWhiteSpace(filter) || string.Equals(value?.Trim(), filter, StringComparison.OrdinalIgnoreCase); + + private static bool MatchesEmployeeSearch(HrKpiEmployeeRow row, string? search) + => MatchesTextSearch(search, row.NameVoll, row.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, row.Organisationseinheit, row.KostenstelleText, row.Stelle); + + private static bool MatchesTextSearch(string? search, params string[] values) + { + if (string.IsNullOrWhiteSpace(search)) + return true; + return values.Any(value => value.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesFluctuationFilter(HrLeaverRow row, string? filter) + { + if (string.IsNullOrWhiteSpace(filter) || string.Equals(filter, "Alle", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(filter, "Fluktuationsrelevant", StringComparison.OrdinalIgnoreCase)) + return row.IstFluktuationsrelevant; + if (string.Equals(filter, "Arbeitnehmerkuendigung", StringComparison.OrdinalIgnoreCase)) + return row.IstArbeitnehmerkuendigung; + if (string.Equals(filter, "Ausgeschlossen", StringComparison.OrdinalIgnoreCase)) + return !row.IstFluktuationsrelevant; + return true; + } + + private static string BlankAsUnknown(string value) + => string.IsNullOrWhiteSpace(value) ? "Unbekannt" : value; + + private static string BuildPersonalKey(int? personalnummer) + => personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + + private static string NormalizeKey(string value) + => value.Trim().ToUpperInvariant(); + + private static int? ParseCostCenter(string value) + { + var raw = value.Split('/')[0].Trim(); + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + } + + private static (string Nachname, string Vorname) SplitName(string value) + { + var parts = value.Split(',', 2, StringSplitOptions.TrimEntries); + return parts.Length == 2 ? (parts[0], parts[1]) : (value.Trim(), string.Empty); + } + + private static int? YearsSince(DateTime? date) + { + if (!date.HasValue) + return null; + var today = DateTime.Today; + var years = today.Year - date.Value.Year; + if (date.Value.Date > today.AddYears(-years)) + years--; + return years; + } + + private static string BuildAgeGroup(int? age) + { + if (!age.HasValue) return "Unbekannt"; + if (age.Value < 30) return "< 30"; + if (age.Value < 40) return "30-39"; + if (age.Value < 50) return "40-49"; + if (age.Value < 60) return "50-59"; + return "60+"; + } + + private static string MapGender(int? value) + => value switch + { + 1 => "Maennlich", + 2 => "Weiblich", + _ => "Unbekannt" + }; + + private static decimal ParseTimeBalance(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return 0; + var trimmed = value.Trim(); + var negative = trimmed.StartsWith("-", StringComparison.Ordinal); + trimmed = trimmed.TrimStart('-'); + var parts = trimmed.Split(':'); + if (parts.Length == 0) + return 0; + var hours = ParseDecimal(parts[0]); + var minutes = parts.Length > 1 ? ParseDecimal(parts[1]) : 0; + var result = hours + minutes / 60m; + return negative ? -result : result; + } + + private static string BuildTrafficLight(decimal balance) + { + var absolute = Math.Abs(balance); + if (absolute <= 50) return "Gruen"; + return absolute <= 100 ? "Gelb" : "Rot"; + } + + private static string BuildEmployeeType(string position) + { + var lower = NormalizeReason(position); + if (lower.Contains("praktik", StringComparison.OrdinalIgnoreCase)) return "Praktikant"; + if (lower.Contains("werkstudent", StringComparison.OrdinalIgnoreCase)) return "Werkstudent"; + if (lower.Contains("aushilfe", StringComparison.OrdinalIgnoreCase)) return "Aushilfe"; + if (lower.Contains("lehrling", StringComparison.OrdinalIgnoreCase)) return "Lehrling"; + return "Festangestellt"; + } + + private static string NormalizeReason(string value) + { + var normalized = RemoveDiacritics(value).Trim().ToLowerInvariant(); + return normalized + .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) + .Replace("ß", "ss", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildExclusionReason(string employeeType, string reason, bool isEmployeeResignation) + { + 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("trafag", StringComparison.OrdinalIgnoreCase) || + reason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || + reason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || + reason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) || + reason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase)) return "Kuendigung durch Trafag"; + return isEmployeeResignation ? "Ausgeschlossen" : "Keine Arbeitnehmerkuendigung"; + } + + private static string ReadString(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) + { + var index = FindHeader(headers, aliases); + return index.HasValue ? row.Cell(index.Value).GetFormattedString().Trim() : string.Empty; + } + + private static int? ReadInt(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) + { + var value = ReadString(row, headers, aliases); + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + return parsed; + var decimalValue = ParseDecimalNullable(value); + return decimalValue.HasValue ? (int)Math.Truncate(decimalValue.Value) : null; + } + + private static decimal ReadDecimal(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) + => ReadDecimalNullable(row, headers, aliases) ?? 0; + + private static decimal? ReadDecimalNullable(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) + { + var index = FindHeader(headers, aliases); + if (!index.HasValue) + return null; + var cell = row.Cell(index.Value); + if (cell.TryGetValue(out var decimalValue)) + return decimalValue; + if (cell.TryGetValue(out var doubleValue)) + return (decimal)doubleValue; + return ParseDecimalNullable(cell.GetFormattedString()); + } + + private static DateTime? ReadDate(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) + { + var index = FindHeader(headers, aliases); + if (!index.HasValue) + return null; + var cell = row.Cell(index.Value); + if (cell.TryGetValue(out var dateValue)) + return dateValue.Date; + if (cell.TryGetValue(out var serialValue)) + return DateTime.FromOADate(serialValue).Date; + var value = cell.GetFormattedString().Trim(); + if (DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.None, out var parsed) || + DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out parsed) || + DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + return parsed.Date; + if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out serialValue)) + return DateTime.FromOADate(serialValue).Date; + return null; + } + + private static int? FindHeader(IReadOnlyDictionary headers, params string[] aliases) + { + foreach (var alias in aliases) + { + if (headers.TryGetValue(NormalizeHeader(alias), out var index)) + return index; + } + + return null; + } + + private static decimal ParseDecimal(string value) + => ParseDecimalNullable(value) ?? 0; + + private static decimal? ParseDecimalNullable(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var normalized = value.Trim() + .Replace("'", "", StringComparison.Ordinal) + .Replace(" ", "", StringComparison.Ordinal); + if (decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-CH"), out var result) || + decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out result) || + decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out result)) + return result; + return null; + } + + private static string NormalizeHeader(string value) + { + var normalized = RemoveDiacritics(value) + .Replace("ü", "u", StringComparison.OrdinalIgnoreCase) + .Replace("ä", "a", StringComparison.OrdinalIgnoreCase) + .Replace("ö", "o", StringComparison.OrdinalIgnoreCase) + .Replace("Ø", "o", StringComparison.OrdinalIgnoreCase) + .Replace("ø", "o", StringComparison.OrdinalIgnoreCase) + .Replace("Ø", "o", StringComparison.OrdinalIgnoreCase); + var builder = new StringBuilder(normalized.Length); + foreach (var ch in normalized.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) + builder.Append(ch); + } + return builder.ToString(); + } + + private static string RemoveDiacritics(string value) + { + var normalized = value.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(normalized.Length); + foreach (var ch in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) + builder.Append(ch); + } + return builder.ToString().Normalize(NormalizationForm.FormC); + } + + private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag); + + private sealed record SapRow( + string PersonalKey, + string Buchungskreis, + string Personalbereich, + string Personalteilbereich, + string Mitarbeitergruppe, + string Mitarbeiterkreis, + string Teilzeitkennzeichen, + decimal? BeschaeftigungsgradProzent, + int? Geschlecht, + string Planstelle, + string SollStelle, + decimal NbuTage, + decimal BuTage, + string Abrechnungskreis); + + private sealed class ImportContext + { + private readonly HrKpiResult _result; + private readonly string _folder; + + public ImportContext(HrKpiResult result, string folder) + { + _result = result; + _folder = folder; + } + + public bool HasFile(string fileName) + => File.Exists(BuildPath(fileName)); + + public List ReadRows(string fileName, string label, Func, T> map) + { + var path = BuildPath(fileName); + var status = new HrKpiFileStatus + { + Label = label, + Path = path, + Exists = File.Exists(path) + }; + _result.FileStatuses.Add(status); + + if (!status.Exists) + { + status.Message = "Datei nicht gefunden"; + return []; + } + + try + { + using var workbook = new XLWorkbook(path); + var worksheet = workbook.Worksheets.First(); + var headerRow = worksheet.FirstRowUsed(); + if (headerRow is null) + { + status.Message = "Leeres Arbeitsblatt"; + return []; + } + + var headers = headerRow.CellsUsed() + .GroupBy(c => NormalizeHeader(c.GetString())) + .Where(g => !string.IsNullOrWhiteSpace(g.Key)) + .ToDictionary(g => g.Key, g => g.First().Address.ColumnNumber, StringComparer.OrdinalIgnoreCase); + + var rows = worksheet.RowsUsed() + .Where(r => r.RowNumber() > headerRow.RowNumber()) + .Where(r => !r.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetFormattedString()))) + .Select(r => map(r, headers)) + .ToList(); + + status.RowCount = rows.Count; + status.Message = "OK"; + return rows; + } + catch (Exception ex) + { + status.Message = ex.Message; + _result.Notices.Add($"{label}: {ex.Message}"); + return []; + } + } + + private string BuildPath(string fileName) + => Path.Combine(_folder, fileName); + } +} diff --git a/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.docx b/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.docx new file mode 100644 index 0000000000000000000000000000000000000000..420db6e553018ac70fdd03bea6818255a5236776 GIT binary patch literal 6501 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 GfBhGCmFCC* literal 0 HcmV?d00001 diff --git a/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md b/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md new file mode 100644 index 0000000..abbd097 --- /dev/null +++ b/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md @@ -0,0 +1,315 @@ +# HR-KPI-Pruefung gegen Schweizer Praxis und HR-Best-Practices + +Stand: 2026-05-13 + +Zweck dieses Dokuments: + +- fachliche Pruefpunkte fuer den neuen Reiter `HR KPI` sammeln +- keine Codeaenderung ausloesen +- sichtbar machen, welche Kennzahlen bereits plausibel sind und wo vor produktiver Nutzung noch HR-/Fachentscheid noetig ist + +## Quellen und Massstab + +Verwendeter Massstab: + +- Schweizer Absenzverstaendnis gemaess BFS/AVOL: Absenzen sind Zeiten, in denen eine Person normalerweise haette arbeiten muessen, aber nicht gearbeitet hat. Ferien/Feiertage und flexible Arbeitszeitreduktionen sind keine Absenzen. +- Obsan/BFS-Definition fuer gesundheitsbedingte Absenzen: Krankheit und Unfall; Absenzenquote = Absenzen als Prozent der vertraglich festgelegten Jahresarbeitszeit. +- Internationale HR-Controlling-Praxis fuer Fluktuation: Austritte im Zeitraum geteilt durch durchschnittlichen Headcount im Zeitraum. Freiwillige und unfreiwillige Austritte sollten getrennt ausgewiesen werden. + +Referenzen: + +- BFS Arbeitsvolumenstatistik / Definitionen: https://www.bfs.admin.ch/bfs/de/home/statistiken/arbeit-erwerb/erhebungen/avol.html +- Obsan/BFS Absenzen Krankheit/Unfall: https://ind.obsan.admin.ch/de/indicator/pflemo/absenzen-durch-krankheitunfall +- BAG/Obsan MonAM Absenzen Krankheit/Unfall: https://ind.obsan.admin.ch/fr/indicator/monam/absences-au-travail-pour-cause-de-maladie-ou-d-accident-age-15 +- CIPD Retention/Turnover Guidance: https://www.cipd.org/en/knowledge/guides/employee-retention/ +- SHRM-nahe Turnover-Formel, oeffentlich referenziert: Separations / Average Employees * 100 + +## Aktueller Umsetzungsstand im Reiter + +Der Reiter liest aktuell: + +- `C:\temp\Saldiperstichdatum.xlsx` als Hauptquelle Rexx #757 +- `C:\temp\Exportkommengehen.xlsx` fuer Geburtsdatum / Arbeitszeitmodell +- `C:\temp\HR_KPI_Export.xlsx` fuer SAP-Felder +- `C:\temp\Abwesenheitinstunden.xlsx` fuer Krankheit/Absenzen aus Rexx #744 +- `C:\temp\Personalausgeschieden.xlsx` fuer Austritte/Fluktuation aus Rexx #381 + +Die Power-Query-/DAX-Logik wurde nicht als Interpreter umgesetzt, sondern als C#-Nachbau. + +## Pruefpunkte mit moeglicher Abweichung + +### 1. Fluktuationsnenner: Stichtags-Headcount statt Durchschnitt + +Aktueller Reiter: + +- `Headcount Festangestellt` wird aus dem aktuell geladenen Stichtagsbestand gerechnet. +- `Avg Headcount Quartal` und `Avg Headcount Jahr` entsprechen aktuell faktisch ebenfalls diesem Stichtagswert. + +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. + +Pruefen: + +- Liefert Rexx/SAP monatliche Headcount-Snapshots? +- Falls ja: Monatsdurchschnitt fuer Quartal/Jahr berechnen. +- Falls nein: UI klar als `Stichtagsnahe Fluktuation` oder `Naeherung` beschriften. + +Status: + +- fachlich akzeptabel als erste Naeherung +- fuer offizielles HR-Reporting noch zu bestaetigen + +### 2. Freiwillige vs. unfreiwillige Austritte + +Aktueller Reiter: + +- `Ist_Arbeitnehmerkuendigung` versucht freiwillige Arbeitnehmer-/Mitarbeiterkuendigungen anhand Textmustern zu erkennen. +- Praktikanten, Werkstudenten, Aushilfen, Lehrlinge, Pensionierungen, befristete Vertraege und Kuendigungen durch Trafag werden ausgeschlossen. + +Best Practice: + +- Total Turnover und Voluntary Turnover getrennt ausweisen. +- Fuer Retention ist freiwillige Fluktuation meist entscheidender als Gesamtaustritte. + +Pruefen: + +- Sind alle Rexx-Austrittsarten stabil und vollstaendig gemappt? +- Gibt es lokale Schreibweisen wie `Kdg AN`, `Eigenkuendigung`, `Aufhebungsvereinbarung`, `Mutual agreement`, `Ende Probezeit`? +- Soll `Aufhebungsvereinbarung` zaehlen oder separat ausgewiesen werden? + +Status: + +- HR-gepruefte Grundlogik vorhanden +- Mappingliste muss bei neuen Austrittsarten gepflegt/validiert werden + +### 3. Fluktuation Quartal/Jahr bei nur einem aktuellen Bestand + +Aktueller Reiter: + +- Quartals-/Jahresraten werden ueber Austrittsdatum gefiltert. +- Headcount bleibt aktueller Stichtagsbestand. + +Risiko: + +- Wenn der aktuelle Bestand z. B. Ende Jahr niedriger/hoeher ist als im Quartal, verzerrt das die historische Rate. + +Pruefen: + +- Fuer Quartal/Jahr entweder echte historische Headcounts laden oder die Kennzahl explizit als operative Naeherung fuehren. + +Status: + +- Darstellung gut fuer operatives Cockpit +- nicht automatisch als auditierbare Jahreskennzahl verwenden + +### 4. Absenzenquote: 21 Arbeitstage pauschal + +Aktueller Reiter: + +- Krankheitstage = Stunden / 8.4 +- Krankenquote je Mitarbeiter = Krankheitstage / 21 +- Gesamtquote = Krankheitstage / (Headcount * 21) + +Schweizer/BFS-nahe Praxis: + +- Absenzenquote wird als Dauer der Absenzen in Prozent der vertraglich festgelegten Arbeitszeit berechnet. +- Bei Teilzeit und unterschiedlichen Sollzeiten sollte der Nenner aus Sollarbeitszeit/Solltagen kommen. + +Pruefen: + +- Soll der Nenner pro Person aus `Avg_Sollzeit_Tag`, Arbeitszeitmodell oder Beschaeftigungsgrad berechnet werden? +- Fuer Teilzeit nicht pauschal 21 Vollzeittage verwenden, falls die Quote offiziell sein soll. +- Krankheit und Unfall separat ausweisen, wenn Datenquelle das erlaubt. + +Status: + +- 21-Tage-Naeherung gut fuer schnelle Sicht +- fuer Schweizer Standard-Absenzquote fachlich zu ungenau + +### 5. Krankheit kurz/lang Definition + +Aktueller Reiter: + +- `Krankheit angetreten` = kurz +- `Krank nicht buchbar angetreten` = lang +- Umrechnung pauschal Stunden / 8.4 + +Pruefen: + +- Bedeutet `Krank nicht buchbar` fachlich wirklich Langzeitkrankheit? +- Oder ist es ein Buchungs-/Workflowstatus? +- HR muss bestaetigen, ob diese Felder Kurz-/Langzeitkrankheit abbilden. + +Status: + +- benoetigt HR-/Rexx-Felddefinition + +### 6. Unfalltage aus SAP vs. Rexx-Absenzen + +Aktueller Reiter: + +- Krankheit kommt aus Rexx-Stunden. +- BU/NBU kommt aus SAP-HR-KPI-Datei. + +Pruefen: + +- Sind BU/NBU in SAP und Krankheit in Rexx zeitlich gleich abgegrenzt? +- Sind Unfalltage in den Rexx-Krankheitsstunden enthalten oder getrennt? +- Gibt es Doppelzaehlung, wenn Krankheit/Unfall spaeter zusammengefuehrt werden? + +Status: + +- getrennte Anzeige ist korrekt +- Gesamtabsenzquote aus Krankheit + Unfall erst nach Quellenabgleich bilden + +### 7. FTE-Berechnung + +Aktueller Reiter: + +- FTE = Beschaeftigungsgrad aus SAP / 100. +- Wenn SAP-Wert fehlt: Vollzeit = 1, sonst 0.5. + +Best Practice: + +- FTE sollte aus vertraglichem Beschaeftigungsgrad oder Sollarbeitszeit pro Person kommen. +- Pauschal 0.5 fuer Nicht-Vollzeit ist nur Fallback. + +Pruefen: + +- Ist `Beschaeftigungsgrad %` fuer alle aktiven Mitarbeitenden verfuegbar? +- Wenn nein: kann Rexx `Arbeitszeitmodell` oder Sollzeit genauer liefern? + +Status: + +- korrekt, wenn SAP-Datei vollstaendig ist +- Fallback fuer offizielle FTE zu grob + +### 8. GLZ-Ampel 50/100 Stunden + +Aktueller Reiter: + +- Gruen: absolut <= 50h +- Gelb: absolut <= 100h +- Rot: absolut > 100h + +Pruefen: + +- Sind diese Schwellen HR-/GL-/Reglement-konform? +- Soll negative GLZ gleich behandelt werden wie positive? +- Gibt es unterschiedliche Regeln fuer Teilzeit? + +Status: + +- als Management-Ampel plausibel +- Schwellen fachlich bestaetigen lassen + +### 9. Ferien-Rest-Ampel + +Aktueller Reiter: + +- Restferien <= 5 Tage = Gruen +- > 5 Tage = Rot + +Pruefen: + +- Ist >5 Tage wirklich kritisch oder nur zum Jahresende relevant? +- Soll der Stichtag im Jahr beruecksichtigt werden? +- Soll Anspruch, bezogen, ausstehend und Rest getrennt nach Kalenderjahr gezeigt werden? + +Status: + +- sehr grobe Ampel +- saisonale Logik fehlt + +### 10. Lohn / Datenschutz + +Aktueller Reiter: + +- Bruttolohn wird im Model geladen, aber aktuell nicht prominent als KPI angezeigt. + +Pruefen: + +- Darf Bruttolohn im HR-KPI-Reiter angezeigt werden? +- Falls ja: welche Rollen duerfen ihn sehen? +- Falls nein: Feld im UI konsequent ausblenden oder gar nicht laden. + +Status: + +- vor produktivem Einsatz mit Datenschutz/HR klaeren + +### 11. Altersgruppen / Geschlecht + +Aktueller Reiter: + +- Alter und Geschlecht werden berechnet/gemappt. +- Noch keine spezifischen Diversity-/Altersstruktur-Kacheln. + +Pruefen: + +- Soll Geschlecht nach Schweizer Datenschutz-/HR-Kontext im Cockpit sichtbar sein? +- Aggregiert ja/nein? +- Mindestgruppengroessen fuer Anzeige definieren, damit keine Einzelpersonen ableitbar sind. + +Status: + +- Daten vorhanden +- Anzeige/Datenschutz noch nicht entschieden + +### 12. Personalschluessel / Join-Qualitaet + +Aktueller Reiter: + +- Rexx #757 und SAP werden ueber Personalnummer verbunden. +- Rexx #732 wird ueber Name verbunden, weil keine Personalnummer vorhanden ist. + +Risiko: + +- Name-Join ist fehleranfaellig bei gleichen Namen, Namensaenderungen, Sonderzeichen oder Formatabweichungen. + +Pruefen: + +- Gibt es in #732 doch eine stabile ID? +- Falls nein: Join-Trefferquote anzeigen. +- Nicht gematchte Namen separat ausweisen. + +Status: + +- wichtigster technischer Qualitaetspruefpunkt + +## Empfohlene Mindestkontrollen vor produktiver Nutzung + +1. Kontrollwerte aus Power BI / HR gegen neuen Reiter vergleichen: + - `Austritte Total Rexx = 104` + - `Austritte Arbeitnehmerkuendigung = 42` + - `Austritte Fluktuationsrelevant = 33` +2. Headcount aktiv gegen Rexx/HR-Stichtagszahl vergleichen. +3. FTE-Summe gegen SAP/HR vergleichen. +4. Krankheitstage aus Rexx direkt gegen Export-Summe vergleichen. +5. BU/NBU-Tage gegen SAP-Datei summieren. +6. Stichprobe von mindestens 10 Mitarbeitenden pruefen: + - Personalnummer + - Organisation + - FTE + - GLZ + - Ferien Rest + - Krankheitstage +7. Join-Qualitaet dokumentieren: + - Anzahl Rexx-Hauptzeilen + - Anzahl SAP-Treffer + - Anzahl #732-Name-Treffer + - Anzahl nicht gematcht + +## Empfehlung fuer die naechste Umsetzung + +Noch keine Formel aendern, bevor die Kontrollwerte protokolliert sind. + +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. +