Refactor HR KPI cockpit architecture

This commit is contained in:
2026-05-13 07:30:43 +02:00
parent 20be752adc
commit 1cd0ad998f
8 changed files with 1802 additions and 1557 deletions
@@ -0,0 +1,587 @@
@using Microsoft.AspNetCore.Components
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
<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 {
[Parameter, EditorRequired] public HrKpiResult Result { get; set; } = new();
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") ?? "-";
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>
@@ -1,6 +1,9 @@
@page "/hr-kpi"
@page "/hr-kpi"
@using Microsoft.Extensions.Options
@using TrafagSalesExporter.Components.HrKpi
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@@ -102,131 +105,12 @@
</MudPaper>
}
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@MetricGrid(_result.Metrics)
<HrKpiDashboardTabs Result="_result" />
<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 string _dataFolder = HrKpiDataSourceOptions.DefaultFolder;
private int _year = DateTime.Today.Year;
private DateTime? _fromDate;
private DateTime? _toDate;
@@ -252,6 +136,7 @@
protected override async Task OnInitializedAsync()
{
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
await LoadAsync();
}
@@ -287,460 +172,4 @@
}
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>
+28 -1
View File
@@ -2,7 +2,7 @@ namespace TrafagSalesExporter.Models;
public sealed class HrKpiOptions
{
public string DataFolder { get; set; } = @"C:\temp";
public string DataFolder { get; set; } = HrKpiDataSourceOptions.DefaultFolder;
public int Year { get; set; } = DateTime.Today.Year;
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
@@ -16,6 +16,33 @@ public sealed class HrKpiOptions
public string? SearchText { get; set; }
}
public sealed class HrKpiDataSourceOptions
{
public const string SectionName = "HrKpi";
public const string DefaultFolder = @"C:\temp";
public string DataFolder { get; set; } = DefaultFolder;
public string MainFile { get; set; } = "Saldiperstichdatum.xlsx";
public string TimeFile { get; set; } = "Exportkommengehen.xlsx";
public string SapFile { get; set; } = "HR_KPI_Export.xlsx";
public string AbsenceFile { get; set; } = "Abwesenheitinstunden.xlsx";
public string LeaverFile { get; set; } = "Personalausgeschieden.xlsx";
public HrKpiDataSourceOptions Normalize()
=> new()
{
DataFolder = NormalizeText(DataFolder, DefaultFolder),
MainFile = NormalizeText(MainFile, "Saldiperstichdatum.xlsx"),
TimeFile = NormalizeText(TimeFile, "Exportkommengehen.xlsx"),
SapFile = NormalizeText(SapFile, "HR_KPI_Export.xlsx"),
AbsenceFile = NormalizeText(AbsenceFile, "Abwesenheitinstunden.xlsx"),
LeaverFile = NormalizeText(LeaverFile, "Personalausgeschieden.xlsx")
};
private static string NormalizeText(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
public sealed class HrKpiResult
{
public HrKpiOptions Options { get; set; } = new();
+2
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
@@ -43,6 +44,7 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -0,0 +1,988 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
internal sealed class HrKpiDashboardBuilder
{
private readonly HrKpiDataSourceOptions _dataSources;
public HrKpiDashboardBuilder(HrKpiDataSourceOptions dataSources)
{
_dataSources = dataSources.Normalize();
}
public HrKpiResult Build(HrKpiOptions options)
{
var normalizedOptions = new HrKpiOptions
{
DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? _dataSources.DataFolder : 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 {_dataSources.MainFile} 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(_dataSources.MainFile))
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
if (!context.HasFile(_dataSources.SapFile))
result.Notices.Add($"SAP-Datei {_dataSources.SapFile} fehlt. SAP-only Felder wie Geschlecht, Beschaeftigungsgrad, BU/NBU und Planstelle bleiben leer.");
if (!context.HasFile(_dataSources.AbsenceFile))
result.Notices.Add("Rexx-Absenzen fehlen. Absenzquote und Krankheitstage bleiben 0.");
if (!context.HasFile(_dataSources.LeaverFile))
result.Notices.Add("Rexx-Austritte fehlen. Fluktuationskennzahlen bleiben 0.");
return result;
}
private List<HrKpiEmployeeRow> LoadEmployees(
ImportContext context,
IReadOnlyDictionary<string, TimeRow> timeRows,
IReadOnlyDictionary<string, SapRow> sapRows)
{
return context.ReadRows(_dataSources.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 Dictionary<string, TimeRow> LoadTimeRows(ImportContext context)
{
var rows = context.ReadRows(_dataSources.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 Dictionary<string, SapRow> LoadSapRows(ImportContext context)
{
var rows = context.ReadRows(_dataSources.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 List<HrAbsenceRow> LoadAbsences(ImportContext context)
{
return context.ReadRows(_dataSources.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 List<HrLeaverRow> LoadLeavers(ImportContext context)
{
return context.ReadRows(_dataSources.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);
}
}
+8 -978
View File
@@ -1,6 +1,4 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
@@ -12,981 +10,13 @@ public interface IHrKpiService
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";
private readonly HrKpiDataSourceOptions _dataSources;
public HrKpiService(IOptions<HrKpiDataSourceOptions>? dataSources = null)
{
_dataSources = (dataSources?.Value ?? new HrKpiDataSourceOptions()).Normalize();
}
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);
}
=> Task.FromResult(new HrKpiDashboardBuilder(_dataSources).Build(options));
}
@@ -0,0 +1,174 @@
using ClosedXML.Excel;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Tests;
public sealed class HrKpiServiceTests : IDisposable
{
private readonly string _folder;
private readonly HrKpiService _service;
public HrKpiServiceTests()
{
_folder = Path.Combine(Path.GetTempPath(), "trafag-hr-kpi-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_folder);
WriteFixtureFiles(_folder);
_service = new HrKpiService(Options.Create(new HrKpiDataSourceOptions
{
DataFolder = _folder
}));
}
public void Dispose()
{
if (Directory.Exists(_folder))
Directory.Delete(_folder, recursive: true);
}
[Fact]
public async Task BuildAsync_Applies_Organisation_Filter_To_Absences()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025,
Organisationseinheit = "Org A"
});
Assert.All(result.Employees, row => Assert.Equal("Org A", row.Organisationseinheit));
var absence = Assert.Single(result.Absences);
Assert.Equal(1001, absence.Personalnummer);
Assert.Equal(1.0m, absence.KrankheitstageGesamt);
}
[Fact]
public async Task BuildAsync_Uses_Date_Range_Instead_Of_Year_For_Leavers()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2024,
FromDate = new DateTime(2025, 3, 1),
ToDate = new DateTime(2025, 3, 31)
});
var relevant = Assert.Single(result.FluctuationRelevantLeavers);
Assert.Equal(1001, relevant.Personalnummer);
Assert.DoesNotContain(result.Leavers, row => row.Austrittsdatum?.Year == 2024);
}
[Fact]
public async Task BuildAsync_Excludes_Missing_Personalnummer_From_Distinct_Headcount_And_Uses_Fte_Fallback()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025
});
var headcount = Assert.Single(result.Metrics, metric => metric.Label == "Headcount aktiv");
Assert.Equal("3", headcount.Value);
var fallbackEmployee = Assert.Single(result.Employees, row => row.NameVoll == "Fallback, Fiona");
Assert.Null(fallbackEmployee.BeschaeftigungsgradProzent);
Assert.Equal(0.5m, fallbackEmployee.Fte);
Assert.Contains(result.Notices, notice => notice.Contains("ohne Personalnummer", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Notices, notice => notice.Contains("FTE-Fallback", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task BuildAsync_Classifies_Turnover_Relevance_And_Visuals()
{
var result = await _service.BuildAsync(new HrKpiOptions
{
DataFolder = _folder,
Year = 2025
});
Assert.Equal(3, result.Leavers.Count);
Assert.Single(result.Leavers, row => row.IstFluktuationsrelevant);
Assert.Contains(result.Leavers, row => row.FluktuationAusschlussgrund == "Kuendigung durch Trafag");
Assert.Contains(result.Leavers, row => row.FluktuationAusschlussgrund == "Praktikant");
Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers.Single(row => row.Label == "Mär").Count);
}
private static void WriteFixtureFiles(string folder)
{
WriteWorkbook(Path.Combine(folder, "Saldiperstichdatum.xlsx"),
[
"Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation", "Kostenstelle", "Stelle",
"Leitung j/n", "Eintrittsdatum", "Personal Status", "Stunden Saldo", "Urlaubsanspruch",
"Urlaub Rest", "Ferien ausstehend (Tage)", "Lohn", "Lohn Waehrung"
],
[
[1001, "Alpha, Anna", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "120:00", 25, 8, 2, 100000, "CHF"],
[1002, "Beta, Bruno", "Org B", "200 / Org B", "Engineer", "n", new DateTime(2024, 2, 1), "Aktiv", "10:00", 25, 4, 1, 90000, "CHF"],
[1003, "Fallback, Fiona", "Org B", "200 / Org B", "Engineer", "n", new DateTime(2025, 1, 15), "Aktiv", "0:00", 25, 3, 0, 70000, "CHF"],
["", "NoNumber, Nora", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2025, 2, 1), "Aktiv", "0:00", 25, 1, 0, 65000, "CHF"],
[1004, "Inactive, Ivan", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2021, 1, 1), "Inaktiv", "0:00", 25, 0, 0, 65000, "CHF"]
]);
WriteWorkbook(Path.Combine(folder, "Exportkommengehen.xlsx"),
["Nachname, Vorname (Link Personal)", "Geburtsdatum", "Arbeitszeitmodell", "O taegliche Sollarbeitszeit (Woche)"],
[
["Alpha, Anna", new DateTime(1990, 1, 1), "Vollzeit", 8.4],
["Beta, Bruno", new DateTime(1991, 1, 1), "Teilzeit", 4.2],
["Fallback, Fiona", new DateTime(1992, 1, 1), "Teilzeit", 4.2],
["NoNumber, Nora", new DateTime(1993, 1, 1), "Vollzeit", 8.4]
]);
WriteWorkbook(Path.Combine(folder, "HR_KPI_Export.xlsx"),
[
"Personalnummer", "Buchungskreis", "Personalbereich", "Personalteilbereich", "Mitarbeitergruppe",
"Mitarbeiterkreis", "Teilzeitkraft", "Beschaeftigungsgrad %", "Geschlecht", "Planstelle",
"Stellenschluessel", "Nichtberufsunfall Tage", "Berufsunfall Tage", "Abrechnungskreis"
],
[
[1001, "CH01", "PB", "PTB", "MG", "MK", "Nein", 100, 2, "P1", "S1", 0, 0, "A"],
[1002, "CH01", "PB", "PTB", "MG", "MK", "Ja", 50, 1, "P2", "S2", 0, 0, "A"]
]);
WriteWorkbook(Path.Combine(folder, "Abwesenheitinstunden.xlsx"),
[
"Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation", "Stelle", "Personal Status",
"Krankheit angetreten (Stunden Ind.)", "Krank nicht buchbar angetreten (Stunden Ind.)"
],
[
[1001, "Alpha, Anna", "Org A", "Engineer", "Aktiv", 8.4, 0],
[1002, "Beta, Bruno", "Org B", "Engineer", "Aktiv", 16.8, 0],
[9999, "External, Elsa", "Org X", "Engineer", "Aktiv", 84, 0]
]);
WriteWorkbook(Path.Combine(folder, "Personalausgeschieden.xlsx"),
[
"Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation-1", "Stelle-1",
"Personal Status", "Austrittsdatum", "Eintrittsdatum", "Austrittsart"
],
[
[1001, "Alpha, Anna", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 3, 10), new DateTime(2020, 1, 1), "Arbeitnehmer Kuendigung"],
[1002, "Beta, Bruno", "Org B", "Engineer", "Inaktiv", new DateTime(2025, 4, 5), new DateTime(2024, 2, 1), "Kuendigung Arbeitgeber"],
[2001, "Trainee, Tom", "Org A", "Praktikant", "Inaktiv", new DateTime(2025, 5, 5), new DateTime(2025, 1, 1), "Arbeitnehmer Kuendigung"]
]);
}
private static void WriteWorkbook(string path, string[] headers, object?[][] rows)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("Sheet1");
for (var column = 0; column < headers.Length; column++)
sheet.Cell(1, column + 1).Value = headers[column];
for (var row = 0; row < rows.Length; row++)
{
for (var column = 0; column < rows[row].Length; column++)
sheet.Cell(row + 2, column + 1).Value = XLCellValue.FromObject(rows[row][column]);
}
workbook.SaveAs(path);
}
}
+8
View File
@@ -16,5 +16,13 @@
"AdminGroups": [
"TRAFAG\\TrafagSalesExporter-Admins"
]
},
"HrKpi": {
"DataFolder": "C:\\temp",
"MainFile": "Saldiperstichdatum.xlsx",
"TimeFile": "Exportkommengehen.xlsx",
"SapFile": "HR_KPI_Export.xlsx",
"AbsenceFile": "Abwesenheitinstunden.xlsx",
"LeaverFile": "Personalausgeschieden.xlsx"
}
}