refactor: BonusCalculator runs all 3 variants and picks winner

This commit is contained in:
Kenearos 2026-05-12 00:15:38 +02:00
parent f7153a5e53
commit bc1d92937c
2 changed files with 170 additions and 142 deletions

View file

@ -1,41 +1,26 @@
/**
* Duty Schedule Bonus Calculator
* Calculates bonuses based on weekend and holiday duty shifts
* Bonus Calculator (NRW Psychiatrie 2011)
* Orchestrator: classifies duties, runs all three variants (V1/V2/V3), picks the winner.
* Pure variant logic lives in variants.js.
*/
class BonusCalculator {
constructor(holidayProvider) {
this.holidayProvider = holidayProvider;
this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday)
this.RATE_WEEKEND = 450; // Weekend/holiday rate
this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus
this.DEDUCTION_AMOUNT = 2.0; // Deduction after reaching threshold
this.RATE_NORMAL = 250;
this.RATE_WEEKEND = 450;
}
/**
* Check if a date is a qualifying day (weekend or holiday related)
* Qualifying days: Friday, Saturday, Sunday, Public Holiday, Day before public holiday
* @param {Date} date
* @returns {boolean}
* Whether the given date is a "qualifying" day (used by UI for badge coloring).
* Mirrors the old isQualifyingDay so app.js does not break.
*/
isQualifyingDay(date) {
const dayOfWeek = date.getDay(); // 0 = Sunday, 5 = Friday, 6 = Saturday
// Weekend: Friday (5), Saturday (6), Sunday (0)
const isWeekend = dayOfWeek === 5 || dayOfWeek === 6 || dayOfWeek === 0;
// Public holiday
const isHoliday = this.holidayProvider.isHoliday(date);
// Day before public holiday
const isDayBeforeHoliday = this.holidayProvider.isDayBeforeHoliday(date);
return isWeekend || isHoliday || isDayBeforeHoliday;
const slot = classify(date, this.holidayProvider);
return slot !== 'weekday';
}
/**
* Get day type label for display
* @param {Date} date
* @returns {string}
* Human-readable label for the date's day type (used by UI).
*/
getDayTypeLabel(date) {
const dayOfWeek = date.getDay();
@ -43,12 +28,8 @@ class BonusCalculator {
const holidayName = this.holidayProvider.getHolidayName(date);
const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date);
if (isHoliday) {
return `Feiertag (${holidayName})`;
}
if (isDayBefore) {
return 'Tag vor Feiertag';
}
if (isHoliday) return `Feiertag (${holidayName})`;
if (isDayBefore) return 'Tag vor Feiertag';
if (dayOfWeek === 5) return 'Freitag';
if (dayOfWeek === 6) return 'Samstag';
if (dayOfWeek === 0) return 'Sonntag';
@ -58,143 +39,90 @@ class BonusCalculator {
}
/**
* Calculate bonus for a single employee for a given month
* @param {Array} duties - Array of duty objects: {date: Date, share: number (1.0 or 0.5)}
* @returns {Object} Calculation result
* Build the dutyDetails array (date, share, isQualifying, dayType) for the UI.
*/
calculateMonthlyBonus(duties) {
buildDutyDetails(duties) {
return duties.map(duty => ({
date: duty.date,
share: duty.share,
isQualifying: this.isQualifyingDay(duty.date),
dayType: this.getDayTypeLabel(duty.date)
}));
}
/**
* Calculate the bonus for a single employee for a given month.
* @param {Array} duties - Array of { date: Date, share: number }
* @param {boolean} isVacation - Vacation toggle (halves thresholds + deductions)
* @returns {Object} new-shape result (winner, allResults, totalBonus, classified, isVacation, dutyDetails)
*/
calculateMonthlyBonus(duties, isVacation = false) {
if (!duties || duties.length === 0) {
return this.getEmptyResult();
return this.getEmptyResult(isVacation);
}
// Separate qualifying days (Friday separate from others) and non-qualifying days
let qualifyingDaysFriday = 0;
let qualifyingDaysOther = 0;
let normalDays = 0;
const dutyDetails = [];
const classified = classifyDuties(duties, this.holidayProvider);
const v1 = variant1(classified, isVacation);
const v2 = variant2(classified, isVacation);
const v3 = variant3(classified, isVacation);
const results = [v1, v2, v3];
duties.forEach(duty => {
const isQualifying = this.isQualifyingDay(duty.date);
const dayType = this.getDayTypeLabel(duty.date);
const isFriday = duty.date.getDay() === 5;
if (isQualifying) {
if (isFriday) {
qualifyingDaysFriday += duty.share;
} else {
qualifyingDaysOther += duty.share;
}
} else {
normalDays += duty.share;
// Pick winner: highest bonus, tie-breaker = lowest variantId (strict >)
let winner = results[0];
for (let i = 1; i < results.length; i++) {
if (results[i].bonus > winner.bonus) {
winner = results[i];
}
dutyDetails.push({
date: duty.date,
share: duty.share,
isQualifying: isQualifying,
dayType: dayType
});
});
const qualifyingDaysTotal = qualifyingDaysFriday + qualifyingDaysOther;
// Check if threshold is reached
const thresholdReached = qualifyingDaysTotal >= this.MIN_QUALIFYING_DAYS;
let bonus = 0;
let normalDaysPaid = 0;
let qualifyingDaysPaid = 0;
let deductionFromFriday = 0;
let deductionFromOther = 0;
let totalDeduction = 0;
if (thresholdReached) {
// Deduct qualifying days with Friday priority
totalDeduction = this.DEDUCTION_AMOUNT;
// First deduct from Friday
deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday);
// Remaining deduction from other qualifying days
deductionFromOther = Math.max(0, totalDeduction - deductionFromFriday);
// Calculate paid days
const qualifyingDaysFridayPaid = Math.max(0, qualifyingDaysFriday - deductionFromFriday);
const qualifyingDaysOtherPaid = Math.max(0, qualifyingDaysOther - deductionFromOther);
qualifyingDaysPaid = qualifyingDaysFridayPaid + qualifyingDaysOtherPaid;
normalDaysPaid = normalDays;
// Calculate bonus
bonus = (normalDaysPaid * this.RATE_NORMAL) + (qualifyingDaysPaid * this.RATE_WEEKEND);
}
// If threshold not reached: no bonus paid (neither WT nor WE)
winner.isWinner = true;
return {
classified,
isVacation,
winner,
allResults: results,
totalBonus: winner.bonus,
totalDuties: duties.length,
totalDaysWorked: qualifyingDaysTotal + normalDays,
normalDays: normalDays,
qualifyingDaysFriday: qualifyingDaysFriday,
qualifyingDaysOther: qualifyingDaysOther,
qualifyingDays: qualifyingDaysTotal,
thresholdReached: thresholdReached,
deductionFromFriday: deductionFromFriday,
deductionFromOther: deductionFromOther,
qualifyingDaysDeducted: totalDeduction,
normalDaysPaid: normalDaysPaid,
qualifyingDaysPaid: qualifyingDaysPaid,
bonusNormalDays: normalDaysPaid * this.RATE_NORMAL,
bonusQualifyingDays: qualifyingDaysPaid * this.RATE_WEEKEND,
totalBonus: bonus,
dutyDetails: dutyDetails
dutyDetails: this.buildDutyDetails(duties)
};
}
/**
* Calculate bonuses for all employees
* @param {Object} employeeDuties - Object with employee names as keys and duty arrays as values
* @returns {Object} Results for all employees
* Calculate for all employees. vacationMap: { [employeeName]: boolean }
*/
calculateAllEmployees(employeeDuties) {
calculateAllEmployees(employeeDuties, vacationMap = {}) {
const results = {};
for (const [employeeName, duties] of Object.entries(employeeDuties)) {
results[employeeName] = this.calculateMonthlyBonus(duties);
for (const [name, duties] of Object.entries(employeeDuties)) {
const isVac = Boolean(vacationMap[name]);
results[name] = this.calculateMonthlyBonus(duties, isVac);
}
return results;
}
/**
* Get empty result structure
* @returns {Object}
*/
getEmptyResult() {
getEmptyResult(isVacation = false) {
const empty = {
variantId: 1,
eligible: false,
threshold: null,
deduction: { fr: 0, sa: 0, so: 0, weekday: 0 },
paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 },
bonus: 0,
isWinner: true
};
return {
totalDuties: 0,
totalDaysWorked: 0,
normalDays: 0,
qualifyingDaysFriday: 0,
qualifyingDaysOther: 0,
qualifyingDays: 0,
thresholdReached: false,
deductionFromFriday: 0,
deductionFromOther: 0,
qualifyingDaysDeducted: 0,
normalDaysPaid: 0,
qualifyingDaysPaid: 0,
bonusNormalDays: 0,
bonusQualifyingDays: 0,
classified: { fr: 0, sa: 0, so: 0, weekday: 0 },
isVacation,
winner: empty,
allResults: [empty,
{ ...empty, variantId: 2, isWinner: false },
{ ...empty, variantId: 3, isWinner: false }
],
totalBonus: 0,
totalDuties: 0,
dutyDetails: []
};
}
/**
* Format currency for display
* @param {number} amount
* @returns {string}
*/
formatCurrency(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',