using System.Globalization; using System.Text; using ClosedXML.Excel; using TrafagSalesExporter.Models; namespace TrafagSalesExporter.Services; public interface IHrKpiService { Task BuildAsync(HrKpiOptions options); } public sealed class HrKpiService : IHrKpiService { private const string MainFile = "Saldiperstichdatum.xlsx"; private const string TimeFile = "Exportkommengehen.xlsx"; private const string SapFile = "HR_KPI_Export.xlsx"; private const string AbsenceFile = "Abwesenheitinstunden.xlsx"; private const string LeaverFile = "Personalausgeschieden.xlsx"; public Task BuildAsync(HrKpiOptions options) { var normalizedOptions = new HrKpiOptions { DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? @"C:\temp" : options.DataFolder.Trim(), Year = options.Year <= 0 ? DateTime.Today.Year : options.Year, FromDate = options.FromDate?.Date, ToDate = options.ToDate?.Date, EntryYear = options.EntryYear, Organisationseinheit = NormalizeFilter(options.Organisationseinheit), KostenstelleText = NormalizeFilter(options.KostenstelleText), Mitarbeitertyp = NormalizeFilter(options.Mitarbeitertyp), FluktuationFilter = NormalizeFilter(options.FluktuationFilter), GlzAmpel = NormalizeFilter(options.GlzAmpel), RestferienAmpel = NormalizeFilter(options.RestferienAmpel), SearchText = NormalizeFilter(options.SearchText) }; var result = new HrKpiResult { Options = normalizedOptions }; var context = new ImportContext(result, normalizedOptions.DataFolder); var timeRows = LoadTimeRows(context); var sapRows = LoadSapRows(context); var employees = LoadEmployees(context, timeRows, sapRows); var absences = LoadAbsences(context); var leavers = LoadLeavers(context); result.OrganisationOptions = employees .Select(x => x.Organisationseinheit) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); result.KostenstelleOptions = employees .Select(x => x.KostenstelleText) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); result.EntryYearOptions = employees .Where(x => x.Eintrittsdatum.HasValue) .Select(x => x.Eintrittsdatum!.Value.Year) .Distinct() .OrderByDescending(x => x) .ToList(); result.MitarbeitertypOptions = employees .Select(x => x.Mitarbeitertyp) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); var analysisYear = ResolveAnalysisYear(normalizedOptions); var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList(); var filteredEmployeeNumbers = filteredEmployees .Where(x => x.Personalnummer.HasValue) .Select(x => x.Personalnummer!.Value) .ToHashSet(); employees = filteredEmployees; absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList(); leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList(); result.Employees = employees; result.Absences = absences; result.Leavers = leavers; result.Metrics = BuildOverviewMetrics(employees, absences, leavers, analysisYear); result.TurnoverMetrics = BuildTurnoverMetrics(employees, leavers, analysisYear, ResolveTurnoverAnchorDate(normalizedOptions, analysisYear)); result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences); result.TimeVacationMetrics = BuildTimeVacationMetrics(employees); result.TurnoverVisuals = BuildTurnoverVisuals(employees, leavers, analysisYear); result.HeadcountByOrganisation = employees .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) .Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Select(x => x.Personalnummer).Distinct().Count(), Value = g.Sum(x => x.Fte) }) .OrderByDescending(x => x.Count) .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) .Take(12) .ToList(); result.CriticalTimeBalances = employees .OrderByDescending(x => Math.Abs(x.StundenSaldo)) .Take(25) .ToList(); result.FluctuationRelevantLeavers = leavers .Where(x => x.IstFluktuationsrelevant) .OrderByDescending(x => x.Austrittsdatum) .Take(25) .ToList(); if (employees.Count == 0) result.Notices.Add("Keine aktiven Mitarbeitenden geladen. Pruefe Saldiperstichdatum.xlsx und die Filter."); var missingEmployeeNumberCount = employees.Count(x => !x.Personalnummer.HasValue); if (missingEmployeeNumberCount > 0) result.Notices.Add($"{missingEmployeeNumberCount:N0} aktive Mitarbeitendenzeilen ohne Personalnummer werden in Headcount-Distinct-Kennzahlen nicht mitgezaehlt."); var missingFteCount = employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue); if (missingFteCount > 0) result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit."); if (!context.HasFile(MainFile)) result.Notices.Add("Hauptdatei fehlt: Saldiperstichdatum.xlsx. Ohne diese Datei sind keine HR-KPIs moeglich."); if (!context.HasFile(SapFile)) result.Notices.Add("SAP-Datei HR_KPI_Export.xlsx fehlt. SAP-only Felder wie Geschlecht, Beschaeftigungsgrad, BU/NBU und Planstelle bleiben leer."); if (!context.HasFile(AbsenceFile)) result.Notices.Add("Rexx-Absenzen fehlen. Absenzquote und Krankheitstage bleiben 0."); if (!context.HasFile(LeaverFile)) result.Notices.Add("Rexx-Austritte fehlen. Fluktuationskennzahlen bleiben 0."); return Task.FromResult(result); } private static List LoadEmployees( ImportContext context, IReadOnlyDictionary timeRows, IReadOnlyDictionary sapRows) { return context.ReadRows(MainFile, "Rexx #757 Saldi", (row, headers) => { var personalnummer = ReadInt(row, headers, "Personalnummer"); var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Rexx"); var key = BuildPersonalKey(personalnummer); timeRows.TryGetValue(NormalizeKey(name), out var time); if (!sapRows.TryGetValue(key, out var sap) && personalnummer.HasValue) sapRows.TryGetValue(personalnummer.Value.ToString(CultureInfo.InvariantCulture), out sap); var entryDate = ReadDate(row, headers, "Eintrittsdatum"); var birthDate = time?.Geburtsdatum; var status = ReadString(row, headers, "Personal Status", "Personal_Status"); var rawBalance = ReadString(row, headers, "Stunden Saldo", "Stunden_Saldo_Raw"); var balance = ParseTimeBalance(rawBalance); var percent = sap?.BeschaeftigungsgradProzent; var arbeitzeitmodell = time?.Arbeitszeitmodell ?? string.Empty; var fte = ResolveFte(percent, arbeitzeitmodell, time?.AvgSollzeitTag); var nameParts = SplitName(name); var urlaubsanspruch = ReadDecimal(row, headers, "Urlaubsanspruch", "Urlaubsanspruch_Raw"); var urlaubRest = ReadDecimal(row, headers, "Urlaub Rest", "Urlaub_Rest_Raw"); var ferienAusstehend = ReadDecimal(row, headers, "Ferien ausstehend (Tage)", "Ferien_Ausstehend_Raw"); var ferienBezogen = urlaubsanspruch - urlaubRest - ferienAusstehend; return new HrKpiEmployeeRow { Personalnummer = personalnummer, NameVoll = name, Vorname = nameParts.Vorname, Nachname = nameParts.Nachname, Organisationseinheit = ReadString(row, headers, "Organisation", "Organisation_Text"), KostenstelleText = ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx"), Kostenstelle = ParseCostCenter(ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx")), Stelle = ReadString(row, headers, "Stelle", "Stelle_Rexx"), Leitung = ReadString(row, headers, "Leitung j/n", "Leitung"), Eintrittsdatum = entryDate, Geburtsdatum = birthDate, AlterJahre = YearsSince(birthDate), Altersgruppe = BuildAgeGroup(YearsSince(birthDate)), GeschlechtText = MapGender(sap?.Geschlecht), BeschaeftigungsgradProzent = percent, Fte = fte, IstTeilzeit = percent.HasValue && percent.Value > 0 ? percent.Value < 100 : string.Equals(arbeitzeitmodell, "Teilzeit", StringComparison.OrdinalIgnoreCase), Dienstjahre = YearsSince(entryDate), IstAktiv = string.Equals(status, "Aktiv", StringComparison.OrdinalIgnoreCase), Mitarbeitertyp = BuildEmployeeType(ReadString(row, headers, "Stelle", "Stelle_Rexx")), StundenSaldo = balance, GlzAmpel = BuildTrafficLight(balance), UrlaubRest = urlaubRest, Urlaubsanspruch = urlaubsanspruch, FerienAusstehend = ferienAusstehend, Ferientage = ferienBezogen < 0 ? 0 : ferienBezogen, RestferienAmpel = urlaubRest <= 5 ? "Gruen" : "Rot", Bruttolohn = ReadDecimal(row, headers, "Lohn", "Lohn_Raw"), LohnWaehrung = ReadString(row, headers, "Lohn Waehrung", "Lohn Währung"), BuTage = sap?.BuTage ?? 0, NbuTage = sap?.NbuTage ?? 0, Buchungskreis = sap?.Buchungskreis ?? string.Empty, Personalbereich = sap?.Personalbereich ?? string.Empty, Personalteilbereich = sap?.Personalteilbereich ?? string.Empty, Mitarbeitergruppe = sap?.Mitarbeitergruppe ?? string.Empty, Mitarbeiterkreis = sap?.Mitarbeiterkreis ?? string.Empty, Planstelle = sap?.Planstelle ?? string.Empty, SollStelle = sap?.SollStelle ?? string.Empty, Periode = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1) }; }) .Where(x => x.IstAktiv) .OrderBy(x => x.Personalnummer ?? int.MaxValue) .ToList(); } private static Dictionary LoadTimeRows(ImportContext context) { var rows = context.ReadRows(TimeFile, "Rexx #732 Kommen/Gehen", (row, headers) => { var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)"); return new TimeRow( NormalizeKey(name), ReadDate(row, headers, "Geburtsdatum"), ReadString(row, headers, "Arbeitszeitmodell"), ReadDecimal(row, headers, "O taegliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)")); }); return rows .Where(x => !string.IsNullOrWhiteSpace(x.NameKey)) .GroupBy(x => x.NameKey, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); } private static Dictionary LoadSapRows(ImportContext context) { var rows = context.ReadRows(SapFile, "SAP HR KPI", (row, headers) => { var pernr = ReadInt(row, headers, "Personalnummer"); return new SapRow( BuildPersonalKey(pernr), ReadString(row, headers, "Buchungskreis"), ReadString(row, headers, "Personalbereich"), ReadString(row, headers, "Personalteilbereich"), ReadString(row, headers, "Mitarbeitergruppe"), ReadString(row, headers, "Mitarbeiterkreis"), ReadString(row, headers, "Teilzeitkraft", "Teilzeitkennzeichen"), ReadDecimalNullable(row, headers, "Beschaeftigungsgrad %", "Beschäftigungsgrad %", "Beschäftigungsgrad %", "Beschaeftigungsgrad_Prozent"), ReadInt(row, headers, "Geschlecht"), ReadString(row, headers, "Planstelle"), ReadString(row, headers, "Stellenschluessel", "Stellenschlüssel", "Stellenschlüssel", "Soll_Stelle"), ReadDecimal(row, headers, "Nichtberufsunfall Tage", "NBU_Tage"), ReadDecimal(row, headers, "Berufsunfall Tage", "BU_Tage"), ReadString(row, headers, "Abrechnungskreis")); }); return rows .Where(x => !string.IsNullOrWhiteSpace(x.PersonalKey)) .GroupBy(x => x.PersonalKey, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); } private static List LoadAbsences(ImportContext context) { return context.ReadRows(AbsenceFile, "Rexx #744 Absenzen", (row, headers) => { var kurz = ReadDecimal(row, headers, "Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std"); var lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std"); var gesamt = kurz + lang; var tage = Math.Round(gesamt / 8.4m, 1); return new HrAbsenceRow { Personalnummer = ReadInt(row, headers, "Personalnummer"), Name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name"), Organisationseinheit = ReadString(row, headers, "Organisation"), Stelle = ReadString(row, headers, "Stelle"), Status = ReadString(row, headers, "Personal Status", "Status"), KrankheitKurzStd = kurz, KrankheitLangStd = lang, KrankheitGesamtStd = gesamt, KrankheitstageGesamt = tage, KrankheitstageKurz = Math.Round(kurz / 8.4m, 1), KrankheitstageLang = Math.Round(lang / 8.4m, 1), KrankenquoteMa = tage == 0 ? 0 : tage / 21m }; }) .Where(x => string.Equals(x.Status, "Aktiv", StringComparison.OrdinalIgnoreCase)) .ToList(); } private static List LoadLeavers(ImportContext context) { return context.ReadRows(LeaverFile, "Rexx #381 Ausgeschieden", (row, headers) => { var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Voll"); var nameParts = SplitName(name); var austritt = ReadDate(row, headers, "Austrittsdatum"); var eintritt = ReadDate(row, headers, "Eintrittsdatum"); var stelle = ReadString(row, headers, "Stelle-1", "Stelle"); var type = BuildEmployeeType(stelle); var reason = ReadString(row, headers, "Austrittsart"); var normalizedReason = NormalizeReason(reason); var isEmployeeResignation = normalizedReason.Contains("arbeitnehmer", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("mitarbeiter", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("kuendigung an", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("an kuendigung", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("eigenkuendigung", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("kuendigung ma", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("kuendigung durch ma", StringComparison.OrdinalIgnoreCase); var isExcluded = !string.Equals(type, "Festangestellt", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("befrist", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("pension", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("rente", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("trafag", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase); var isRelevant = isEmployeeResignation && !isExcluded; return new HrLeaverRow { Personalnummer = ReadInt(row, headers, "Personalnummer"), NameVoll = name, Vorname = nameParts.Vorname, Nachname = nameParts.Nachname, Organisationseinheit = ReadString(row, headers, "Organisation-1", "Organisationseinheit"), Stelle = stelle, Status = ReadString(row, headers, "Personal Status", "Status"), Austrittsdatum = austritt, Eintrittsdatum = eintritt, VerweildauerMonate = austritt.HasValue && eintritt.HasValue ? Math.Round((decimal)(austritt.Value - eintritt.Value).TotalDays / 30.44m, 1) : null, Austrittsart = reason, AustrittsartNormalisiert = normalizedReason, Mitarbeitertyp = type, IstArbeitnehmerkuendigung = isEmployeeResignation, IstFluktuationAusgeschlossen = isExcluded, IstFluktuationsrelevant = isRelevant, FluktuationAusschlussgrund = isRelevant ? null : BuildExclusionReason(type, normalizedReason, isEmployeeResignation), Austrittsmonat = austritt.HasValue ? new DateTime(austritt.Value.Year, austritt.Value.Month, 1) : null, Austrittsjahr = austritt?.Year }; }) .ToList(); } private static IEnumerable ApplyEmployeeFilters(IEnumerable rows, HrKpiOptions options) => rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && MatchesFilter(x.KostenstelleText, options.KostenstelleText) && MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && MatchesFilter(x.GlzAmpel, options.GlzAmpel) && MatchesFilter(x.RestferienAmpel, options.RestferienAmpel) && (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && MatchesEmployeeSearch(x, options.SearchText)); private static IEnumerable ApplyAbsenceFilters( IEnumerable rows, HrKpiOptions options, IReadOnlySet filteredEmployeeNumbers) => rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && x.Personalnummer.HasValue && filteredEmployeeNumbers.Contains(x.Personalnummer.Value) && MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); private static IEnumerable ApplyLeaverFilters(IEnumerable rows, HrKpiOptions options) => rows.Where(x => MatchesLeaverDateFilter(x, options) && MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && MatchesFluctuationFilter(x, options.FluktuationFilter) && MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); private static List BuildOverviewMetrics( IReadOnlyCollection employees, IReadOnlyCollection absences, IReadOnlyCollection leavers, int year) { var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer)); var fixedCount = CountDistinctPersons(employees .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) .Select(x => x.Personalnummer)); var fte = employees.Sum(x => x.Fte); var sickDays = absences.Sum(x => x.KrankheitstageGesamt); var absenceRate = activeCount == 0 ? 0 : sickDays / (activeCount * 21m); var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer)); var employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer)); var turnover = fixedCount == 0 ? 0 : relevantLeavers / (decimal)fixedCount; var avgBalance = activeCount == 0 ? 0 : employees.Average(x => x.StundenSaldo); var redBalance = employees.Count(x => x.GlzAmpel == "Rot"); return [ new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{fixedCount:N0} festangestellt", Severity = "Normal" }, new() { Label = "FTE", Value = fte.ToString("N1"), Detail = "Summe Beschaeftigungsgrad", Severity = "Normal" }, new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, new() { Label = $"Fluktuation {year}", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen", Severity = turnover > 0.12m ? "Warning" : "Normal" }, new() { Label = "GLZ Schnitt", Value = avgBalance.ToString("N1"), Detail = $"{redBalance:N0} Personen > 100h absolut", Severity = redBalance > 0 ? "Warning" : "Normal" }, new() { Label = "Unfalltage", Value = employees.Sum(x => x.BuTage + x.NbuTage).ToString("N1"), Detail = $"BU {employees.Sum(x => x.BuTage):N1} / NBU {employees.Sum(x => x.NbuTage):N1}", Severity = "Normal" } ]; } private static List BuildTurnoverMetrics( IReadOnlyCollection employees, IReadOnlyCollection leavers, int year, DateTime anchorDate) { var fixedHeadcount = CountDistinctPersons(employees .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) .Select(x => x.Personalnummer)); var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer)); var employeeResignations = leavers .Where(x => x.IstArbeitnehmerkuendigung) .Select(x => x.Personalnummer) .ToList(); var relevantLeavers = leavers .Where(x => x.IstFluktuationsrelevant) .Select(x => x.Personalnummer) .ToList(); var nonRelevantLeavers = leavers .Where(x => !x.IstFluktuationsrelevant) .Select(x => x.Personalnummer) .ToList(); var employeeResignationCount = CountDistinctPersons(employeeResignations); var relevantLeaverCount = CountDistinctPersons(relevantLeavers); var nonRelevantLeaverCount = CountDistinctPersons(nonRelevantLeavers); var currentMonth = anchorDate.Month; var currentQuarter = ((currentMonth - 1) / 3) + 1; var quarterLeavers = leavers .Where(x => x.IstFluktuationsrelevant && x.Austrittsdatum.HasValue && x.Austrittsdatum.Value.Year == year && ((x.Austrittsdatum.Value.Month - 1) / 3) + 1 == currentQuarter) .Select(x => x.Personalnummer) .ToList(); var monthLeavers = leavers .Where(x => x.IstFluktuationsrelevant && x.Austrittsdatum.HasValue && x.Austrittsdatum.Value.Year == year && x.Austrittsdatum.Value.Month == currentMonth) .Select(x => x.Personalnummer) .ToList(); var yearLeavers = leavers .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == year) .Select(x => x.Personalnummer) .ToList(); var quarterLeaverCount = CountDistinctPersons(quarterLeavers); var monthLeaverCount = CountDistinctPersons(monthLeavers); var yearLeaverCount = CountDistinctPersons(yearLeavers); var monthRate = fixedHeadcount == 0 ? 0 : monthLeaverCount / (decimal)fixedHeadcount; var quarterRate = fixedHeadcount == 0 ? 0 : quarterLeaverCount / (decimal)fixedHeadcount; var forecastRate = quarterRate * 4; var yearRate = fixedHeadcount == 0 ? 0 : yearLeaverCount / (decimal)fixedHeadcount; return [ new() { Label = "Headcount Festangestellt", Value = fixedHeadcount.ToString("N0"), Detail = "Nenner fuer Fluktuation", Severity = "Normal" }, new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Rexx", Severity = "Normal" }, new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" }, new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" }, new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" }, new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte im Monat", Severity = monthRate > 0.03m ? "Warning" : "Normal" }, new() { Label = "Avg Headcount Quartal", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" }, new() { Label = "Austritte Quartal", Value = quarterLeaverCount.ToString("N0"), Detail = $"Quartal {currentQuarter}/{year}", Severity = "Normal" }, new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Headcount", Severity = quarterRate > 0.08m ? "Warning" : "Normal" }, new() { Label = "Fluktuation Hochrechnung Jahr %", Value = forecastRate.ToString("P1"), Detail = "Quartalsrate x 4", Severity = forecastRate > 0.12m ? "Warning" : "Normal" }, new() { Label = "Avg Headcount Jahr", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" }, new() { Label = "Austritte Jahr", Value = yearLeaverCount.ToString("N0"), Detail = $"Fluktuationsrelevant {year}", Severity = "Normal" }, new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Headcount", Severity = yearRate > 0.12m ? "Warning" : "Normal" }, new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" } ]; } private static List BuildAbsenceMetrics( IReadOnlyCollection employees, IReadOnlyCollection absences) { var headcount = CountDistinctPersons(employees.Select(x => x.Personalnummer)); var totalSick = absences.Sum(x => x.KrankheitstageGesamt); var shortSick = absences.Sum(x => x.KrankheitstageKurz); var longSick = absences.Sum(x => x.KrankheitstageLang); var absenceRate = headcount == 0 ? 0 : totalSick / (headcount * 21m); var bu = employees.Sum(x => x.BuTage); var nbu = employees.Sum(x => x.NbuTage); return [ new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, new() { Label = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" }, new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" }, new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / 21 Tage / Headcount", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" }, new() { Label = "NBU-Tage", Value = nbu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" }, new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" } ]; } private static List BuildTimeVacationMetrics(IReadOnlyCollection employees) { var headcount = employees.Count; var avgBalance = headcount == 0 ? 0 : employees.Average(x => x.StundenSaldo); var red = employees.Count(x => x.GlzAmpel == "Rot"); var yellow = employees.Count(x => x.GlzAmpel == "Gelb"); var vacationEntitlement = employees.Sum(x => x.Urlaubsanspruch); var vacationUsed = employees.Sum(x => x.Ferientage); var vacationLeft = employees.Sum(x => x.UrlaubRest); var vacationOpen = employees.Sum(x => x.FerienAusstehend); var restVacationRed = employees.Count(x => x.RestferienAmpel == "Rot"); return [ new() { Label = "GLZ-Saldo Schnitt", Value = avgBalance.ToString("N1"), Detail = "Stunden pro Mitarbeiter", Severity = Math.Abs(avgBalance) > 50 ? "Warning" : "Normal" }, new() { Label = "GLZ Gelb", Value = yellow.ToString("N0"), Detail = "51-100h absolut", Severity = yellow > 0 ? "Warning" : "Normal" }, new() { Label = "GLZ Rot", Value = red.ToString("N0"), Detail = ">100h absolut", Severity = red > 0 ? "Warning" : "Normal" }, new() { Label = "Ferienanspruch", Value = vacationEntitlement.ToString("N1"), Detail = "Summe Tage", Severity = "Normal" }, new() { Label = "Ferien bezogen", Value = vacationUsed.ToString("N1"), Detail = "Anspruch - Rest - ausstehend", Severity = "Normal" }, new() { Label = "Ferien Rest", Value = vacationLeft.ToString("N1"), Detail = "Rexx Urlaub Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" }, new() { Label = "Ferien ausstehend", Value = vacationOpen.ToString("N1"), Detail = "Rexx ausstehend", Severity = "Normal" }, new() { Label = "Restferien Rot", Value = restVacationRed.ToString("N0"), Detail = ">5 Tage Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" } ]; } private static HrTurnoverVisuals BuildTurnoverVisuals( IReadOnlyCollection employees, IReadOnlyCollection leavers, int year) { var fixedHeadcount = CountDistinctPersons(employees .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) .Select(x => x.Personalnummer)); var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer)); var employeeResignations = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer)); var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer)); var notRelevant = Math.Max(0, totalLeavers - relevantLeavers); var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / (decimal)fixedHeadcount * 100m; var gaugeColor = ratePercent > 12m ? "#c62828" : ratePercent >= 8m ? "#f9a825" : "#2e7d32"; var maxFunnel = Math.Max(totalLeavers, 1); var reasonColors = new[] { "#455a64", "#7b1fa2", "#0277bd", "#ef6c00", "#8d6e63", "#ad1457", "#558b2f" }; var reasons = leavers .GroupBy(x => x.FluktuationAusschlussgrund ?? "Fluktuationsrelevant", StringComparer.OrdinalIgnoreCase) .Select((g, index) => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count(), Percent = totalLeavers == 0 ? 0 : g.Count() / (decimal)totalLeavers * 100m, Color = reasonColors[index % reasonColors.Length] }) .OrderByDescending(x => x.Count) .ToList(); var relevantByOrg = leavers .Where(x => x.IstFluktuationsrelevant) .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) .Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count(), Percent = relevantLeavers == 0 ? 0 : g.Count() / (decimal)relevantLeavers * 100m, Color = "#1565c0" }) .OrderByDescending(x => x.Count) .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) .Take(10) .ToList(); var monthly = Enumerable.Range(1, 12) .Select(month => { var count = leavers.Count(x => x.IstFluktuationsrelevant && x.Austrittsdatum.HasValue && x.Austrittsdatum.Value.Year == year && x.Austrittsdatum.Value.Month == month); return new HrKpiGroupValue { Label = CultureInfo.GetCultureInfo("de-CH").DateTimeFormat.GetAbbreviatedMonthName(month), Count = count, Value = count, Percent = relevantLeavers == 0 ? 0 : count / (decimal)relevantLeavers * 100m, Color = "#00897b" }; }) .ToList(); return new HrTurnoverVisuals { YearRatePercent = ratePercent, YearRateLabel = (ratePercent / 100m).ToString("P1"), GaugeColor = gaugeColor, GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m, FunnelSteps = [ new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" }, new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" }, new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" }, new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" } ], ExclusionReasons = reasons, RelevantByOrganisation = relevantByOrg, MonthlyRelevantLeavers = monthly }; } private static string? NormalizeFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static int ResolveAnalysisYear(HrKpiOptions options) => (options.ToDate ?? options.FromDate)?.Year ?? options.Year; private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int analysisYear) { if (options.ToDate.HasValue) return options.ToDate.Value.Date; if (options.FromDate.HasValue) return options.FromDate.Value.Date; return analysisYear == DateTime.Today.Year ? DateTime.Today : new DateTime(analysisYear, 12, 31); } private static bool MatchesLeaverDateFilter(HrLeaverRow row, HrKpiOptions options) { var hasRange = options.FromDate.HasValue || options.ToDate.HasValue; if (hasRange) { if (!row.Austrittsdatum.HasValue) return false; return (!options.FromDate.HasValue || row.Austrittsdatum.Value.Date >= options.FromDate.Value) && (!options.ToDate.HasValue || row.Austrittsdatum.Value.Date <= options.ToDate.Value); } return row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year; } private static int CountDistinctPersons(IEnumerable personalNumbers) => personalNumbers .Where(x => x.HasValue) .Select(x => x!.Value) .Distinct() .Count(); private static decimal ResolveFte(decimal? employmentPercent, string workingTimeModel, decimal? averageHoursPerDay) { if (employmentPercent.HasValue && employmentPercent.Value > 0) return employmentPercent.Value / 100m; if (averageHoursPerDay.HasValue && averageHoursPerDay.Value > 0) return Math.Clamp(averageHoursPerDay.Value / 8.4m, 0.1m, 1.2m); if (string.Equals(workingTimeModel, "Vollzeit", StringComparison.OrdinalIgnoreCase)) return 1m; if (string.Equals(workingTimeModel, "Teilzeit", StringComparison.OrdinalIgnoreCase)) return 0.5m; return 0m; } private static bool MatchesFilter(string value, string? filter) => string.IsNullOrWhiteSpace(filter) || string.Equals(value?.Trim(), filter, StringComparison.OrdinalIgnoreCase); private static bool MatchesEmployeeSearch(HrKpiEmployeeRow row, string? search) => MatchesTextSearch(search, row.NameVoll, row.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, row.Organisationseinheit, row.KostenstelleText, row.Stelle); private static bool MatchesTextSearch(string? search, params string[] values) { if (string.IsNullOrWhiteSpace(search)) return true; return values.Any(value => value.Contains(search, StringComparison.OrdinalIgnoreCase)); } private static bool MatchesFluctuationFilter(HrLeaverRow row, string? filter) { if (string.IsNullOrWhiteSpace(filter) || string.Equals(filter, "Alle", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(filter, "Fluktuationsrelevant", StringComparison.OrdinalIgnoreCase)) return row.IstFluktuationsrelevant; if (string.Equals(filter, "Arbeitnehmerkuendigung", StringComparison.OrdinalIgnoreCase)) return row.IstArbeitnehmerkuendigung; if (string.Equals(filter, "Ausgeschlossen", StringComparison.OrdinalIgnoreCase)) return !row.IstFluktuationsrelevant; return true; } private static string BlankAsUnknown(string value) => string.IsNullOrWhiteSpace(value) ? "Unbekannt" : value; private static string BuildPersonalKey(int? personalnummer) => personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; private static string NormalizeKey(string value) => value.Trim().ToUpperInvariant(); private static int? ParseCostCenter(string value) { var raw = value.Split('/')[0].Trim(); return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; } private static (string Nachname, string Vorname) SplitName(string value) { var parts = value.Split(',', 2, StringSplitOptions.TrimEntries); return parts.Length == 2 ? (parts[0], parts[1]) : (value.Trim(), string.Empty); } private static int? YearsSince(DateTime? date) { if (!date.HasValue) return null; var today = DateTime.Today; var years = today.Year - date.Value.Year; if (date.Value.Date > today.AddYears(-years)) years--; return years; } private static string BuildAgeGroup(int? age) { if (!age.HasValue) return "Unbekannt"; if (age.Value < 30) return "< 30"; if (age.Value < 40) return "30-39"; if (age.Value < 50) return "40-49"; if (age.Value < 60) return "50-59"; return "60+"; } private static string MapGender(int? value) => value switch { 1 => "Maennlich", 2 => "Weiblich", _ => "Unbekannt" }; private static decimal ParseTimeBalance(string value) { if (string.IsNullOrWhiteSpace(value)) return 0; var trimmed = value.Trim(); var negative = trimmed.StartsWith("-", StringComparison.Ordinal); trimmed = trimmed.TrimStart('-'); var parts = trimmed.Split(':'); if (parts.Length == 0) return 0; var hours = ParseDecimal(parts[0]); var minutes = parts.Length > 1 ? ParseDecimal(parts[1]) : 0; var result = hours + minutes / 60m; return negative ? -result : result; } private static string BuildTrafficLight(decimal balance) { var absolute = Math.Abs(balance); if (absolute <= 50) return "Gruen"; return absolute <= 100 ? "Gelb" : "Rot"; } private static string BuildEmployeeType(string position) { var lower = NormalizeReason(position); if (lower.Contains("praktik", StringComparison.OrdinalIgnoreCase)) return "Praktikant"; if (lower.Contains("werkstudent", StringComparison.OrdinalIgnoreCase)) return "Werkstudent"; if (lower.Contains("aushilfe", StringComparison.OrdinalIgnoreCase)) return "Aushilfe"; if (lower.Contains("lehrling", StringComparison.OrdinalIgnoreCase)) return "Lehrling"; return "Festangestellt"; } private static string NormalizeReason(string value) { var normalized = RemoveDiacritics(value).Trim().ToLowerInvariant(); return normalized .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) .Replace("ß", "ss", StringComparison.OrdinalIgnoreCase); } private static string BuildExclusionReason(string employeeType, string reason, bool isEmployeeResignation) { if (!string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase)) return employeeType; if (string.IsNullOrWhiteSpace(reason)) return "Austrittsart leer/unklar"; if (reason.Contains("befrist", StringComparison.OrdinalIgnoreCase)) return "Befristeter Vertrag"; if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) || reason.Contains("rente", StringComparison.OrdinalIgnoreCase)) return "Pensionierung"; if (reason.Contains("trafag", StringComparison.OrdinalIgnoreCase) || reason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || reason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || reason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) || reason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase)) return "Kuendigung durch Trafag"; return isEmployeeResignation ? "Ausgeschlossen" : "Keine Arbeitnehmerkuendigung"; } private static string ReadString(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) { var index = FindHeader(headers, aliases); return index.HasValue ? row.Cell(index.Value).GetFormattedString().Trim() : string.Empty; } private static int? ReadInt(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) { var value = ReadString(row, headers, aliases); if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed; var decimalValue = ParseDecimalNullable(value); return decimalValue.HasValue ? (int)Math.Truncate(decimalValue.Value) : null; } private static decimal ReadDecimal(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) => ReadDecimalNullable(row, headers, aliases) ?? 0; private static decimal? ReadDecimalNullable(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) { var index = FindHeader(headers, aliases); if (!index.HasValue) return null; var cell = row.Cell(index.Value); if (cell.TryGetValue(out var decimalValue)) return decimalValue; if (cell.TryGetValue(out var doubleValue)) return (decimal)doubleValue; return ParseDecimalNullable(cell.GetFormattedString()); } private static DateTime? ReadDate(IXLRow row, IReadOnlyDictionary headers, params string[] aliases) { var index = FindHeader(headers, aliases); if (!index.HasValue) return null; var cell = row.Cell(index.Value); if (cell.TryGetValue(out var dateValue)) return dateValue.Date; if (cell.TryGetValue(out var serialValue)) return DateTime.FromOADate(serialValue).Date; var value = cell.GetFormattedString().Trim(); if (DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.None, out var parsed) || DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out parsed) || DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) return parsed.Date; if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out serialValue)) return DateTime.FromOADate(serialValue).Date; return null; } private static int? FindHeader(IReadOnlyDictionary headers, params string[] aliases) { foreach (var alias in aliases) { if (headers.TryGetValue(NormalizeHeader(alias), out var index)) return index; } return null; } private static decimal ParseDecimal(string value) => ParseDecimalNullable(value) ?? 0; private static decimal? ParseDecimalNullable(string value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = value.Trim() .Replace("'", "", StringComparison.Ordinal) .Replace(" ", "", StringComparison.Ordinal); if (decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-CH"), out var result) || decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out result) || decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out result)) return result; return null; } private static string NormalizeHeader(string value) { var normalized = RemoveDiacritics(value) .Replace("ü", "u", StringComparison.OrdinalIgnoreCase) .Replace("ä", "a", StringComparison.OrdinalIgnoreCase) .Replace("ö", "o", StringComparison.OrdinalIgnoreCase) .Replace("Ø", "o", StringComparison.OrdinalIgnoreCase) .Replace("ø", "o", StringComparison.OrdinalIgnoreCase) .Replace("Ø", "o", StringComparison.OrdinalIgnoreCase); var builder = new StringBuilder(normalized.Length); foreach (var ch in normalized.ToLowerInvariant()) { if (char.IsLetterOrDigit(ch)) builder.Append(ch); } return builder.ToString(); } private static string RemoveDiacritics(string value) { var normalized = value.Normalize(NormalizationForm.FormD); var builder = new StringBuilder(normalized.Length); foreach (var ch in normalized) { if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) builder.Append(ch); } return builder.ToString().Normalize(NormalizationForm.FormC); } private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag); private sealed record SapRow( string PersonalKey, string Buchungskreis, string Personalbereich, string Personalteilbereich, string Mitarbeitergruppe, string Mitarbeiterkreis, string Teilzeitkennzeichen, decimal? BeschaeftigungsgradProzent, int? Geschlecht, string Planstelle, string SollStelle, decimal NbuTage, decimal BuTage, string Abrechnungskreis); private sealed class ImportContext { private readonly HrKpiResult _result; private readonly string _folder; public ImportContext(HrKpiResult result, string folder) { _result = result; _folder = folder; } public bool HasFile(string fileName) => File.Exists(BuildPath(fileName)); public List ReadRows(string fileName, string label, Func, T> map) { var path = BuildPath(fileName); var status = new HrKpiFileStatus { Label = label, Path = path, Exists = File.Exists(path) }; _result.FileStatuses.Add(status); if (!status.Exists) { status.Message = "Datei nicht gefunden"; return []; } try { using var workbook = new XLWorkbook(path); var worksheet = workbook.Worksheets.First(); var headerRow = worksheet.FirstRowUsed(); if (headerRow is null) { status.Message = "Leeres Arbeitsblatt"; return []; } var headers = headerRow.CellsUsed() .GroupBy(c => NormalizeHeader(c.GetString())) .Where(g => !string.IsNullOrWhiteSpace(g.Key)) .ToDictionary(g => g.Key, g => g.First().Address.ColumnNumber, StringComparer.OrdinalIgnoreCase); var rows = worksheet.RowsUsed() .Where(r => r.RowNumber() > headerRow.RowNumber()) .Where(r => !r.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetFormattedString()))) .Select(r => map(r, headers)) .ToList(); status.RowCount = rows.Count; status.Message = "OK"; return rows; } catch (Exception ex) { status.Message = ex.Message; _result.Notices.Add($"{label}: {ex.Message}"); return []; } } private string BuildPath(string fileName) => Path.Combine(_folder, fileName); } }