Add HR KPI cockpit

This commit is contained in:
2026-05-13 07:10:13 +02:00
parent 819a023163
commit 20be752adc
7 changed files with 2216 additions and 0 deletions
@@ -18,6 +18,9 @@
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics"> <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
@T("Management Cockpit", "Management Cockpit") @T("Management Cockpit", "Management Cockpit")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI", "HR KPI")
</MudNavLink>
<AuthorizeView Policy="@SecurityPolicies.AdminOnly"> <AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized> <Authorized>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings"> <MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@@ -0,0 +1,746 @@
@page "/hr-kpi"
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" />
</MudItem>
<MudItem xs="6" md="2">
<MudNumericField T="int" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Min="2000" Max="2100" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
@foreach (var option in _result?.OrganisationOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
</MudButton>
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
@foreach (var option in _result?.EntryYearOptions ?? [])
{
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
@foreach (var option in _result?.KostenstelleOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
@foreach (var option in _result?.MitarbeitertypOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
@foreach (var option in _fluktuationOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
@foreach (var option in _ampelOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
@foreach (var option in _restferienOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
</MudGrid>
</MudPaper>
@if (_result is not null)
{
@if (_result.Notices.Count > 0)
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
@foreach (var notice in _result.Notices)
{
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
}
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@MetricGrid(_result.Metrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HeadcountByOrganisationTable(_result.HeadcountByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@CriticalBalancesTable(_result.CriticalTimeBalances)
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
@MetricGrid(_result.TurnoverMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@TurnoverRelevantTable(_result.FluctuationRelevantLeavers)
</MudItem>
<MudItem xs="12" md="6">
@LeaverExclusionTable(_result.Leavers)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="4">
@TurnoverGauge(_result.TurnoverVisuals)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverFunnel(_result.TurnoverVisuals.FunnelSteps)
</MudItem>
<MudItem xs="12" md="4">
@TurnoverDonut(_result.TurnoverVisuals.ExclusionReasons)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@HorizontalBars(_result.TurnoverVisuals.RelevantByOrganisation)
</MudItem>
<MudItem xs="12" md="6">
@MonthlyBars(_result.TurnoverVisuals.MonthlyRelevantLeavers)
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@MetricGrid(_result.AbsenceMetrics)
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
<MudTable Items="_result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
<MudTh>@T("Quote", "Rate")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
<MudTd>@context.KrankenquoteMa.ToString("P1")</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
@MetricGrid(_result.TimeVacationMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@CriticalBalancesTable(_result.CriticalTimeBalances)
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische Restferien", "Critical vacation balance")</MudText>
<MudTable Items="_result.Employees.OrderByDescending(x => x.UrlaubRest).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Rest", "Left")</MudTh>
<MudTh>@T("Ausstehend", "Open")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.NameVoll</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.RestferienAmpel)" Variant="Variant.Outlined">
@context.RestferienAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
@EmployeesTable(_result.Employees)
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@FileStatusTable(_result.FileStatuses)
</MudTabPanel>
</MudTabs>
}
@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<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
private readonly List<string> _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<IReadOnlyList<HrKpiMetric>> MetricGrid => metrics => @<MudGrid Class="mb-4">
@foreach (var metric in metrics)
{
<MudItem xs="12" sm="6" md="3" lg="2">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@metric.Label</MudText>
<MudText Typo="Typo.h5">@metric.Value</MudText>
<MudText Typo="Typo.body2" Color="@MetricColor(metric.Severity)">@metric.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HeadcountByOrganisationTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Headcount nach Organisation", "Headcount by organisation")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Headcount", "Headcount")</MudTh>
<MudTh>FTE</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Value.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Saldo", "Balance")</MudTh>
<MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.NameVoll</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.GlzAmpel)" Variant="Variant.Outlined">
@context.GlzAmpel
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> TurnoverRelevantTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Fluktuationsrelevante Austritte", "Turnover relevant leavers")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Austritt", "Exit")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Austrittsart", "Exit type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.NameVoll</MudTd>
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.Austrittsart</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrLeaverRow>> LeaverExclusionTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<MudTable Items="BuildLeaverExclusionRows(items)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Grund", "Reason")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> EmployeesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Mitarbeitende", "Employees")</MudText>
<MudTable Items="items.Take(250)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kostenstelle", "Cost center")</MudTh>
<MudTh>FTE</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Dienstjahre", "Service years")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Personalnummer</MudTd>
<MudTd>@context.NameVoll</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KostenstelleText</MudTd>
<MudTd>@context.Fte.ToString("N2")</MudTd>
<MudTd>@context.AlterJahre</MudTd>
<MudTd>@context.Dienstjahre</MudTd>
<MudTd>@context.Mitarbeitertyp</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiFileStatus>> FileStatusTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Dateistatus", "File status")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="Typo.body2">@context.Label</MudText>
<MudText Typo="Typo.caption">@context.Path</MudText>
</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
@(context.Message ?? "-")
</MudChip>
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private static IEnumerable<HrKpiGroupValue> BuildLeaverExclusionRows(IReadOnlyList<HrLeaverRow> 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<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahres-Fluktuation", "Annual turnover")</MudText>
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
<div class="hr-gauge-track"></div>
<div class="hr-gauge-needle"></div>
<div class="hr-gauge-center">
<div class="hr-gauge-value">@visual.YearRateLabel</div>
<div class="hr-gauge-caption">0-20%</div>
</div>
</div>
<div class="hr-gauge-scale">
<span>0%</span>
<span>8%</span>
<span>12%</span>
<span>20%+</span>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverFunnel => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Austritts-Funnel", "Leaver funnel")</MudText>
<div class="hr-funnel">
@foreach (var item in items)
{
<div class="hr-funnel-row">
<div class="hr-funnel-label">@item.Label</div>
<div class="hr-funnel-bar-wrap">
<div class="hr-funnel-bar" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
<span>@item.Count.ToString("N0")</span>
</div>
</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverDonut => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
<div class="hr-donut-wrap">
<div class="hr-donut" style="@BuildDonutStyle(items)">
<div class="hr-donut-hole">@items.Sum(x => x.Count).ToString("N0")</div>
</div>
<div class="hr-donut-legend">
@foreach (var item in items.Take(7))
{
<div class="hr-legend-row">
<span class="hr-legend-dot" style="@($"background:{item.Color}")"></span>
<span>@item.Label</span>
<strong>@item.Count.ToString("N0")</strong>
</div>
}
</div>
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HorizontalBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte nach Organisation", "Relevant leavers by organisation")</MudText>
<div class="hr-bars">
@foreach (var item in items)
{
<div class="hr-bar-row">
<div class="hr-bar-label">@item.Label</div>
<div class="hr-bar-track">
<div class="hr-bar-fill" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
</div>
<div class="hr-bar-value">@item.Count.ToString("N0")</div>
</div>
}
</div>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> MonthlyBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte pro Monat", "Relevant leavers per month")</MudText>
<div class="hr-month-bars">
@foreach (var item in items)
{
<div class="hr-month">
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
<div class="hr-month-value">@item.Count</div>
<div class="hr-month-label">@item.Label</div>
</div>
}
</div>
</MudPaper>;
private static string BuildDonutStyle(IReadOnlyList<HrKpiGroupValue> items)
{
var total = items.Sum(x => x.Count);
if (total <= 0)
return "background:#e0e0e0";
var current = 0m;
var segments = new List<string>();
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)})";
}
}
<style>
.hr-viz-panel {
min-height: 100%;
}
.hr-gauge {
--gauge-color: #2e7d32;
--gauge-deg: 0deg;
position: relative;
height: 170px;
display: grid;
place-items: end center;
overflow: hidden;
}
.hr-gauge-track {
width: 260px;
height: 130px;
border-radius: 260px 260px 0 0;
background: conic-gradient(from 270deg at 50% 100%, #2e7d32 0deg 72deg, #f9a825 72deg 108deg, #c62828 108deg 180deg, transparent 180deg 360deg);
position: absolute;
bottom: 0;
}
.hr-gauge-track::after {
content: "";
position: absolute;
left: 34px;
right: 34px;
bottom: 0;
height: 96px;
border-radius: 192px 192px 0 0;
background: var(--mud-palette-surface);
}
.hr-gauge-needle {
position: absolute;
bottom: 4px;
width: 4px;
height: 112px;
background: #263238;
transform-origin: bottom center;
transform: rotate(calc(var(--gauge-deg) - 90deg));
border-radius: 4px;
z-index: 2;
}
.hr-gauge-center {
z-index: 3;
text-align: center;
margin-bottom: 4px;
}
.hr-gauge-value {
font-size: 2rem;
font-weight: 700;
color: var(--gauge-color);
}
.hr-gauge-caption,
.hr-gauge-scale {
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-gauge-scale {
display: flex;
justify-content: space-between;
max-width: 280px;
margin: 4px auto 0;
}
.hr-funnel-row,
.hr-bar-row,
.hr-legend-row {
display: grid;
grid-template-columns: minmax(110px, 1fr) 2fr auto;
gap: 10px;
align-items: center;
margin: 9px 0;
}
.hr-funnel-bar-wrap,
.hr-bar-track {
background: rgba(0,0,0,.08);
border-radius: 4px;
height: 24px;
overflow: hidden;
}
.hr-funnel-bar,
.hr-bar-fill {
height: 100%;
border-radius: 4px;
color: white;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
min-width: 26px;
}
.hr-donut-wrap {
display: grid;
grid-template-columns: 150px 1fr;
gap: 18px;
align-items: center;
}
.hr-donut {
width: 150px;
height: 150px;
border-radius: 50%;
position: relative;
}
.hr-donut::after {
content: "";
position: absolute;
inset: 34px;
border-radius: 50%;
background: var(--mud-palette-surface);
}
.hr-donut-hole {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 2;
font-weight: 700;
font-size: 1.35rem;
}
.hr-legend-row {
grid-template-columns: auto 1fr auto;
margin: 6px 0;
font-size: .9rem;
}
.hr-legend-dot {
width: 11px;
height: 11px;
border-radius: 50%;
display: inline-block;
}
.hr-bars {
display: grid;
gap: 7px;
}
.hr-bar-row {
grid-template-columns: minmax(130px, 1.2fr) 2fr auto;
}
.hr-month-bars {
height: 240px;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 8px;
align-items: end;
}
.hr-month {
height: 100%;
display: grid;
grid-template-rows: 1fr auto auto;
align-items: end;
text-align: center;
color: var(--mud-palette-text-secondary);
font-size: .8rem;
}
.hr-month-bar {
width: 100%;
min-height: 2px;
border-radius: 4px 4px 0 0;
}
.hr-month-value {
color: var(--mud-palette-text-primary);
font-weight: 600;
}
@@media (max-width: 700px) {
.hr-donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
.hr-funnel-row,
.hr-bar-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
</style>
+159
View File
@@ -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<HrKpiFileStatus> FileStatuses { get; set; } = [];
public List<string> Notices { get; set; } = [];
public List<string> OrganisationOptions { get; set; } = [];
public List<string> KostenstelleOptions { get; set; } = [];
public List<int> EntryYearOptions { get; set; } = [];
public List<string> MitarbeitertypOptions { get; set; } = [];
public List<HrKpiMetric> Metrics { get; set; } = [];
public List<HrKpiMetric> TurnoverMetrics { get; set; } = [];
public List<HrKpiMetric> AbsenceMetrics { get; set; } = [];
public List<HrKpiMetric> TimeVacationMetrics { get; set; } = [];
public List<HrKpiEmployeeRow> Employees { get; set; } = [];
public List<HrAbsenceRow> Absences { get; set; } = [];
public List<HrLeaverRow> Leavers { get; set; } = [];
public List<HrKpiGroupValue> HeadcountByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalTimeBalances { get; set; } = [];
public List<HrLeaverRow> 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<HrKpiGroupValue> FunnelSteps { get; set; } = [];
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> 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; }
}
+1
View File
@@ -70,6 +70,7 @@ builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>(); builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>(); builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>(); builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>(); builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>(); builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>(); builder.Services.AddSingleton<IExportLogService, ExportLogService>();
@@ -0,0 +1,992 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IHrKpiService
{
Task<HrKpiResult> 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<HrKpiResult> 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<HrKpiEmployeeRow> LoadEmployees(
ImportContext context,
IReadOnlyDictionary<string, TimeRow> timeRows,
IReadOnlyDictionary<string, SapRow> 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<string, TimeRow> 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<string, SapRow> 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<HrAbsenceRow> 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<HrLeaverRow> 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<HrKpiEmployeeRow> ApplyEmployeeFilters(IEnumerable<HrKpiEmployeeRow> 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<HrAbsenceRow> ApplyAbsenceFilters(
IEnumerable<HrAbsenceRow> rows,
HrKpiOptions options,
IReadOnlySet<int> 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<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> 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<HrKpiMetric> BuildOverviewMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences,
IReadOnlyCollection<HrLeaverRow> 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<HrKpiMetric> BuildTurnoverMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> 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<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 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<HrKpiMetric> BuildTimeVacationMetrics(IReadOnlyCollection<HrKpiEmployeeRow> 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<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> 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<int?> 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<string, int> 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<string, int> 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<string, int> headers, params string[] aliases)
=> ReadDecimalNullable(row, headers, aliases) ?? 0;
private static decimal? ReadDecimalNullable(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
{
var index = FindHeader(headers, aliases);
if (!index.HasValue)
return null;
var cell = row.Cell(index.Value);
if (cell.TryGetValue<decimal>(out var decimalValue))
return decimalValue;
if (cell.TryGetValue<double>(out var doubleValue))
return (decimal)doubleValue;
return ParseDecimalNullable(cell.GetFormattedString());
}
private static DateTime? ReadDate(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
{
var index = FindHeader(headers, aliases);
if (!index.HasValue)
return null;
var cell = row.Cell(index.Value);
if (cell.TryGetValue<DateTime>(out var dateValue))
return dateValue.Date;
if (cell.TryGetValue<double>(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<string, int> 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<T> ReadRows<T>(string fileName, string label, Func<IXLRow, IReadOnlyDictionary<string, int>, 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);
}
}
@@ -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.