refactor: BonusCalculator runs all 3 variants and picks winner
This commit is contained in:
parent
f7153a5e53
commit
bc1d92937c
2 changed files with 170 additions and 142 deletions
212
calculator.js
212
calculator.js
|
|
@ -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',
|
||||
|
|
|
|||
100
test-suite.js
100
test-suite.js
|
|
@ -793,6 +793,106 @@ runner.test('variant2: threshold-Shape normal {sa:1, weekday:2}', (t) => {
|
|||
t.assertEqual(r.threshold.weekday, 2, 'threshold.weekday=2');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BonusCalculator - Winner Selection (new shape)
|
||||
// ============================================================================
|
||||
|
||||
runner.test('Winner: klarer Sieger mit weekdays + 1 Fr', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr
|
||||
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
|
||||
{ date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di
|
||||
{ date: new Date('2025-11-26T12:00:00'), share: 1.0 }, // Mi
|
||||
{ date: new Date('2025-11-27T12:00:00'), share: 1.0 }, // Do
|
||||
{ date: new Date('2025-11-04T12:00:00'), share: 1.0 } // Di
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, false);
|
||||
t.assertTrue(result.winner.isWinner, 'winner.isWinner=true');
|
||||
t.assertEqual(result.allResults.length, 3, '3 Varianten im allResults');
|
||||
t.assertTrue(result.totalBonus > 0, 'Bonus > 0');
|
||||
});
|
||||
|
||||
runner.test('Winner: klarer V3-Sieger (nur WE-Dienste)', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa
|
||||
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // So
|
||||
{ date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, false);
|
||||
// V1: fr+so=1, weekday=0 -> not eligible
|
||||
// V2: sa=2, weekday=0 -> not eligible
|
||||
// V3: pool=3 -> eligible, deduction 2 (fr=0,so=1 abgezogen, sa=1 abgezogen) -> 1 sa paid -> 450
|
||||
t.assertEqual(result.winner.variantId, 3, 'V3 muss Sieger sein');
|
||||
t.assertEqual(result.totalBonus, 450, 'bonus=450');
|
||||
});
|
||||
|
||||
runner.test('Winner: Tie-Breaker - alle three not eligible -> V1 nominal winner, totalBonus 0', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
// fr=1, sa=0, so=0, weekday=3:
|
||||
// V1: fr+so=1 ok, weekday=3 ok -> eligible. Abzug fr=1, weekday=3 -> alles weg, bonus 0.
|
||||
// V2: sa=0 -> not eligible (0).
|
||||
// V3: pool=1 < 2 -> not eligible (0).
|
||||
// -> tie at 0; V1 has eligible=true so its result is still 0. Strict > keeps v1 as winner.
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr
|
||||
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
|
||||
{ date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di
|
||||
{ date: new Date('2025-11-26T12:00:00'), share: 1.0 } // Mi
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, false);
|
||||
t.assertEqual(result.winner.variantId, 1, 'V1 wins tie (lowest variantId)');
|
||||
t.assertEqual(result.totalBonus, 0, 'totalBonus=0 (all-zero tie)');
|
||||
});
|
||||
|
||||
runner.test('Winner: nur V3 produziert positive bonus -> V3 winner', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
// Three Saturdays: V1 not eligible, V2 not eligible (weekday=0), V3 eligible with positive bonus.
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 },
|
||||
{ date: new Date('2025-11-29T12:00:00'), share: 1.0 },
|
||||
{ date: new Date('2025-11-15T12:00:00'), share: 1.0 }
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, false);
|
||||
// V3: pool=3, abzug 2 (so=0, fr=0, sa=2 abgezogen) -> 1 sa paid -> 450
|
||||
t.assertEqual(result.winner.variantId, 3, 'V3 winner');
|
||||
t.assertEqual(result.totalBonus, 450, 'bonus=450');
|
||||
});
|
||||
|
||||
runner.test('Winner: result-Shape enthaelt classified, isVacation, dutyDetails', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 },
|
||||
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 }
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, false);
|
||||
t.assertTrue('classified' in result, 'classified field exists');
|
||||
t.assertTrue('isVacation' in result, 'isVacation field exists');
|
||||
t.assertTrue('dutyDetails' in result, 'dutyDetails field exists');
|
||||
t.assertEqual(result.dutyDetails.length, 2, 'dutyDetails has 2 entries');
|
||||
t.assertEqual(result.isVacation, false, 'isVacation=false');
|
||||
});
|
||||
|
||||
runner.test('Winner: Urlaubsmodus halbiert alle Schwellen', (t) => {
|
||||
const hp = new HolidayProvider();
|
||||
const calc = new BonusCalculator(hp);
|
||||
// fr=0.5, weekday=1.5 -> V1 eligible im Urlaub (0.5 >= 0.5, 1.5 >= 1.5)
|
||||
const duties = [
|
||||
{ date: new Date('2025-11-21T12:00:00'), share: 0.5 }, // Fr
|
||||
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
|
||||
{ date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di
|
||||
];
|
||||
const result = calc.calculateMonthlyBonus(duties, true);
|
||||
t.assertEqual(result.isVacation, true, 'isVacation propagated');
|
||||
t.assertEqual(result.winner.variantId, 1, 'V1 wins under vacation');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Display Functions
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue