Update HR KPI and finance dashboard docs
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
@HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation)
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
@MonthlyBars(Result.TurnoverVisuals.MonthlyRelevantLeavers)
|
||||
@MonthlyBars(Result.TurnoverVisuals)
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
@@ -286,7 +286,7 @@
|
||||
.OrderByDescending(x => x.Count);
|
||||
|
||||
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahres-Fluktuation", "Annual turnover")</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@visual.RateTitle</MudText>
|
||||
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
|
||||
<div class="hr-gauge-track"></div>
|
||||
<div class="hr-gauge-needle"></div>
|
||||
@@ -355,10 +355,10 @@
|
||||
</div>
|
||||
</MudPaper>;
|
||||
|
||||
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> MonthlyBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte pro Monat", "Relevant leavers per month")</MudText>
|
||||
private RenderFragment<HrTurnoverVisuals> MonthlyBars => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@visual.TimelineTitle</MudText>
|
||||
<div class="hr-month-bars">
|
||||
@foreach (var item in items)
|
||||
@foreach (var item in visual.MonthlyRelevantLeavers)
|
||||
{
|
||||
<div class="hr-month">
|
||||
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
<MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Min="2000" Max="2100" />
|
||||
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
|
||||
@foreach (var option in _result?.ExitYearOptions ?? [])
|
||||
{
|
||||
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> Notices { get; set; } = [];
|
||||
public List<string> OrganisationOptions { get; set; } = [];
|
||||
public List<string> KostenstelleOptions { get; set; } = [];
|
||||
public List<int> ExitYearOptions { get; set; } = [];
|
||||
public List<int> EntryYearOptions { get; set; } = [];
|
||||
public List<string> MitarbeitertypOptions { get; set; } = [];
|
||||
public List<HrKpiMetric> 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<HrKpiGroupValue> FunnelSteps { get; set; } = [];
|
||||
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
|
||||
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
|
||||
|
||||
@@ -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<HrKpiEmployeeRow> ApplyTurnoverEmployeeFilters(IEnumerable<HrKpiEmployeeRow> 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<HrAbsenceRow> ApplyAbsenceFilters(
|
||||
IEnumerable<HrAbsenceRow> 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<HrLeaverRow> ApplyTurnoverHeadcountLeaverFilters(IEnumerable<HrLeaverRow> 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<HrKpiMetric> BuildOverviewMetrics(
|
||||
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<HrAbsenceRow> absences,
|
||||
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
|
||||
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
|
||||
IReadOnlyCollection<HrLeaverRow> 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<HrKpiMetric> BuildTurnoverMetrics(
|
||||
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
|
||||
IReadOnlyCollection<HrLeaverRow> 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<HrKpiMetric>
|
||||
{
|
||||
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<HrKpiMetric> BuildAbsenceMetrics(
|
||||
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<HrAbsenceRow> absences)
|
||||
{
|
||||
var headcount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
|
||||
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
|
||||
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
|
||||
var longSick = absences.Sum(x => x.KrankheitstageLang);
|
||||
var absenceRate = headcount == 0 ? 0 : totalSick / (headcount * 21m);
|
||||
var 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<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
|
||||
IReadOnlyCollection<HrLeaverRow> 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<HrKpiGroupValue> BuildMonthlyTurnoverTimeline(
|
||||
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||
int relevantLeavers,
|
||||
int year)
|
||||
=> Enumerable.Range(1, 12)
|
||||
.Select(month =>
|
||||
{
|
||||
var count = leavers.Count(x =>
|
||||
x.IstFluktuationsrelevant &&
|
||||
var count = CountDistinctPersons(leavers
|
||||
.Where(x => x.IstFluktuationsrelevant &&
|
||||
x.Austrittsdatum.HasValue &&
|
||||
x.Austrittsdatum.Value.Year == year &&
|
||||
x.Austrittsdatum.Value.Month == month);
|
||||
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<HrKpiGroupValue> BuildYearlyTurnoverTimeline(
|
||||
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||
int relevantLeavers)
|
||||
=> leavers
|
||||
.Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr.HasValue)
|
||||
.GroupBy(x => x.Austrittsjahr!.Value)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g =>
|
||||
{
|
||||
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
|
||||
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<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<TurnoverEmploymentInterval> intervals,
|
||||
TurnoverPeriodScope period)
|
||||
{
|
||||
if (period.ShowPeriodMetrics && period.BreakdownYear.HasValue)
|
||||
{
|
||||
return CalculateAverageFixedHeadcount(
|
||||
intervals,
|
||||
Enumerable.Range(1, 12).Select(month => (period.BreakdownYear.Value, month)));
|
||||
}
|
||||
|
||||
return CountCurrentFixedHeadcount(employees);
|
||||
}
|
||||
|
||||
private static int CountCurrentFixedHeadcount(IReadOnlyCollection<HrKpiEmployeeRow> employees)
|
||||
=> CountDistinctPersons(employees
|
||||
.Where(x => IsFixedEmployee(x.Mitarbeitertyp))
|
||||
.Select(x => x.Personalnummer));
|
||||
|
||||
private static List<TurnoverEmploymentInterval> BuildTurnoverIntervals(
|
||||
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||
IReadOnlyCollection<HrLeaverRow> leavers)
|
||||
{
|
||||
var intervals = new List<TurnoverEmploymentInterval>();
|
||||
|
||||
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<TurnoverEmploymentInterval> 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<TurnoverEmploymentInterval> 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<TurnoverEmploymentInterval> 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<int> 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<HrLeaverRow> 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<HrLeaverRow> 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<int> 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<int?> 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(
|
||||
|
||||
@@ -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(
|
||||
<span>Aktualisiert: {{Html(generatedAt)}}</span>
|
||||
</div>
|
||||
<nav aria-label="Finance Probe Navigation">
|
||||
<a href="#chef">Finanzchef Übersicht</a>
|
||||
<a href="#briefing">Meeting Ampel</a>
|
||||
<a href="#all-sites">Detail alle Laender</a>
|
||||
<a href="#coverage">Datenabdeckung</a>
|
||||
@@ -808,6 +829,7 @@ static string BuildPage(
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{{financeChiefOverview}}
|
||||
{{executiveBriefing}}
|
||||
<section class="summary">
|
||||
<div class="metric"><strong>{{rows.Count}}</strong><span>Standorte</span></div>
|
||||
@@ -954,6 +976,181 @@ static string BuildDetailRows(
|
||||
.Select(row => row.Html));
|
||||
}
|
||||
|
||||
static string BuildFinanceChiefOverview(
|
||||
IReadOnlyList<NetSalesReferenceRow> rows,
|
||||
IReadOnlyDictionary<string, CheckedExcelReference> 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
|
||||
? """
|
||||
<tr>
|
||||
<td colspan="7" class="wrap">Keine offenen Abweichungen. Alle vorhandenen Laender passen rechnerisch gegen den Sollwert.</td>
|
||||
</tr>
|
||||
"""
|
||||
: string.Join(Environment.NewLine, openIssues.Select(BuildFinanceChiefIssueRow));
|
||||
|
||||
return $$"""
|
||||
<section id="chef" class="briefing">
|
||||
<h2>Finanzchef Übersicht</h2>
|
||||
<p class="chief-note">Kompakte Sicht nur auf offene Soll/Ist-Themen. Detailtabellen bleiben unten fuer Analyse und Nachvollzug.</p>
|
||||
<div class="chief-summary">
|
||||
<div class="metric"><strong>{{openIssues.Count}}</strong><span>Offen</span></div>
|
||||
<div class="metric"><strong>{{checkCount}}</strong><span>Abweichungen</span></div>
|
||||
<div class="metric"><strong>{{missingCount}}</strong><span>Keine Daten</span></div>
|
||||
</div>
|
||||
<div class="chief-note" style="margin-bottom:10px;">Groesste absolute Abweichung: {{Amount(largestDifference)}}</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Land</th>
|
||||
<th>Waehrung</th>
|
||||
<th class="num">Ist</th>
|
||||
<th class="num">Soll</th>
|
||||
<th class="num">Abweichung</th>
|
||||
<th>Was ist zu pruefen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{{tableRows}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
""";
|
||||
}
|
||||
|
||||
static IEnumerable<FinanceChiefIssue> BuildFinanceChiefIssues(
|
||||
IReadOnlyList<NetSalesReferenceRow> rows,
|
||||
IReadOnlyDictionary<string, CheckedExcelReference> 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 $$"""
|
||||
<tr>
|
||||
<td><span class="status {{Html(statusClass)}}">{{Html(issue.Status)}}</span></td>
|
||||
<td><strong>{{Html(issue.Label)}}</strong><div class="small">{{Html(issue.Key)}}</div></td>
|
||||
<td>{{Html(issue.Currency)}}</td>
|
||||
<td class="num">{{Amount(issue.ActualValue)}}</td>
|
||||
<td class="num">{{Amount(issue.ReferenceValue)}}</td>
|
||||
<td class="num">{{Amount(issue.Difference)}}</td>
|
||||
<td class="chief-action">{{Html(issue.Reason)}}</td>
|
||||
</tr>
|
||||
""";
|
||||
}
|
||||
|
||||
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<NetSalesReferenceRow> rows,
|
||||
IReadOnlyDictionary<string, CheckedExcelReference> 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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 |
|
||||
@@ -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<int?>` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user