diff --git a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor index 3f3f829..369208e 100644 --- a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor +++ b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor @@ -46,7 +46,7 @@ @HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation) - @MonthlyBars(Result.TurnoverVisuals.MonthlyRelevantLeavers) + @MonthlyBars(Result.TurnoverVisuals) @@ -286,7 +286,7 @@ .OrderByDescending(x => x.Count); private RenderFragment TurnoverGauge => visual => @ - @T("Jahres-Fluktuation", "Annual turnover") + @visual.RateTitle
@@ -355,10 +355,10 @@
; - private RenderFragment> MonthlyBars => items => @ - @T("Relevante Austritte pro Monat", "Relevant leavers per month") + private RenderFragment MonthlyBars => visual => @ + @visual.TimelineTitle
- @foreach (var item in items) + @foreach (var item in visual.MonthlyRelevantLeavers) {
0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor index e149b3e..f818afb 100644 --- a/TrafagSalesExporter/Components/Pages/HrKpi.razor +++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor @@ -17,7 +17,12 @@ - + + @foreach (var option in _result?.ExitYearOptions ?? []) + { + @option + } + @@ -111,7 +116,7 @@ @code { private string _dataFolder = HrKpiDataSourceOptions.DefaultFolder; - private int _year = DateTime.Today.Year; + private int? _year; private DateTime? _fromDate; private DateTime? _toDate; private int? _entryYear; diff --git a/TrafagSalesExporter/Models/HrKpiModels.cs b/TrafagSalesExporter/Models/HrKpiModels.cs index 4190ccb..2e689da 100644 --- a/TrafagSalesExporter/Models/HrKpiModels.cs +++ b/TrafagSalesExporter/Models/HrKpiModels.cs @@ -3,7 +3,7 @@ namespace TrafagSalesExporter.Models; public sealed class HrKpiOptions { public string DataFolder { get; set; } = HrKpiDataSourceOptions.DefaultFolder; - public int Year { get; set; } = DateTime.Today.Year; + public int? Year { get; set; } public DateTime? FromDate { get; set; } public DateTime? ToDate { get; set; } public int? EntryYear { get; set; } @@ -50,6 +50,7 @@ public sealed class HrKpiResult public List Notices { get; set; } = []; public List OrganisationOptions { get; set; } = []; public List KostenstelleOptions { get; set; } = []; + public List ExitYearOptions { get; set; } = []; public List EntryYearOptions { get; set; } = []; public List MitarbeitertypOptions { get; set; } = []; public List Metrics { get; set; } = []; @@ -93,10 +94,12 @@ public sealed class HrKpiGroupValue public sealed class HrTurnoverVisuals { + public string RateTitle { get; set; } = "Fluktuation Auswahl"; public decimal YearRatePercent { get; set; } public string YearRateLabel { get; set; } = "0.0%"; public string GaugeColor { get; set; } = "#2e7d32"; public decimal GaugeRotationDegrees { get; set; } + public string TimelineTitle { get; set; } = "Relevante Austritte"; public List FunnelSteps { get; set; } = []; public List ExclusionReasons { get; set; } = []; public List RelevantByOrganisation { get; set; } = []; diff --git a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs index 5dc4e3c..4a5315e 100644 --- a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs +++ b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs @@ -19,7 +19,7 @@ internal sealed class HrKpiDashboardBuilder var normalizedOptions = new HrKpiOptions { DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? _dataSources.DataFolder : options.DataFolder.Trim(), - Year = options.Year <= 0 ? DateTime.Today.Year : options.Year, + Year = options.Year.HasValue && options.Year.Value > 0 ? options.Year.Value : null, FromDate = options.FromDate?.Date, ToDate = options.ToDate?.Date, EntryYear = options.EntryYear, @@ -53,6 +53,12 @@ internal sealed class HrKpiDashboardBuilder .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); + result.ExitYearOptions = leavers + .Where(x => x.Austrittsjahr.HasValue) + .Select(x => x.Austrittsjahr!.Value) + .Distinct() + .OrderByDescending(x => x) + .ToList(); result.EntryYearOptions = employees .Where(x => x.Eintrittsdatum.HasValue) .Select(x => x.Eintrittsdatum!.Value.Year) @@ -66,7 +72,8 @@ internal sealed class HrKpiDashboardBuilder .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); - var analysisYear = ResolveAnalysisYear(normalizedOptions); + var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList(); + var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList(); var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList(); var filteredEmployeeNumbers = filteredEmployees .Where(x => x.Personalnummer.HasValue) @@ -76,21 +83,22 @@ internal sealed class HrKpiDashboardBuilder employees = filteredEmployees; absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList(); leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList(); + var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers); result.Employees = employees; result.Absences = absences; result.Leavers = leavers; - result.Metrics = BuildOverviewMetrics(employees, absences, leavers, analysisYear); - result.TurnoverMetrics = BuildTurnoverMetrics(employees, leavers, analysisYear, ResolveTurnoverAnchorDate(normalizedOptions, analysisYear)); + result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); + result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences); result.TimeVacationMetrics = BuildTimeVacationMetrics(employees); - result.TurnoverVisuals = BuildTurnoverVisuals(employees, leavers, analysisYear); + result.TurnoverVisuals = BuildTurnoverVisuals(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); result.HeadcountByOrganisation = employees .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) .Select(g => new HrKpiGroupValue { Label = g.Key, - Count = g.Select(x => x.Personalnummer).Distinct().Count(), + Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), Value = g.Sum(x => x.Fte) }) .OrderByDescending(x => x.Count) @@ -115,6 +123,8 @@ internal sealed class HrKpiDashboardBuilder var missingFteCount = employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue); if (missingFteCount > 0) result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit."); + if (HasEmployeeOnlyTurnoverFilters(normalizedOptions)) + result.Notices.Add("Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende und Absenzen, aber nicht die Fluktuation. Die Austrittsdatei enthaelt diese Felder nicht stabil genug fuer denselben Schnitt."); if (!context.HasFile(_dataSources.MainFile)) result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich."); if (!context.HasFile(_dataSources.SapFile)) @@ -305,6 +315,7 @@ internal sealed class HrKpiDashboardBuilder normalizedReason.Contains("befrist", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("pension", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("rente", StringComparison.OrdinalIgnoreCase) || + normalizedReason.Contains("ruhestand", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("trafag", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || normalizedReason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || @@ -349,6 +360,12 @@ internal sealed class HrKpiDashboardBuilder (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && MatchesEmployeeSearch(x, options.SearchText)); + private static IEnumerable ApplyTurnoverEmployeeFilters(IEnumerable rows, HrKpiOptions options) + => rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && + MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && + (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && + MatchesEmployeeSearch(x, options.SearchText)); + private static IEnumerable ApplyAbsenceFilters( IEnumerable rows, HrKpiOptions options, @@ -362,34 +379,46 @@ internal sealed class HrKpiDashboardBuilder => rows.Where(x => MatchesLeaverDateFilter(x, options) && MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && + (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && MatchesFluctuationFilter(x, options.FluktuationFilter) && MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); + private static IEnumerable ApplyTurnoverHeadcountLeaverFilters(IEnumerable rows, HrKpiOptions options) + => rows.Where(x => MatchesLeaverEmploymentPeriodFilter(x, options) && + MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) && + MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) && + (!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) && + MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty)); + private static List BuildOverviewMetrics( IReadOnlyCollection employees, IReadOnlyCollection absences, + IReadOnlyCollection turnoverEmployees, + IReadOnlyCollection turnoverHeadcountLeavers, IReadOnlyCollection leavers, - int year) + TurnoverPeriodScope period) { var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer)); - var fixedCount = CountDistinctPersons(employees + var activeFixedCount = CountDistinctPersons(employees .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) .Select(x => x.Personalnummer)); + var turnoverIntervals = BuildTurnoverIntervals(turnoverEmployees, turnoverHeadcountLeavers); + var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period); var fte = employees.Sum(x => x.Fte); var sickDays = absences.Sum(x => x.KrankheitstageGesamt); - var absenceRate = activeCount == 0 ? 0 : sickDays / (activeCount * 21m); + var absenceRate = fte <= 0 ? 0 : sickDays / (fte * 21m); var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer)); var employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer)); - var turnover = fixedCount == 0 ? 0 : relevantLeavers / (decimal)fixedCount; + var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator; var avgBalance = activeCount == 0 ? 0 : employees.Average(x => x.StundenSaldo); var redBalance = employees.Count(x => x.GlzAmpel == "Rot"); return [ - new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{fixedCount:N0} festangestellt", Severity = "Normal" }, + new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{activeFixedCount:N0} festangestellt", Severity = "Normal" }, new() { Label = "FTE", Value = fte.ToString("N1"), Detail = "Summe Beschaeftigungsgrad", Severity = "Normal" }, - new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, - new() { Label = $"Fluktuation {year}", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen", Severity = turnover > 0.12m ? "Warning" : "Normal" }, + new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote FTE {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, + new() { Label = period.ShowPeriodMetrics ? $"Fluktuation {period.Label}" : "Fluktuation Auswahl", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen, Nenner {FormatHeadcount(turnoverDenominator)} HC", Severity = turnover > 0.12m ? "Warning" : "Normal" }, new() { Label = "GLZ Schnitt", Value = avgBalance.ToString("N1"), Detail = $"{redBalance:N0} Personen > 100h absolut", Severity = redBalance > 0 ? "Warning" : "Normal" }, new() { Label = "Unfalltage", Value = employees.Sum(x => x.BuTage + x.NbuTage).ToString("N1"), Detail = $"BU {employees.Sum(x => x.BuTage):N1} / NBU {employees.Sum(x => x.NbuTage):N1}", Severity = "Normal" } ]; @@ -397,13 +426,12 @@ internal sealed class HrKpiDashboardBuilder private static List BuildTurnoverMetrics( IReadOnlyCollection employees, + IReadOnlyCollection turnoverHeadcountLeavers, IReadOnlyCollection leavers, - int year, - DateTime anchorDate) + TurnoverPeriodScope period) { - var fixedHeadcount = CountDistinctPersons(employees - .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Personalnummer)); + var turnoverIntervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers); + var selectionHeadcount = ResolveTurnoverDenominator(employees, turnoverIntervals, period); var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer)); var employeeResignations = leavers .Where(x => x.IstArbeitnehmerkuendigung) @@ -420,9 +448,30 @@ internal sealed class HrKpiDashboardBuilder var employeeResignationCount = CountDistinctPersons(employeeResignations); var relevantLeaverCount = CountDistinctPersons(relevantLeavers); var nonRelevantLeaverCount = CountDistinctPersons(nonRelevantLeavers); + var selectionRate = selectionHeadcount == 0 ? 0 : relevantLeaverCount / selectionHeadcount; - var currentMonth = anchorDate.Month; + var metrics = new List + { + new() { Label = "Headcount Festangestellt", Value = FormatHeadcount(selectionHeadcount), Detail = period.ShowPeriodMetrics ? "Avg Headcount Jahr, nicht FTE" : "Aktueller Headcount, nicht FTE", Severity = "Normal" }, + new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Auswahl", Severity = "Normal" }, + new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" }, + new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" }, + new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" }, + new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" } + }; + + if (!period.ShowPeriodMetrics || !period.BreakdownYear.HasValue) + { + metrics.Insert(5, new HrKpiMetric { Label = "Fluktuation Auswahl %", Value = selectionRate.ToString("P1"), Detail = "Auswahl / Headcount, nicht annualisiert", Severity = selectionRate > 0.12m ? "Warning" : "Normal" }); + return metrics; + } + + var year = period.BreakdownYear.Value; + var currentMonth = period.AnchorDate.Month; var currentQuarter = ((currentMonth - 1) / 3) + 1; + var monthHeadcount = CalculateMonthlyAverageFixedHeadcount(turnoverIntervals, year, currentMonth); + var quarterHeadcount = CalculateAverageFixedHeadcount(turnoverIntervals, BuildQuarterMonths(currentQuarter).Select(month => (year, month))); + var yearHeadcount = CalculateAverageFixedHeadcount(turnoverIntervals, Enumerable.Range(1, 12).Select(month => (year, month))); var quarterLeavers = leavers .Where(x => x.IstFluktuationsrelevant && x.Austrittsdatum.HasValue && @@ -445,39 +494,44 @@ internal sealed class HrKpiDashboardBuilder var monthLeaverCount = CountDistinctPersons(monthLeavers); var yearLeaverCount = CountDistinctPersons(yearLeavers); - var monthRate = fixedHeadcount == 0 ? 0 : monthLeaverCount / (decimal)fixedHeadcount; - var quarterRate = fixedHeadcount == 0 ? 0 : quarterLeaverCount / (decimal)fixedHeadcount; + var monthRate = monthHeadcount == 0 ? 0 : monthLeaverCount / monthHeadcount; + var quarterRate = quarterHeadcount == 0 ? 0 : quarterLeaverCount / quarterHeadcount; var forecastRate = quarterRate * 4; - var yearRate = fixedHeadcount == 0 ? 0 : yearLeaverCount / (decimal)fixedHeadcount; + var yearRate = yearHeadcount == 0 ? 0 : yearLeaverCount / yearHeadcount; - return + metrics[0] = new HrKpiMetric + { + Label = "Headcount Festangestellt", + Value = FormatHeadcount(yearHeadcount), + Detail = "Avg Headcount Jahr, nicht FTE", + Severity = "Normal" + }; + + metrics.AddRange( [ - new() { Label = "Headcount Festangestellt", Value = fixedHeadcount.ToString("N0"), Detail = "Nenner fuer Fluktuation", Severity = "Normal" }, - new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Rexx", Severity = "Normal" }, - new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" }, - new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" }, - new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" }, - new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte im Monat", Severity = monthRate > 0.03m ? "Warning" : "Normal" }, - new() { Label = "Avg Headcount Quartal", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" }, + new() { Label = "Headcount Monat", Value = FormatHeadcount(monthHeadcount), Detail = $"Monats-HC {currentMonth:N0}/{year}, nicht FTE", Severity = "Normal" }, + new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte / HC {FormatHeadcount(monthHeadcount)}", Severity = monthRate > 0.03m ? "Warning" : "Normal" }, + new() { Label = "Avg Headcount Quartal", Value = FormatHeadcount(quarterHeadcount), Detail = "Durchschnitt Monats-HC, nicht FTE", Severity = "Normal" }, new() { Label = "Austritte Quartal", Value = quarterLeaverCount.ToString("N0"), Detail = $"Quartal {currentQuarter}/{year}", Severity = "Normal" }, - new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Headcount", Severity = quarterRate > 0.08m ? "Warning" : "Normal" }, + new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Avg HC Quartal", Severity = quarterRate > 0.08m ? "Warning" : "Normal" }, new() { Label = "Fluktuation Hochrechnung Jahr %", Value = forecastRate.ToString("P1"), Detail = "Quartalsrate x 4", Severity = forecastRate > 0.12m ? "Warning" : "Normal" }, - new() { Label = "Avg Headcount Jahr", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" }, + new() { Label = "Avg Headcount Jahr", Value = FormatHeadcount(yearHeadcount), Detail = "Durchschnitt Monats-HC, nicht FTE", Severity = "Normal" }, new() { Label = "Austritte Jahr", Value = yearLeaverCount.ToString("N0"), Detail = $"Fluktuationsrelevant {year}", Severity = "Normal" }, - new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Headcount", Severity = yearRate > 0.12m ? "Warning" : "Normal" }, - new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" } - ]; + new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Avg HC Jahr", Severity = yearRate > 0.12m ? "Warning" : "Normal" } + ]); + + return metrics; } private static List 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 fte = employees.Sum(x => x.Fte); + var absenceRate = fte <= 0 ? 0 : totalSick / (fte * 21m); var bu = employees.Sum(x => x.BuTage); var nbu = employees.Sum(x => x.NbuTage); @@ -486,7 +540,7 @@ internal sealed class HrKpiDashboardBuilder new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, new() { Label = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" }, new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" }, - new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / 21 Tage / Headcount", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, + new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / (FTE * 21 Tage)", Severity = absenceRate > 0.05m ? "Warning" : "Normal" }, new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" }, new() { Label = "NBU-Tage", Value = nbu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" }, new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" } @@ -520,17 +574,17 @@ internal sealed class HrKpiDashboardBuilder private static HrTurnoverVisuals BuildTurnoverVisuals( IReadOnlyCollection employees, + IReadOnlyCollection turnoverHeadcountLeavers, IReadOnlyCollection leavers, - int year) + TurnoverPeriodScope period) { - var fixedHeadcount = CountDistinctPersons(employees - .Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Personalnummer)); + var turnoverIntervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers); + var fixedHeadcount = ResolveTurnoverDenominator(employees, turnoverIntervals, period); var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer)); var employeeResignations = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer)); var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer)); var notRelevant = Math.Max(0, totalLeavers - relevantLeavers); - var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / (decimal)fixedHeadcount * 100m; + var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / fixedHeadcount * 100m; var gaugeColor = ratePercent > 12m ? "#c62828" : ratePercent >= 8m ? "#f9a825" : "#2e7d32"; var maxFunnel = Math.Max(totalLeavers, 1); @@ -540,9 +594,9 @@ internal sealed class HrKpiDashboardBuilder .Select((g, index) => new HrKpiGroupValue { Label = g.Key, - Count = g.Count(), - Value = g.Count(), - Percent = totalLeavers == 0 ? 0 : g.Count() / (decimal)totalLeavers * 100m, + Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), + Value = CountDistinctPersons(g.Select(x => x.Personalnummer)), + Percent = totalLeavers == 0 ? 0 : CountDistinctPersons(g.Select(x => x.Personalnummer)) / (decimal)totalLeavers * 100m, Color = reasonColors[index % reasonColors.Length] }) .OrderByDescending(x => x.Count) @@ -554,9 +608,9 @@ internal sealed class HrKpiDashboardBuilder .Select(g => new HrKpiGroupValue { Label = g.Key, - Count = g.Count(), - Value = g.Count(), - Percent = relevantLeavers == 0 ? 0 : g.Count() / (decimal)relevantLeavers * 100m, + Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), + Value = CountDistinctPersons(g.Select(x => x.Personalnummer)), + Percent = relevantLeavers == 0 ? 0 : CountDistinctPersons(g.Select(x => x.Personalnummer)) / (decimal)relevantLeavers * 100m, Color = "#1565c0" }) .OrderByDescending(x => x.Count) @@ -564,14 +618,44 @@ internal sealed class HrKpiDashboardBuilder .Take(10) .ToList(); - var monthly = Enumerable.Range(1, 12) + var timeline = period.BreakdownYear.HasValue + ? BuildMonthlyTurnoverTimeline(leavers, relevantLeavers, period.BreakdownYear.Value) + : BuildYearlyTurnoverTimeline(leavers, relevantLeavers); + + return new HrTurnoverVisuals + { + RateTitle = period.ShowPeriodMetrics ? $"Jahres-Fluktuation {period.Label}" : "Fluktuation Auswahl", + YearRatePercent = ratePercent, + YearRateLabel = (ratePercent / 100m).ToString("P1"), + GaugeColor = gaugeColor, + GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m, + TimelineTitle = period.BreakdownYear.HasValue ? "Relevante Austritte pro Monat" : "Relevante Austritte pro Jahr", + FunnelSteps = + [ + new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" }, + new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" }, + new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" }, + new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" } + ], + ExclusionReasons = reasons, + RelevantByOrganisation = relevantByOrg, + MonthlyRelevantLeavers = timeline + }; + } + + private static List BuildMonthlyTurnoverTimeline( + IReadOnlyCollection leavers, + int relevantLeavers, + int year) + => Enumerable.Range(1, 12) .Select(month => { - var count = leavers.Count(x => - x.IstFluktuationsrelevant && - x.Austrittsdatum.HasValue && - x.Austrittsdatum.Value.Year == year && - x.Austrittsdatum.Value.Month == month); + var count = CountDistinctPersons(leavers + .Where(x => x.IstFluktuationsrelevant && + x.Austrittsdatum.HasValue && + x.Austrittsdatum.Value.Year == year && + x.Austrittsdatum.Value.Month == month) + .Select(x => x.Personalnummer)); return new HrKpiGroupValue { Label = CultureInfo.GetCultureInfo("de-CH").DateTimeFormat.GetAbbreviatedMonthName(month), @@ -583,42 +667,178 @@ internal sealed class HrKpiDashboardBuilder }) .ToList(); - return new HrTurnoverVisuals + private static List BuildYearlyTurnoverTimeline( + IReadOnlyCollection leavers, + int relevantLeavers) + => leavers + .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr.HasValue) + .GroupBy(x => x.Austrittsjahr!.Value) + .OrderBy(g => g.Key) + .Select(g => + { + var count = CountDistinctPersons(g.Select(x => x.Personalnummer)); + return new HrKpiGroupValue + { + Label = g.Key.ToString(CultureInfo.InvariantCulture), + Count = count, + Value = count, + Percent = relevantLeavers == 0 ? 0 : count / (decimal)relevantLeavers * 100m, + Color = "#00897b" + }; + }) + .ToList(); + + private static decimal ResolveTurnoverDenominator( + IReadOnlyCollection employees, + IReadOnlyCollection intervals, + TurnoverPeriodScope period) + { + if (period.ShowPeriodMetrics && period.BreakdownYear.HasValue) { - YearRatePercent = ratePercent, - YearRateLabel = (ratePercent / 100m).ToString("P1"), - GaugeColor = gaugeColor, - GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m, - FunnelSteps = - [ - new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" }, - new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" }, - new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" }, - new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" } - ], - ExclusionReasons = reasons, - RelevantByOrganisation = relevantByOrg, - MonthlyRelevantLeavers = monthly - }; + return CalculateAverageFixedHeadcount( + intervals, + Enumerable.Range(1, 12).Select(month => (period.BreakdownYear.Value, month))); + } + + return CountCurrentFixedHeadcount(employees); } + private static int CountCurrentFixedHeadcount(IReadOnlyCollection employees) + => CountDistinctPersons(employees + .Where(x => IsFixedEmployee(x.Mitarbeitertyp)) + .Select(x => x.Personalnummer)); + + private static List BuildTurnoverIntervals( + IReadOnlyCollection employees, + IReadOnlyCollection leavers) + { + var intervals = new List(); + + intervals.AddRange(employees + .Where(x => x.Personalnummer.HasValue && IsFixedEmployee(x.Mitarbeitertyp)) + .Select(x => new TurnoverEmploymentInterval(x.Personalnummer!.Value, x.Eintrittsdatum?.Date, null))); + + intervals.AddRange(leavers + .Where(x => x.Personalnummer.HasValue && IsFixedEmployee(x.Mitarbeitertyp)) + .Select(x => new TurnoverEmploymentInterval(x.Personalnummer!.Value, x.Eintrittsdatum?.Date, x.Austrittsdatum?.Date))); + + return intervals; + } + + private static decimal CalculateMonthlyAverageFixedHeadcount( + IReadOnlyCollection intervals, + int year, + int month) + { + var start = new DateTime(year, month, 1); + var end = start.AddMonths(1).AddDays(-1); + return (CountFixedHeadcountOn(intervals, start) + CountFixedHeadcountOn(intervals, end)) / 2m; + } + + private static decimal CalculateAverageFixedHeadcount( + IReadOnlyCollection intervals, + IEnumerable<(int Year, int Month)> months) + { + var monthlyHeadcounts = months + .Select(x => CalculateMonthlyAverageFixedHeadcount(intervals, x.Year, x.Month)) + .ToList(); + + return monthlyHeadcounts.Count == 0 ? 0 : monthlyHeadcounts.Average(); + } + + private static int CountFixedHeadcountOn(IReadOnlyCollection intervals, DateTime date) + => intervals + .Where(x => (!x.Eintrittsdatum.HasValue || x.Eintrittsdatum.Value <= date) && + (!x.Austrittsdatum.HasValue || x.Austrittsdatum.Value >= date)) + .Select(x => x.Personalnummer) + .Distinct() + .Count(); + + private static IEnumerable BuildQuarterMonths(int quarter) + { + var normalizedQuarter = Math.Clamp(quarter, 1, 4); + return Enumerable.Range(((normalizedQuarter - 1) * 3) + 1, 3); + } + + private static bool IsFixedEmployee(string employeeType) + => string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase); + + private static string FormatHeadcount(decimal value) + => decimal.Remainder(value, 1m) == 0 ? value.ToString("N0") : value.ToString("N1"); + private static string? NormalizeFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static int ResolveAnalysisYear(HrKpiOptions options) - => (options.ToDate ?? options.FromDate)?.Year ?? options.Year; + private static TurnoverPeriodScope ResolveTurnoverPeriodScope(HrKpiOptions options, IReadOnlyCollection leavers) + { + var hasRange = options.FromDate.HasValue || options.ToDate.HasValue; + var selectedYears = leavers + .Where(x => x.Austrittsjahr.HasValue) + .Select(x => x.Austrittsjahr!.Value) + .Distinct() + .OrderBy(x => x) + .ToList(); - private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int analysisYear) + int? breakdownYear = null; + var showPeriodMetrics = false; + if (!hasRange && options.Year.HasValue) + { + breakdownYear = options.Year.Value; + showPeriodMetrics = true; + } + else if (selectedYears.Count == 1) + { + breakdownYear = selectedYears[0]; + } + + var anchorDate = ResolveTurnoverAnchorDate(options, breakdownYear, leavers); + var label = showPeriodMetrics && breakdownYear.HasValue + ? breakdownYear.Value.ToString(CultureInfo.InvariantCulture) + : BuildTurnoverSelectionLabel(options, selectedYears); + + return new TurnoverPeriodScope(breakdownYear, anchorDate, label, showPeriodMetrics); + } + + private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int? breakdownYear, IReadOnlyCollection leavers) { if (options.ToDate.HasValue) return options.ToDate.Value.Date; if (options.FromDate.HasValue) return options.FromDate.Value.Date; - return analysisYear == DateTime.Today.Year + if (breakdownYear.HasValue) + { + return breakdownYear.Value == DateTime.Today.Year ? DateTime.Today - : new DateTime(analysisYear, 12, 31); + : new DateTime(breakdownYear.Value, 12, 31); + } + + return leavers + .Where(x => x.Austrittsdatum.HasValue) + .Select(x => x.Austrittsdatum!.Value.Date) + .DefaultIfEmpty(DateTime.Today) + .Max(); } + private static string BuildTurnoverSelectionLabel(HrKpiOptions options, IReadOnlyList selectedYears) + { + if (options.FromDate.HasValue || options.ToDate.HasValue) + { + var from = options.FromDate?.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-CH")) ?? "..."; + var to = options.ToDate?.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-CH")) ?? "..."; + return $"{from} - {to}"; + } + + if (selectedYears.Count == 1) + return selectedYears[0].ToString(CultureInfo.InvariantCulture); + + return "Auswahl"; + } + + private static bool HasEmployeeOnlyTurnoverFilters(HrKpiOptions options) + => !string.IsNullOrWhiteSpace(options.KostenstelleText) || + !string.IsNullOrWhiteSpace(options.GlzAmpel) || + !string.IsNullOrWhiteSpace(options.RestferienAmpel); + private static bool MatchesLeaverDateFilter(HrLeaverRow row, HrKpiOptions options) { var hasRange = options.FromDate.HasValue || options.ToDate.HasValue; @@ -630,7 +850,34 @@ internal sealed class HrKpiDashboardBuilder (!options.ToDate.HasValue || row.Austrittsdatum.Value.Date <= options.ToDate.Value); } - return row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year; + return !options.Year.HasValue || + (row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year.Value); + } + + private static bool MatchesLeaverEmploymentPeriodFilter(HrLeaverRow row, HrKpiOptions options) + { + var period = ResolveEmploymentPeriod(options); + if (!period.HasValue) + return true; + + var entry = row.Eintrittsdatum?.Date ?? DateTime.MinValue; + var exit = row.Austrittsdatum?.Date ?? DateTime.MaxValue; + return entry <= period.Value.End && exit >= period.Value.Start; + } + + private static (DateTime Start, DateTime End)? ResolveEmploymentPeriod(HrKpiOptions options) + { + if (options.Year.HasValue && !options.FromDate.HasValue && !options.ToDate.HasValue) + { + return (new DateTime(options.Year.Value, 1, 1), new DateTime(options.Year.Value, 12, 31)); + } + + if (!options.FromDate.HasValue && !options.ToDate.HasValue) + return null; + + var start = options.FromDate?.Date ?? new DateTime(options.ToDate!.Value.Year, 1, 1); + var end = options.ToDate?.Date ?? new DateTime(start.Year, 12, 31); + return start <= end ? (start, end) : (end, start); } private static int CountDistinctPersons(IEnumerable personalNumbers) @@ -768,7 +1015,15 @@ internal sealed class HrKpiDashboardBuilder private static string NormalizeReason(string value) { - var normalized = RemoveDiacritics(value).Trim().ToLowerInvariant(); + var normalized = value + .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) + .Replace("Ä", "Ae", StringComparison.Ordinal) + .Replace("Ö", "Oe", StringComparison.Ordinal) + .Replace("Ü", "Ue", StringComparison.Ordinal) + .Replace("ß", "ss", StringComparison.OrdinalIgnoreCase); + normalized = RemoveDiacritics(normalized).Trim().ToLowerInvariant(); return normalized .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) @@ -781,7 +1036,9 @@ internal sealed class HrKpiDashboardBuilder if (!string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase)) return employeeType; if (string.IsNullOrWhiteSpace(reason)) return "Austrittsart leer/unklar"; if (reason.Contains("befrist", StringComparison.OrdinalIgnoreCase)) return "Befristeter Vertrag"; - if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) || reason.Contains("rente", StringComparison.OrdinalIgnoreCase)) return "Pensionierung"; + if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) || + reason.Contains("rente", StringComparison.OrdinalIgnoreCase) || + reason.Contains("ruhestand", StringComparison.OrdinalIgnoreCase)) return "Pensionierung"; if (reason.Contains("trafag", StringComparison.OrdinalIgnoreCase) || reason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) || reason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) || @@ -899,6 +1156,10 @@ internal sealed class HrKpiDashboardBuilder return builder.ToString().Normalize(NormalizationForm.FormC); } + private sealed record TurnoverPeriodScope(int? BreakdownYear, DateTime AnchorDate, string Label, bool ShowPeriodMetrics); + + private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum); + private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag); private sealed record SapRow( diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs index 933742c..83b1b43 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs +++ b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs @@ -604,6 +604,7 @@ static string BuildPage( var checkCount = rows.Count(r => r.Status == "Pruefen"); var missingCount = rows.Count(r => r.Status == "Keine Daten"); var excelCount = excelReferences.Count; + var financeChiefOverview = BuildFinanceChiefOverview(rows, excelReferences, spainCsv, germanySample); var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample); var detailRows = BuildDetailRows(rows, excelReferences, spainCsv); var coverageRows = BuildCoverageRows(coverage); @@ -760,6 +761,25 @@ static string BuildPage( margin: 0 0 10px; line-height: 1.45; } + .chief-note { + color: var(--muted); + margin: 0; + line-height: 1.45; + } + .chief-summary { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 10px; + margin: 10px 0; + } + .chief-summary .metric { + margin: 0; + } + .chief-action { + min-width: 240px; + max-width: 380px; + line-height: 1.35; + } .ampel { display: inline-flex; align-items: center; @@ -800,6 +820,7 @@ static string BuildPage( Aktualisiert: {{Html(generatedAt)}}
+ {{financeChiefOverview}} {{executiveBriefing}}
{{rows.Count}}Standorte
@@ -954,6 +976,181 @@ static string BuildDetailRows( .Select(row => row.Html)); } +static string BuildFinanceChiefOverview( + IReadOnlyList rows, + IReadOnlyDictionary excelReferences, + SpainSalesCsvProbe? spainCsv, + GermanyExcelProbe? germanySample) +{ + var issues = BuildFinanceChiefIssues(rows, excelReferences, spainCsv, germanySample).ToList(); + var openIssues = issues + .Where(issue => issue.Status != "OK") + .OrderByDescending(issue => issue.SortValue) + .ThenBy(issue => issue.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var missingCount = openIssues.Count(issue => issue.Status == "Keine Daten"); + var checkCount = openIssues.Count(issue => issue.Status == "Pruefen"); + var largestDifference = openIssues + .Where(issue => issue.Difference.HasValue) + .Select(issue => Math.Abs(issue.Difference!.Value)) + .DefaultIfEmpty(0m) + .Max(); + var tableRows = openIssues.Count == 0 + ? """ + + Keine offenen Abweichungen. Alle vorhandenen Laender passen rechnerisch gegen den Sollwert. + +""" + : string.Join(Environment.NewLine, openIssues.Select(BuildFinanceChiefIssueRow)); + + return $$""" +
+

Finanzchef Übersicht

+

Kompakte Sicht nur auf offene Soll/Ist-Themen. Detailtabellen bleiben unten fuer Analyse und Nachvollzug.

+
+
{{openIssues.Count}}Offen
+
{{checkCount}}Abweichungen
+
{{missingCount}}Keine Daten
+
+
Groesste absolute Abweichung: {{Amount(largestDifference)}}
+
+ + + + + + + + + + + + + {{tableRows}} +
StatusLandWaehrungIstSollAbweichungWas ist zu pruefen
+
+
+"""; +} + +static IEnumerable BuildFinanceChiefIssues( + IReadOnlyList rows, + IReadOnlyDictionary excelReferences, + SpainSalesCsvProbe? spainCsv, + GermanyExcelProbe? germanySample) +{ + var issues = rows + .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) + .Select(row => new FinanceChiefIssue( + row.Status, + row.Label, + row.Key, + BuildFinanceChiefCurrency(row), + row.ActualValue, + row.ReferenceValue, + row.Difference, + BuildFinanceChiefReason(row, germanySample), + row.Difference.HasValue ? Math.Abs(row.Difference.Value) : decimal.MaxValue)) + .ToList(); + + if (spainCsv is not null) + { + var status = Math.Abs(spainCsv.Difference) <= 1m ? "OK" : "Pruefen"; + issues.Add(new FinanceChiefIssue( + status, + "Trafag ES", + "ES", + "EUR", + spainCsv.SalesPriceValue, + spainCsv.ReferenceValue, + spainCsv.Difference, + status == "OK" + ? "Spain CSV passt rechnerisch gegen check.xlsx." + : "Spain CSV hat Differenz. Periodenabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften pruefen.", + Math.Abs(spainCsv.Difference))); + } + + var existingLabels = issues + .Select(issue => issue.Label) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var reference in excelReferences.Values) + { + if (existingLabels.Contains(reference.Label)) + continue; + + var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; + issues.Add(new FinanceChiefIssue( + "Keine Daten", + reference.Label, + "check.xlsx", + reference.PowerBiValue.HasValue ? "Sollwert" : "LC", + null, + referenceValue, + null, + "Sollwert ist in check.xlsx vorhanden, aber es gibt keinen belastbaren Ist-Import. Standort, Export oder Aktivierung pruefen.", + decimal.MaxValue)); + } + + return issues; +} + +static string BuildFinanceChiefIssueRow(FinanceChiefIssue issue) +{ + var statusClass = issue.Status.Replace(" ", string.Empty); + + return $$""" + + {{Html(issue.Status)}} + {{Html(issue.Label)}}
{{Html(issue.Key)}}
+ {{Html(issue.Currency)}} + {{Amount(issue.ActualValue)}} + {{Amount(issue.ReferenceValue)}} + {{Amount(issue.Difference)}} + {{Html(issue.Reason)}} + +"""; +} + +static string BuildFinanceChiefCurrency(NetSalesReferenceRow row) +{ + var actualCurrency = string.IsNullOrWhiteSpace(row.ActualCurrency) ? row.Currencies : row.ActualCurrency; + var referenceCurrency = row.ReferenceCurrency; + + if (string.IsNullOrWhiteSpace(actualCurrency) && string.IsNullOrWhiteSpace(referenceCurrency)) + return "-"; + if (string.IsNullOrWhiteSpace(referenceCurrency) || + referenceCurrency.Equals("Sollwert", StringComparison.OrdinalIgnoreCase) || + actualCurrency.Equals(referenceCurrency, StringComparison.OrdinalIgnoreCase)) + return actualCurrency; + if (string.IsNullOrWhiteSpace(actualCurrency)) + return referenceCurrency; + + return $"{actualCurrency} / Soll {referenceCurrency}"; +} + +static string BuildFinanceChiefReason(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) +{ + if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase) && germanySample is not null) + return "DE-Beispielfile ist lesbar, aber nur Sample. Finalen Jahresexport/Abgrenzung pruefen."; + + if (row.Status == "OK") + return "Passt rechnerisch gegen check.xlsx."; + + if (row.Status == "Keine Daten") + return "Kein belastbarer Ist-Import. Standort, Export, Mapping oder Aktivierung pruefen."; + + if (row.DifferenceExcludingIntercompany.HasValue && + Math.Abs(row.DifferenceExcludingIntercompany.Value) <= 1m) + return "Abweichung ist nach 2nd-party/Intercompany-Abzug erklaerbar. IC-Regel fachlich bestaetigen."; + + if (row.Candidates.Count > 1) + return "Abweichung offen. Gewaehlter Wert folgt Hauswaehrung/Nettofakturawert; alternative Summen sind in Details sichtbar."; + + return "Abweichung offen. Quelle, Periodenabgrenzung, Gutschriften und 2nd-party/3rd-party-Abgrenzung pruefen."; +} + static string BuildExecutiveBriefing( IReadOnlyList rows, IReadOnlyDictionary excelReferences, @@ -1302,6 +1499,17 @@ static string Amount(decimal? value) static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); +sealed record FinanceChiefIssue( + string Status, + string Label, + string Key, + string Currency, + decimal? ActualValue, + decimal? ReferenceValue, + decimal? Difference, + string Reason, + decimal SortValue); + sealed class CheckedExcelReference { public string Label { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/HrKpiServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/HrKpiServiceTests.cs index e9b90a8..3b0889d 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/HrKpiServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/HrKpiServiceTests.cs @@ -28,6 +28,12 @@ public sealed class HrKpiServiceTests : IDisposable Directory.Delete(_folder, recursive: true); } + [Fact] + public void HrKpiOptions_Default_Exit_Year_Is_Empty() + { + Assert.Null(new HrKpiOptions().Year); + } + [Fact] public async Task BuildAsync_Applies_Organisation_Filter_To_Absences() { @@ -60,6 +66,90 @@ public sealed class HrKpiServiceTests : IDisposable Assert.DoesNotContain(result.Leavers, row => row.Austrittsdatum?.Year == 2024); } + [Fact] + public async Task BuildAsync_With_Empty_Exit_Year_Includes_All_Leaver_Years() + { + var result = await _service.BuildAsync(new HrKpiOptions + { + DataFolder = _folder, + Year = null + }); + + Assert.Contains(2025, result.ExitYearOptions); + Assert.Contains(2024, result.ExitYearOptions); + Assert.Contains(result.Leavers, row => row.Austrittsdatum?.Year == 2025); + Assert.Contains(result.Leavers, row => row.Austrittsdatum?.Year == 2024); + } + + [Fact] + public async Task BuildAsync_Employee_Only_Filters_Do_Not_Distort_Turnover_Denominator() + { + var result = await _service.BuildAsync(new HrKpiOptions + { + DataFolder = _folder, + Year = 2025, + KostenstelleText = "100 / Org A" + }); + + var activeHeadcount = Assert.Single(result.Metrics, metric => metric.Label == "Headcount aktiv"); + Assert.Equal("1", activeHeadcount.Value); + + var turnoverHeadcount = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Headcount Festangestellt"); + Assert.Equal(3.0m.ToString("N1"), turnoverHeadcount.Value); + Assert.Contains(result.Notices, notice => notice.Contains("nicht die Fluktuation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task BuildAsync_Uses_Average_Headcount_For_Turnover_Formulas() + { + RewriteEmployeeRows( + [ + [4001, "Stable, Anna", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"], + [4002, "Stable, Bruno", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"], + [4003, "Stable, Carla", "Org A", "100 / Org A", "Engineer", "n", new DateTime(2020, 1, 1), "Aktiv", "0:00", 25, 0, 0, 100000, "CHF"] + ]); + RewriteLeaverRows( + [ + [5001, "Leaving, Lea", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 30), new DateTime(2025, 1, 1), "Kündigung AN"] + ]); + + var result = await _service.BuildAsync(new HrKpiOptions + { + DataFolder = _folder, + Year = 2025 + }); + + var avgYearHeadcount = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Avg Headcount Jahr"); + Assert.Equal(3.5m.ToString("N1"), avgYearHeadcount.Value); + + var yearRate = Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Fluktuation Jahr Effektiv %"); + Assert.Equal((1m / 3.5m).ToString("P1"), yearRate.Value); + Assert.Equal("Austritte Jahr / Avg HC Jahr", yearRate.Detail); + } + + [Fact] + public async Task BuildAsync_Uses_Distinct_Persons_In_Turnover_Visuals() + { + AppendLeaverRow( + 1001, + "Alpha, Anna", + "Org A", + "Engineer", + new DateTime(2025, 3, 20), + new DateTime(2020, 1, 1), + "Arbeitnehmer Kuendigung"); + + var result = await _service.BuildAsync(new HrKpiOptions + { + DataFolder = _folder, + Year = 2025 + }); + + Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers[2].Count); + Assert.Equal(1, result.TurnoverVisuals.RelevantByOrganisation.Single(row => row.Label == "Org A").Count); + Assert.Equal(3, result.TurnoverVisuals.FunnelSteps.Single(row => row.Label == "Austritte Total").Count); + } + [Fact] public async Task BuildAsync_Excludes_Missing_Personalnummer_From_Distinct_Headcount_And_Uses_Fte_Fallback() { @@ -76,6 +166,9 @@ public sealed class HrKpiServiceTests : IDisposable Assert.Null(fallbackEmployee.BeschaeftigungsgradProzent); Assert.Equal(0.5m, fallbackEmployee.Fte); + var absenceRate = Assert.Single(result.AbsenceMetrics, metric => metric.Label == "Krankenquote"); + Assert.Contains("FTE", absenceRate.Detail); + Assert.Contains(result.Notices, notice => notice.Contains("ohne Personalnummer", StringComparison.OrdinalIgnoreCase)); Assert.Contains(result.Notices, notice => notice.Contains("FTE-Fallback", StringComparison.OrdinalIgnoreCase)); } @@ -93,7 +186,29 @@ public sealed class HrKpiServiceTests : IDisposable 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); + Assert.Equal(1, result.TurnoverVisuals.MonthlyRelevantLeavers[2].Count); + } + + [Fact] + public async Task BuildAsync_Recognizes_Rexx_Kuendigung_AN_And_AG() + { + RewriteLeaverRows( + [ + [3001, "Employee, Eva", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 1), new DateTime(2020, 1, 1), "Kündigung AN"], + [3002, "Employer, Emil", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 2), new DateTime(2020, 1, 1), "Kündigung AG"], + [3003, "Retired, Rita", "Org A", "Engineer", "Inaktiv", new DateTime(2025, 6, 3), new DateTime(2020, 1, 1), "Ruhestand"] + ]); + + var result = await _service.BuildAsync(new HrKpiOptions + { + DataFolder = _folder, + Year = 2025 + }); + + Assert.Equal("1", Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Austritte Arbeitnehmerkuendigung").Value); + Assert.Equal("1", Assert.Single(result.TurnoverMetrics, metric => metric.Label == "Austritte Fluktuationsrelevant").Value); + Assert.Contains(result.Leavers, row => row.Austrittsart == "Kündigung AG" && row.FluktuationAusschlussgrund == "Kuendigung durch Trafag"); + Assert.Contains(result.Leavers, row => row.Austrittsart == "Ruhestand" && row.FluktuationAusschlussgrund == "Pensionierung"); } private static void WriteFixtureFiles(string folder) @@ -151,10 +266,58 @@ public sealed class HrKpiServiceTests : IDisposable [ [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"] + [2001, "Trainee, Tom", "Org A", "Praktikant", "Inaktiv", new DateTime(2025, 5, 5), new DateTime(2025, 1, 1), "Arbeitnehmer Kuendigung"], + [1003, "Fallback, Fiona", "Org B", "Engineer", "Inaktiv", new DateTime(2024, 12, 15), new DateTime(2025, 1, 15), "Arbeitnehmer Kuendigung"] ]); } + private void AppendLeaverRow( + int personalNumber, + string name, + string organisation, + string position, + DateTime exitDate, + DateTime entryDate, + string exitType) + { + var path = Path.Combine(_folder, "Personalausgeschieden.xlsx"); + using var workbook = new XLWorkbook(path); + var sheet = workbook.Worksheets.First(); + var row = sheet.LastRowUsed()!.RowNumber() + 1; + + sheet.Cell(row, 1).Value = personalNumber; + sheet.Cell(row, 2).Value = name; + sheet.Cell(row, 3).Value = organisation; + sheet.Cell(row, 4).Value = position; + sheet.Cell(row, 5).Value = "Inaktiv"; + sheet.Cell(row, 6).Value = exitDate; + sheet.Cell(row, 7).Value = entryDate; + sheet.Cell(row, 8).Value = exitType; + + workbook.Save(); + } + + private void RewriteLeaverRows(object?[][] rows) + { + WriteWorkbook(Path.Combine(_folder, "Personalausgeschieden.xlsx"), + [ + "Personalnummer", "Nachname, Vorname (Link Personal)", "Organisation-1", "Stelle-1", + "Personal Status", "Austrittsdatum", "Eintrittsdatum", "Austrittsart" + ], + rows); + } + + private void RewriteEmployeeRows(object?[][] rows) + { + 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" + ], + rows); + } + private static void WriteWorkbook(string path, string[] headers, object?[][] rows) { using var workbook = new XLWorkbook(); diff --git a/TrafagSalesExporter/docs/CFO_Kurzbericht_270515.docx b/TrafagSalesExporter/docs/CFO_Kurzbericht_270515.docx new file mode 100644 index 0000000..459edea Binary files /dev/null and b/TrafagSalesExporter/docs/CFO_Kurzbericht_270515.docx differ diff --git a/TrafagSalesExporter/docs/FINANCE_DASHBOARD_TODO_2026-05-15.md b/TrafagSalesExporter/docs/FINANCE_DASHBOARD_TODO_2026-05-15.md new file mode 100644 index 0000000..0053fef --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_DASHBOARD_TODO_2026-05-15.md @@ -0,0 +1,31 @@ +# Finance Dashboard Todo + +Stand: 2026-05-15 + +Ziel: Aufbau eines modernen, uebersichtlichen Intranet-Dashboards fuer das Group Sales Reporting, zugaenglich fuer alle freigegebenen Benutzer. + +## Todo + +| Prio | Aufgabe | Status | +| --- | --- | --- | +| 1 | Fuehrendes CFO-Dokument verwenden: `FINANCE_CHEF_SUMMARY_2026-05-15.docx` | Offen | +| 1 | Offene Finance-Entscheide mit Andreas/Finance durchgehen | Offen | +| 1 | Italien-Abweichung klaeren: Berechnungsart, Deduplizierung, Intercompany | Offen | +| 1 | Deutschland: finalen Jahresfile 2025 beschaffen | Offen | +| 2 | UK/England: Jahresvollstaendigkeit und Restdifferenz pruefen | Offen | +| 2 | CH/AT: Sollzuordnung und Trennung final bestaetigen | Offen | +| 2 | Spanien und Oesterreich: kleinere Differenzen klaeren | Offen | +| 2 | Intercompany-/2nd-party-Kundenliste final definieren | Offen | +| 3 | Budgetkurse je Jahr als Quelle festlegen | Offen | +| 3 | Dashboard-Sicht fuer CFO: nur Laender mit Abweichung und Handlungsbedarf anzeigen | In Arbeit | +| 3 | Detailansicht je Land mit Berechnungsart und Pruefspur behalten | In Arbeit | +| 3 | Freigabe-/Berechtigungskonzept fuer Intranet-Dashboard klaeren | Offen | + +## Naechster Termin + +Ziel im Termin: +- Deutschland und Spanien muss finales Excel schicken (Rohali 2 mal nachgehakt warte auf finales File) +- Grundlogik bestaetigen: Hauswaehrung, Nettofakturawert, Buchungsdatum, Berechnung pro Belegposition. +- Offene Laenderabweichungen priorisieren. +- Pro Land festlegen, welche Datenquelle und Berechnungslogik final gilt. + diff --git a/TrafagSalesExporter/docs/FINANCE_ES_MAIL_ABWEICHUNG_2026-05-15.md b/TrafagSalesExporter/docs/FINANCE_ES_MAIL_ABWEICHUNG_2026-05-15.md new file mode 100644 index 0000000..52e09ea --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_ES_MAIL_ABWEICHUNG_2026-05-15.md @@ -0,0 +1,36 @@ +# Email an Spanien: Abweichung Net Sales 2025 + +**Asunto:** Revision diferencia Net Sales 2025 - Espana + +Hola, + +En la reconciliacion de Net Sales 2025 para Espana hemos identificado una diferencia pendiente de aclarar contra el valor de referencia de Rhino / `check.xlsx`. + +Resumen: + +- Actual Espana: `3.082.320,18 EUR` +- Referencia Rhino: `3.102.333,61 EUR` +- Diferencia: `-20.013,43 EUR` + +La diferencia no parece muy grande, pero antes de cerrar el dato necesitamos confirmar la causa. + +Por favor, podeis revisar los siguientes puntos? + +1. Periodo incluido en el fichero + Confirmar que el fichero `Spain_Sales_2025.csv` contiene todo el ano 2025 completo. + +2. Series incluidas + Confirmar que las series `REG`, `LAT`, `PRO` y `REC` deben incluirse en el calculo de Net Sales 2025. + +3. Abonos / Credit Notes + Confirmar que los abonos estan incluidos correctamente y con signo negativo. + +4. Criterio de fecha + Confirmar que fecha debe utilizarse para la delimitacion del ano 2025: fecha de factura, fecha contable u otra fecha. + +5. Importe correcto + Confirmar si el campo utilizado como importe neto de ventas es el correcto para comparar contra Rhino / `check.xlsx`. + +Objetivo: aclarar si la diferencia de `-20.013,43 EUR` se explica por periodo, series, abonos o por el campo de importe utilizado. + +Gracias y saludos, diff --git a/TrafagSalesExporter/docs/FINANCE_IT_MAIL_ABWEICHUNG_2026-05-15.md b/TrafagSalesExporter/docs/FINANCE_IT_MAIL_ABWEICHUNG_2026-05-15.md new file mode 100644 index 0000000..c7b7694 --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_IT_MAIL_ABWEICHUNG_2026-05-15.md @@ -0,0 +1,39 @@ +# Email an Italien: Abweichung Net Sales 2025 + +**Oggetto:** Verifica differenza Net Sales 2025 - Italia + +Ciao, + +nella riconciliazione dei Net Sales 2025 per l'Italia abbiamo identificato una differenza significativa rispetto al valore di riferimento Rhino / `check.xlsx`. + +Riepilogo: + +- Actual Italia: `14.704.336,29 EUR` +- Riferimento Rhino: `7.669.840,00 EUR` +- Differenza: `+7.034.496,29 EUR` + +La differenza e molto rilevante. Prima di chiudere il dato dobbiamo confermare quale logica di calcolo e corretta per l'Italia. + +Potete per favore verificare i seguenti punti? + +1. Base di calcolo corretta + Confermare quale valore deve essere usato per i Net Sales 2025: valore netto per riga/posizione, totale documento deduplicato oppure un'altra logica. + +2. Totali documento vs. posizioni + Verificare se i totali documento sono ripetuti su piu righe e quindi devono essere conteggiati una sola volta per documento. + +3. Intercompany / 2nd-party + Confermare quali clienti o transazioni devono essere esclusi come Intercompany o 2nd-party. + +4. Note di credito / Credit Notes + Confermare che le note di credito sono incluse correttamente e con segno negativo. + +5. Criterio data + Confermare quale data deve essere utilizzata per delimitare il 2025: data registrazione, data fattura o altra data. + +6. Valuta + Confermare che la valuta di confronto per l'Italia e `EUR`. + +Obiettivo: capire se la differenza di `+7.034.496,29 EUR` deriva da doppio conteggio dei documenti, trattamento Intercompany, criteri data o dal campo importo utilizzato. + +Grazie e saluti, diff --git a/TrafagSalesExporter/docs/FINANCE_UK_MAIL_ABWEICHUNG_2026-05-15.md b/TrafagSalesExporter/docs/FINANCE_UK_MAIL_ABWEICHUNG_2026-05-15.md new file mode 100644 index 0000000..9e1dff2 --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_UK_MAIL_ABWEICHUNG_2026-05-15.md @@ -0,0 +1,42 @@ +# Email an UK / England: Abweichung Net Sales 2025 + +**Subject:** Review difference Net Sales 2025 - UK + +Hi, + +In the Net Sales 2025 reconciliation for UK / England, we identified a difference against the Rhino / `check.xlsx` reference value. + +Summary: + +- Actual UK: `3,533,710.09 GBP` +- Rhino reference: `3,749,865.00 GBP` +- Difference: `-216,154.91 GBP` + +The mapping has already been reviewed technically, but we still need to clarify the remaining difference before closing the 2025 value. + +Could you please check the following points? + +1. Full-year completeness + Confirm that the UK source file/import contains the full year 2025 and not only a partial period. + +2. Period included + We currently see data from approximately `03.01.2025` to `22.12.2025`. Please confirm whether this is complete for 2025 or if transactions outside this range are missing. + +3. Credit notes + Confirm that credit notes are included correctly and with the correct negative sign. + +4. Net sales field + Confirm which column should be used as the net sales amount for comparison with Rhino / `check.xlsx`. + +5. Discounts, freight or additional charges + Please check whether discounts, freight, additional charges or other adjustments are included in the Rhino reference but not in the current import value, or vice versa. + +6. 2nd-party / 3rd-party / Intercompany + Confirm whether any customers or transactions should be excluded from the Net Sales 2025 value. + +7. Currency + Confirm that the correct comparison currency for UK is `GBP`. + +Goal: clarify whether the difference of `-216,154.91 GBP` is caused by an incomplete period, credit notes, a different net sales field, adjustments, or 2nd-party/3rd-party handling. + +Thanks and best regards, diff --git a/TrafagSalesExporter/docs/FINANCE_WELCHES_DOKUMENT_GILT_2026-05-15.md b/TrafagSalesExporter/docs/FINANCE_WELCHES_DOKUMENT_GILT_2026-05-15.md new file mode 100644 index 0000000..543b313 --- /dev/null +++ b/TrafagSalesExporter/docs/FINANCE_WELCHES_DOKUMENT_GILT_2026-05-15.md @@ -0,0 +1,57 @@ +# Finance: Welches Dokument gilt? + +Stand: 2026-05-15 + +## Fuehrendes Dokument + +Fuer den CFO-/Finance-Termin gilt: + +```text +docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx +``` + +Dieses Dokument ist die aktuellste CFO-Kurzfassung mit Ampel, Laendertabelle, Pruefquellen, Prioritaeten und empfohlenen Massnahmen. + +## Geloeschte alte Fassung + +Die alte Fassung `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx` wurde entfernt, weil sie durch die Version vom 2026-05-15 ersetzt ist. + +## Entscheidbasis + +Die fachlichen Entscheide stehen separat hier: + +```text +entscheide.md +docs/FINANCE_ENTSCHEIDE.md +``` + +Kurzfassung der wichtigsten Entscheide: + +| Thema | Entscheid | +| --- | --- | +| Fuehrende Waehrung | Hauswaehrung je Land | +| CHF-Sicht | Nur separat mit Budgetkursen | +| Aggregation | Pro Artikel bzw. Belegposition | +| Wertbasis | Nettofakturawert | +| Jahresabgrenzung 2025 | Buchungsdatum | +| Gutschriften / Storno | Separat ausweisen, positionsbasiert behandeln | +| Intercompany / 2nd-party | Separat klassifizieren und als Auswahl/Sicht fuehren | +| Indien | INR ist fuehrend | +| Italien | Hauswaehrung, Intercompany separat pruefen | + +## Wichtig fuer Rueckfragen + +Falls im Termin gefragt wird, ob die Berechnungslogik schon entschieden ist: + +> Ja. Die Grundlogik ist entschieden: Hauswaehrung, Nettofakturawert, Buchungsdatum und Berechnung pro Belegposition. Offen sind vor allem Datenvollstaendigkeit, Intercompany-Abgrenzung, Budgetkursquelle und die fachliche Freigabe einzelner Laenderabweichungen. + +## Was noch nicht final ist + +| Thema | Status | +| --- | --- | +| IT | Kritisch; grosse Abweichung, Berechnungsart/IC/Deduplizierung pruefen | +| UK / EN | Hoch; Restdifferenz und Jahresvollstaendigkeit pruefen | +| DE | Hoch; finaler Jahresfile fehlt, Sample nicht verwenden | +| CH / AT | Hoch; Sollzuordnung und Trennung finalisieren | +| ES / AT | Mittel; kleinere Differenzen klaeren | +| FR / IN / US | Rechnerisch freigabefaehig | diff --git a/TrafagSalesExporter/docs/HR_KPI_NACHDOKU_2026-05-13.md b/TrafagSalesExporter/docs/HR_KPI_NACHDOKU_2026-05-13.md new file mode 100644 index 0000000..264c3de --- /dev/null +++ b/TrafagSalesExporter/docs/HR_KPI_NACHDOKU_2026-05-13.md @@ -0,0 +1,239 @@ +# HR KPI Nachdokumentation 2026-05-13 + +## Ziel + +Das HR KPI Cockpit wurde als separater, fachlich entkoppelter Reiter umgesetzt. Es nutzt PowerBI-M-/DAX-Logik nicht als generischen Interpreter, sondern als fachliche Vorlage, die in nachvollziehbare C#-Logik uebertragen wurde. + +Der Reiter ist vom Finance-/Management-Cockpit getrennt. Er nutzt nur gemeinsame technische Infrastruktur wie Blazor, MudBlazor, DI, ClosedXML und bestehende Programmstruktur. + +## Eingebaute HR KPI Funktion + +Neue Navigation: + +- `HR KPI` im Hauptmenue. +- Route: `/hr-kpi`. + +Neue zentrale Dateien: + +- `Components/Pages/HrKpi.razor` +- `Components/HrKpi/HrKpiDashboardTabs.razor` +- `Models/HrKpiModels.cs` +- `Services/HrKpiService.cs` +- `Services/HrKpi/HrKpiDashboardBuilder.cs` +- `TrafagSalesExporter.Tests/HrKpiServiceTests.cs` + +## Datenquellen + +Standard-Datenordner: + +```text +C:\temp +``` + +Konfigurierbar ueber `appsettings.json`: + +```json +"HrKpi": { + "DataFolder": "C:\\temp", + "MainFile": "Saldiperstichdatum.xlsx", + "TimeFile": "Exportkommengehen.xlsx", + "SapFile": "HR_KPI_Export.xlsx", + "AbsenceFile": "Abwesenheitinstunden.xlsx", + "LeaverFile": "Personalausgeschieden.xlsx" +} +``` + +Verarbeitete Dateien: + +- `Saldiperstichdatum.xlsx`: aktive Mitarbeitende, Saldi, Ferien, Organisation, Kostenstelle. +- `Exportkommengehen.xlsx`: Arbeitszeitmodell, Sollzeit, Geburtsdatum. +- `HR_KPI_Export.xlsx`: SAP-HR-Felder wie Beschaeftigungsgrad, Geschlecht, BU/NBU, Planstelle. +- `Abwesenheitinstunden.xlsx`: Krankheit kurz/lang in Stunden. +- `Personalausgeschieden.xlsx`: Austritte, Austrittsart, Austrittsdatum. + +## Dashboard-Reiter + +Das Cockpit zeigt folgende Tabs: + +- `Ueberblick` +- `Fluktuation` +- `Absenzen` +- `Zeit / Ferien` +- `Mitarbeitende` +- `Datenstatus` + +Im Fluktuationsbereich wurden zusaetzliche Visualisierungen ergaenzt: + +- Jahres-Fluktuations-Gauge +- Austritts-Funnel +- Donut nach Ausschlussgruenden +- relevante Austritte nach Organisation +- relevante Austritte pro Monat + +## Filter + +Aktuell vorhandene Filter: + +- Datenordner +- Austrittsjahr +- Von Austritt +- Bis Austritt +- Organisation +- Eintrittsjahr +- Suche Name / Personalnummer +- Kostenstelle +- Mitarbeitertyp +- Fluktuation +- GLZ-Ampel +- Restferien-Ampel + +## Korrektur Austrittsjahr / Von-Bis + +Problem: + +- `Austrittsjahr` war als `int` modelliert. +- Dadurch war immer ein Jahr gesetzt. +- Leeren bzw. "alle Austrittsjahre" war nicht moeglich. +- Aus Sicht UI wirkte es so, als ob leere Auswahl nicht uebernommen wird. + +Umsetzung: + +- `HrKpiOptions.Year` wurde von `int` auf `int?` geaendert. +- `Austrittsjahr` ist in der UI jetzt ein `MudSelect` mit `Clearable`. +- Die Jahresauswahl wird aus den vorhandenen Austrittsdaten gebaut. +- Neues Result-Feld: `ExitYearOptions`. +- Wenn `Austrittsjahr` leer ist, werden alle Austrittsjahre geladen. +- Wenn `Von Austritt` oder `Bis Austritt` gesetzt ist, hat dieser Zeitraum Vorrang vor `Austrittsjahr`. + +Regel: + +```text +Von/Bis gesetzt -> Austrittsdatum muss im Zeitraum liegen +Von/Bis leer und Austrittsjahr gesetzt -> Austrittsjahr muss passen +Von/Bis leer und Austrittsjahr leer -> alle Austritte +``` + +Nach der Architektur-/Formelpruefung wurde zusaetzlich korrigiert: + +- `Austrittsjahr` ist auch beim Start leer und wird nicht mehr automatisch mit dem aktuellen Kalenderjahr vorbelegt. +- Bei leerem Austrittsjahr werden keine Jahres-/Quartals-/Monats-Fluktuationskennzahlen als Jahreswerte vorgetaeuscht; die Anzeige wird als `Fluktuation Auswahl` gefuehrt. +- Bei Von/Bis oder Mehrjahresauswahl zeigt die Timeline Jahresgruppen, wenn kein eindeutiges einzelnes Auswertungsjahr vorliegt. +- Die Fluktuationsberechnung nutzt fuer Mitarbeitendenfilter nur Felder, die in Mitarbeitendenbestand und Austrittsdaten vergleichbar sind: Organisation, Mitarbeitertyp, Eintrittsjahr und Suche. +- Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende/Absenzen, aber nicht Fluktuation, weil die Austrittsdatei diese Felder nicht stabil enthaelt. Das Cockpit weist darauf hin. +- Fluktuationsvisuals zaehlen Austritte distinct nach Personalnummer statt Zeilen. +- Fluktuationsraten nutzen Headcount, nicht FTE. +- `Headcount Monat` wird als Durchschnitt aus Monatsanfang und Monatsende berechnet. +- `Avg Headcount Quartal` ist der Durchschnitt der Monats-Headcounts im Quartal. +- `Avg Headcount Jahr` ist der Durchschnitt der Monats-Headcounts im Jahr. +- `Headcount nach Organisation` zaehlt Personalnummern distinct und ignoriert leere Personalnummern. +- Krankenquote nutzt neu `Krankheitstage / (FTE * 21 Tage)` statt `Krankheitstage / (Headcount * 21 Tage)`. + +## Fluktuationslogik + +Die Fluktuation wird aus den ausgeschiedenen Personen berechnet. + +Grundlage gemaess `formeln.docx`: + +- Monat: Arbeitnehmerkuendigungen des jeweiligen Monats / Headcount des Monats. +- Quartal: Arbeitnehmerkuendigungen des aktuellen Quartals / durchschnittlicher Headcount des Quartals. +- Hochrechnung Jahr: aktuelle Quartals-Fluktuation x 4. +- Effektiv Jahr: Arbeitnehmerkuendigungen des gesamten Jahres / durchschnittlicher Headcount des Jahres. +- Nenner ist Headcount der Festangestellten, nicht FTE. + +Relevant ist ein Austritt, wenn: + +- Austrittsart als Arbeitnehmer-/Mitarbeiterkuendigung erkannt wird. +- Mitarbeitertyp nicht ausgeschlossen ist. +- Austrittsgrund nicht als befristet, Pensionierung, Arbeitgeberkuendigung oder anderer Ausschlussgrund erkannt wird. + +Ausgeschlossen werden unter anderem: + +- Praktikant +- Werkstudent +- Aushilfe +- Lehrling +- befristeter Vertrag +- Pensionierung/Rente +- Kuendigung durch Trafag/Arbeitgeber + +Zusaetzlich korrigiert: + +- Rexx-Werte mit Umlaut wie `Kuendigung AN` werden trotz Originalschreibweise `Kündigung AN` als Arbeitnehmerkuendigung erkannt. +- `Kuendigung AG` bleibt als Arbeitgeberkuendigung ausgeschlossen. +- `Ruhestand` wird als Pensionierung ausgeschlossen. + +## Architektur-Cleanup + +Vorher: + +- `HrKpiService` enthielt Import, Mapping, Filter, KPI-Berechnung, Visual-Daten und Excel-Parsing in einer grossen Klasse. +- `HrKpi.razor` enthielt Route, Filter, alle Tabs, Tabellen, Visualisierungen und CSS. + +Nachher: + +- `HrKpiService.cs` ist nur noch DI-/Service-Fassade. +- `HrKpiDashboardBuilder.cs` enthaelt die Build-Pipeline fuer Import, Mapping, Filter und KPI-Berechnung. +- `HrKpi.razor` bleibt fuer Route, Filter und Laden zustaendig. +- `HrKpiDashboardTabs.razor` enthaelt die Tabs, Tabellen, Fluktuationsvisuals und Styles. +- HR-Datenquellen sind ueber `HrKpiDataSourceOptions` konfigurierbar. + +## Tests + +Neue HR-KPI-Regressionstests: + +- Organisation-Filter wirkt auch auf Absenzen. +- Von/Bis-Austrittsdatum hat Vorrang vor Austrittsjahr. +- Leeres Austrittsjahr liefert Austritte aus allen Jahren. +- Austrittsjahr ist standardmaessig leer. +- Employee-only Filter verzerren die Fluktuationsbasis nicht. +- Fluktuationsvisuals zaehlen distinct nach Personalnummer. +- Rexx-Austrittsarten `Kündigung AN`, `Kündigung AG` und `Ruhestand` werden korrekt klassifiziert. +- Fluktuationsraten verwenden durchschnittlichen Headcount statt aktuellen Stichtagsbestand. +- Mitarbeitende ohne Personalnummer werden nicht im Distinct-Headcount gezaehlt. +- FTE-Fallback aus Arbeitszeitmodell/Sollzeit wird verwendet, wenn SAP-Beschaeftigungsgrad fehlt. +- Fluktuationsrelevanz und Visual-Daten werden klassifiziert. + +Aktueller Teststand nach der Korrektur: + +```text +dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false --verbosity minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich. +- Tests erfolgreich: `69/69`. + +Kontrollwert aus `C:\temp\Personalausgeschieden.xlsx`: + +- Austritte total: `104` +- `Kündigung AN`: `42` +- `Kündigung AG`: `34` +- Fluktuationsrelevant nach aktueller HR-Logik: `33` +- Avg Headcount 2025 nach Intervalllogik: `211.3` +- Fluktuation Jahr effektiv 2025: `15.6%` + +## Offene fachliche Pruefpunkte + +Diese Punkte sind nicht automatisch geloest und muessen fachlich von HR bestaetigt werden: + +- Ob die Abgrenzung "fluktuationsrelevant" exakt der Trafag-HR-Definition entspricht. +- Ob Arbeitnehmerkuendigungen anhand der vorhandenen Austrittsart-Texte vollstaendig erkannt werden. +- Ob Praktikanten, Werkstudenten, Aushilfen und Lehrlinge immer aus der Fluktuation ausgeschlossen werden sollen. +- Ob FTE-Fallback bei fehlendem SAP-Beschaeftigungsgrad fachlich akzeptiert ist. +- Ob `8.4 Stunden = 1 Krankheitstag` als Standardumrechnung fuer alle relevanten Gruppen korrekt ist. +- Ob GLZ- und Restferien-Ampeln mit den internen HR-Grenzwerten uebereinstimmen. + +## Commit-Stand + +Bereits erstellt: + +- `20be752 Add HR KPI cockpit` +- `1cd0ad9 Refactor HR KPI cockpit architecture` +- `001e2a7 Commit pending finance and Power BI work` + +Noch nicht committed zum Zeitpunkt dieser Nachdoku: + +- Korrektur `Austrittsjahr` optional / Von-Bis Vorrang. +- Diese Nachdokumentation. diff --git a/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md b/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md index abbd097..7c73db0 100644 --- a/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md +++ b/TrafagSalesExporter/docs/HR_KPI_PRUEFUNG_SWISS_BEST_PRACTICES.md @@ -38,29 +38,29 @@ Die Power-Query-/DAX-Logik wurde nicht als Interpreter umgesetzt, sondern als C# ## Pruefpunkte mit moeglicher Abweichung -### 1. Fluktuationsnenner: Stichtags-Headcount statt Durchschnitt +### 1. Fluktuationsnenner: Durchschnittlicher Headcount Aktueller Reiter: -- `Headcount Festangestellt` wird aus dem aktuell geladenen Stichtagsbestand gerechnet. -- `Avg Headcount Quartal` und `Avg Headcount Jahr` entsprechen aktuell faktisch ebenfalls diesem Stichtagswert. +- `Headcount Monat` wird als Durchschnitt aus Monatsanfang und Monatsende gerechnet. +- `Avg Headcount Quartal` ist der Durchschnitt der Monats-Headcounts im Quartal. +- `Avg Headcount Jahr` ist der Durchschnitt der Monats-Headcounts im Jahr. +- Die Berechnung nutzt Headcount, nicht FTE, und zaehlt nur Festangestellte. Best Practice: - Fluktuation sollte fuer Monat, Quartal und Jahr mit durchschnittlichem Headcount des jeweiligen Zeitraums gerechnet werden. -- Bei stabiler Belegschaft ist der Unterschied klein. -- Bei Wachstum, Abbau oder saisonalen Schwankungen kann der Unterschied relevant sein. +- Das entspricht der Vorgabe aus `formeln.docx`. Pruefen: -- Liefert Rexx/SAP monatliche Headcount-Snapshots? -- Falls ja: Monatsdurchschnitt fuer Quartal/Jahr berechnen. -- Falls nein: UI klar als `Stichtagsnahe Fluktuation` oder `Naeherung` beschriften. +- Die aktuelle Berechnung rekonstruiert Monats-Headcounts aus Eintritts-/Austrittsdatum der aktiven Mitarbeitenden und der Austrittsdatei. +- Echte historische Monats-Snapshots waeren fuer ein auditierbares Reporting noch genauer. Status: -- fachlich akzeptabel als erste Naeherung -- fuer offizielles HR-Reporting noch zu bestaetigen +- fachlich deutlich naeher an `formeln.docx` +- als Reporting-Definition mit HR bestaetigen, falls HR echte Monats-Snapshots verlangt ### 2. Freiwillige vs. unfreiwillige Austritte @@ -85,25 +85,25 @@ Status: - HR-gepruefte Grundlogik vorhanden - Mappingliste muss bei neuen Austrittsarten gepflegt/validiert werden -### 3. Fluktuation Quartal/Jahr bei nur einem aktuellen Bestand +### 3. Fluktuation Quartal/Jahr ohne echte Monats-Snapshots Aktueller Reiter: - Quartals-/Jahresraten werden ueber Austrittsdatum gefiltert. -- Headcount bleibt aktueller Stichtagsbestand. +- Headcount wird aus Eintritts-/Austrittsintervallen pro Monat rekonstruiert. Risiko: -- Wenn der aktuelle Bestand z. B. Ende Jahr niedriger/hoeher ist als im Quartal, verzerrt das die historische Rate. +- Wenn historische Korrekturen oder Rueckdatierungen nicht in den Dateien enthalten sind, koennen rekonstruierte Monatswerte von offiziellen HR-Snapshots abweichen. Pruefen: -- Fuer Quartal/Jahr entweder echte historische Headcounts laden oder die Kennzahl explizit als operative Naeherung fuehren. +- Falls HR monatliche Headcount-Snapshots liefert, diese spaeter als bevorzugte Quelle fuer den Nenner verwenden. Status: -- Darstellung gut fuer operatives Cockpit -- nicht automatisch als auditierbare Jahreskennzahl verwenden +- operativ passend zur Formel in `formeln.docx` +- fuer Audit/Abschluss mit HR-Snapshot abgleichen ### 4. Absenzenquote: 21 Arbeitstage pauschal @@ -304,12 +304,11 @@ Status: ## Empfehlung fuer die naechste Umsetzung -Noch keine Formel aendern, bevor die Kontrollwerte protokolliert sind. +Die Fluktuationsformel wurde gemaess `formeln.docx` auf durchschnittlichen Headcount umgestellt. Vor produktiver Nutzung bleiben die Kontrollwerte mit HR/Power BI zu protokollieren. Sinnvolle naechste technische Erweiterungen: - Tab `Datenstatus` um Join-Trefferquoten erweitern. - Tab `Fluktuation` mit Kontrollwerten Power BI/HR anzeigen. - Absenzenquote optional auf vertragliche Sollzeit/FTE umstellen. -- Kennzahlen mit `Naeherung` markieren, solange nur ein Stichtagsbestand statt historischer Monats-Snapshots vorhanden ist. - +- Falls HR echte historische Monats-Snapshots liefert, diese als bevorzugte Quelle fuer den durchschnittlichen Headcount nutzen. diff --git a/TrafagSalesExporter/entscheide.md b/TrafagSalesExporter/entscheide.md new file mode 100644 index 0000000..a166769 --- /dev/null +++ b/TrafagSalesExporter/entscheide.md @@ -0,0 +1,56 @@ +# Finance Entscheide Soll/Ist 2025 + +Stand: 2026-05-15 + +Dieses Dokument extrahiert die erkennbaren Fragen und Entscheide aus der Abstimmung. Es dient als Arbeitsgrundlage fuer die Umsetzung im Finance-Abgleich und fuer die naechste Klaerung mit Andreas/Finance. + +## Entscheide + +| Thema | Frage | Entscheid | +| --- | --- | --- | +| Fuehrende Waehrung je Land | Welche Waehrung ist im Landessystem je Land fuehrend: Belegwaehrung, Hauswaehrung oder etwas anderes? | Immer Hauswaehrung. Rechnungen werden in der Hauswaehrung ausgewertet. | +| CHF-Umrechnung | Mit welchem Kurs wird nach CHF umgerechnet: Monatskurs, Tageskurs, Jahresdurchschnitt oder SNB-Tageskurs? | Budgetkurse verwenden. Keine SNB-Tageskurse fuer den Standardabgleich. | +| Aggregation | Wird zuerst pro Beleg/Position summiert und danach umgerechnet, oder wird jede Zeile einzeln umgerechnet und danach summiert? | Pro Artikel bzw. Belegposition rechnen. | +| Indien | In Indien kommen CHF, EUR, GBP, INR, JPY und USD vor. Muss vorher nach CHF umgerechnet oder nach Waehrung getrennt werden? | Indien immer in indischen Rupien auswerten. Fuehrend ist INR. | +| Italien / Intercompany | Soll IT mit Intercompany-Abzug gerechnet werden? Falls ja, nach welchen Kunden/Kriterien? | Hauswaehrung verwenden. Intercompany wird separat abgegrenzt und ausgewiesen. | +| Wertbasis | Welche Basis soll fuer Net Sales Actuals je Land verwendet werden? | Nettofakturawert. | +| Jahresabgrenzung | Fuer das Jahr 2025: Nach welchem Datum wird abgegrenzt? | Buchungsdatum. | +| Gutschriften / Storno | Wie werden Gutschriften und Storno behandelt? | Gutschriften separat ausweisen. Sie haben eigene Rechnungsnummern bzw. Rechnungspositionen. Behandlung immer ueber Artikelnummer/Positionslogik, da alles andere zu komplex wird. | +| Intercompany / 2nd Party | Wie werden Intercompany-Kunden abgegrenzt? | Im zweiten Schritt als neues Auswahlfeld fuer Intercompany bzw. 2nd-party-Kunde. Regeln einmalig hinterlegen, weil sie sich kaum aendern. | + +## Intercompany-Regeln + +Intercompany bzw. 2nd-party soll ueber stabile Kundenmarker erkannt werden. + +Aktuell erkennbare Marker: + +- `MAGNETS SENSE` +- `MAGNETIC SENSE` +- `TRAFAG` +- `GESELLSCHAFT FUER SENSORIK` +- `GESELLSCHAFT FUR SENSORIK` + +Bewertung: + +- Treffer auf diese Marker gelten als Intercompany bzw. 2nd-party. +- Alle anderen Kunden gelten standardmaessig als 3rd-party. +- Weitere Uebersetzungen, lokale Schreibweisen oder Kundennummern muessen bei Bedarf ergaenzt werden. + +## Umsetzungsfolge + +1. Standard-Ist je Land in Hauswaehrung und auf Basis Nettofakturawert berechnen. +2. Jahresfilter 2025 ueber Buchungsdatum anwenden. +3. Werte pro Artikel bzw. Belegposition berechnen und danach summieren. +4. Gutschriften separat sichtbar machen, aber positionsbasiert behandeln. +5. Intercompany/2nd-party als eigenes Auswahlfeld bzw. eigene Sicht ergaenzen. +6. CHF-Sicht nur mit Budgetkursen als separate Kontrollsicht aufbauen. + +## Offene Punkte + +| Punkt | Klaerung | +| --- | --- | +| Intercompany-Kundenliste | Finale Liste der Kundennummern, Namen und lokalen Schreibweisen je Land bestaetigen. | +| Italien | Abgrenzung mit/ohne Intercompany fachlich gegen Rhino/check.xlsx pruefen. | +| Budgetkurse | Quelle und Gueltigkeit der Budgetkurse je Jahr festlegen. | +| Gutschriften | Sicherstellen, dass alle Quellsysteme Gutschriften mit eigener Rechnungsnummer/Position liefern oder sauber markierbar sind. | + diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 50a5f2c..c8f533d 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1172,3 +1172,144 @@ Ergaenzt in `docs/PROGRAMM_DIAGRAMME.md`: - FinanceProbe-Start und Hinweis zu Console-Logging. - Hinweis zu DLL-Sperren durch Visual Studio bzw. alte `dotnet`-Prozesse. + +## HR KPI Cockpit und Filterkorrektur 2026-05-13 + +Ergaenzt: + +- Separater HR-KPI-Reiter `/hr-kpi`. +- Dashboard-Tabs fuer Ueberblick, Fluktuation, Absenzen, Zeit/Ferien, Mitarbeitende und Datenstatus. +- Fluktuationsvisuals: Gauge, Funnel, Donut, Organisation-Balken und Monatsbalken. +- Architektur-Cleanup: `HrKpiService` als Fassade, Build-Pipeline in `Services/HrKpi/HrKpiDashboardBuilder.cs`, UI-Tabs in `Components/HrKpi/HrKpiDashboardTabs.razor`. +- Konfigurierbare HR-Dateiquellen ueber `HrKpi` in `appsettings.json`. +- HR-KPI-Regressionstests. + +Korrigiert: + +- `Austrittsjahr` ist jetzt optional. +- Leeres Austrittsjahr bedeutet: alle Austritte. +- Von/Bis-Austritt hat Vorrang vor Austrittsjahr. +- Die Austrittsjahr-Auswahl wird aus den vorhandenen Austrittsdaten aufgebaut. +- `Austrittsjahr` ist beim Start leer statt automatisch aktuelles Jahr. +- Fluktuation nutzt nur vergleichbare Filter auf Mitarbeitenden- und Austrittsdaten. +- Kostenstelle, GLZ und Restferien filtern nicht die Fluktuation, weil die Austrittsdatei diese Felder nicht stabil enthaelt; das Cockpit zeigt dazu einen Hinweis. +- Bei Mehrjahresauswahl wird die Fluktuation als Auswahlwert statt als Jahreswert gefuehrt. +- Fluktuationsvisuals zaehlen distinct nach Personalnummer. +- Fluktuationsraten nutzen nun durchschnittlichen Headcount statt Stichtags-Headcount: Monat, Quartal und Jahr folgen `formeln.docx`. +- Krankenquote nutzt den FTE-Nenner: `Krankheitstage / (FTE * 21 Tage)`. +- Rexx-Austrittsarten mit Umlaut werden korrekt normalisiert: `Kündigung AN` zaehlt als Arbeitnehmerkuendigung, `Kündigung AG` als Arbeitgeberkuendigung-Ausschluss, `Ruhestand` als Pensionierung. + +Nachdokumentation: + +```text +docs/HR_KPI_NACHDOKU_2026-05-13.md +``` + +Verifikation: + +- `dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_app\ --verbosity minimal` +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_tests\ --verbosity minimal` +- Ergebnis: `69/69` Tests bestanden. +- Kontrollwert `C:\temp\Personalausgeschieden.xlsx`: `104` Austritte total, `42` `Kündigung AN`, `34` `Kündigung AG`, `33` fluktuationsrelevant. +- Kontrollwert neuer Nenner: Avg Headcount 2025 `211.3`, Fluktuation Jahr effektiv `15.6%`. + +## FinanceProbe Finanzchef-Uebersicht 2026-05-13 + +Ergaenzt: + +- Neuer Reiter `Finanzchef Uebersicht` in `Tools/FinanceProbe`. +- Kompakte Soll/Ist-Sicht nur fuer offene Laender. +- Spalten reduziert auf Status, Land, Waehrung, Ist, Soll, Abweichung und Pruefgrund. +- Bestehende Detailtabellen bleiben unveraendert fuer Analyse/Nachvollzug. + +Verifikation: + +- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_financeprobe\ --verbosity minimal` +- Ergebnis: Build erfolgreich, `0` Fehler. +- Hinweis: `NU1900` wegen nicht erreichbarer NuGet-Sicherheitsdaten im eingeschraenkten Netzwerk. + +## Finance CFO Word-Kurzbericht 2026-05-13 + +Erstellt: + +- `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx` +- Kurzbericht fuer Finance/CFO mit Kernaussagen und Massnahmen. +- Enthalten: FR, IN, US, AT, ES, UK/EN, DE, CH, IT. +- Ausgeschlossen: GFS und reine 0-/Leer-Faelle ohne operative Aussage. + +Inhaltlicher Fokus: + +- Freigabefaehige Laender: FR, IN, US. +- Kleine/mittlere Klaerung: AT, ES. +- Hohe Prioritaet: UK/EN, DE, CH. +- Kritisch: IT wegen groesster Abweichung und offener Berechnungsart. + +## Finance CFO Word-Kurzbericht Erweiterung 2026-05-15 + +Ergaenzt: + +- Aktuelle Fassung: `docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx` +- Erweiterte Tabellenansicht mit Status, Ist, Soll/Rhino, Abweichung, Pruefquelle, Massnahme und Prioritaet. +- Grafische Ampel-Uebersicht fuer OK/Klaeren/Hoch/Kritisch. +- Prioritaetsgrafik fuer IT, DE, UK/EN, CH, AT/ES. +- Abschnitt `Geprueft gegen` mit Rhino/Andreas `check.xlsx`, FinanceProbe/CentralSalesRecords, Spain CSV, Deutschland-Beispielfile und UK_B1. + +Verifikation: + +- DOCX enthaelt `word/document.xml`. +- Inhalte `Rhino / Andreas check.xlsx`, `Management-Ampel`, `Prioritaetsgrafik` und `Laendertabelle mit Massnahmen` wurden im Dokumentpaket geprueft. + +## Finance Spanien Mailentwurf 2026-05-15 + +Erstellt: + +- `docs/FINANCE_ES_MAIL_ABWEICHUNG_2026-05-15.md` +- Spanischer Mailentwurf zur Abweichung Spanien Net Sales 2025. +- Enthaltene Pruefpunkte: Zeitraum, Serien `REG/LAT/PRO/REC`, Abonos/Credit Notes, Datumslogik und verwendetes Netto-Umsatzfeld. + +## Finance IT und UK Mailentwuerfe 2026-05-15 + +Erstellt: + +- `docs/FINANCE_IT_MAIL_ABWEICHUNG_2026-05-15.md` +- `docs/FINANCE_UK_MAIL_ABWEICHUNG_2026-05-15.md` + +Inhalt: + +- Italien: grosse Abweichung `+7.034.496,29 EUR`, Fokus Berechnungsart, Beleg/Position-Deduplizierung, Intercompany, Credit Notes, Datumslogik und Waehrung. +- UK/England: Restdifferenz `-216,154.91 GBP`, Fokus Jahresvollstaendigkeit, Periodenbereich, Credit Notes, Nettofeld, Discounts/Freight/Charges, 2nd-/3rd-party und Waehrung. + +## Finance Entscheide Extraktion 2026-05-15 + +Erstellt: + +- `entscheide.md` + +Inhalt: + +- Fragen und Entscheide aus der Finance-Abstimmung extrahiert. +- Festgehaltene Kernentscheide: Hauswaehrung je Land, Budgetkurse fuer CHF-Sicht, Berechnung pro Artikel/Belegposition, Nettofakturawert, Buchungsdatum, separate Gutschriftenausweisung und Intercompany/2nd-party als eigenes Auswahlfeld. +- Intercompany-Marker dokumentiert: `MAGNETS SENSE`, `MAGNETIC SENSE`, `TRAFAG`, `GESELLSCHAFT FUER SENSORIK`, `GESELLSCHAFT FUR SENSORIK`. + +## Finance Dokumentgueltigkeit 2026-05-15 + +Erstellt: + +- `docs/FINANCE_WELCHES_DOKUMENT_GILT_2026-05-15.md` + +Festgelegt: + +- Fuehrendes CFO-Dokument: `docs/FINANCE_CHEF_SUMMARY_2026-05-15.docx` +- Alte CFO-Version `docs/FINANCE_CHEF_SUMMARY_2026-05-13.docx` entfernt, weil sie durch die Version vom 2026-05-15 ersetzt wurde. +- Entscheidbasis: `entscheide.md` und `docs/FINANCE_ENTSCHEIDE.md`. + +## Finance Dashboard Todo 2026-05-15 + +Erstellt: + +- `docs/FINANCE_DASHBOARD_TODO_2026-05-15.md` + +Inhalt: + +- Todo-Liste fuer Group Sales Reporting Intranet-Dashboard. +- Priorisierte Punkte fuer CFO-Dokument, offene Laenderabweichungen, Intercompany, Budgetkurse und Berechtigungskonzept.