Dienstplan-Pro/calculator.js
Kenearos c733c3ed02 fix(calculator): prefer eligible variant over ineligible at bonus-tie
Winner-picker used strict > with lowest-variantId tie-break, which made
V1 (ineligible) win over V3 (eligible) when all variants produced bonus=0
on Sa+So-only duties. Now ties prefer the eligible variant, keeping the
lowest-variantId rule as a sub-tie-breaker.
2026-05-12 18:36:24 +02:00

139 lines
4.6 KiB
JavaScript

/**
* 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;
this.RATE_WEEKEND = 450;
}
/**
* 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 slot = classify(date, this.holidayProvider);
return slot !== 'weekday';
}
/**
* Human-readable label for the date's day type (used by UI).
*/
getDayTypeLabel(date) {
const dayOfWeek = date.getDay();
const isHoliday = this.holidayProvider.isHoliday(date);
const holidayName = this.holidayProvider.getHolidayName(date);
const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date);
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';
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return days[dayOfWeek];
}
/**
* Build the dutyDetails array (date, share, isQualifying, dayType) for the UI.
*/
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(isVacation);
}
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];
// Pick winner: highest bonus; on tie prefer eligible over ineligible;
// further tie-break = lowest variantId (strict > preserves it).
let winner = results[0];
for (let i = 1; i < results.length; i++) {
const r = results[i];
if (r.bonus > winner.bonus) {
winner = r;
} else if (r.bonus === winner.bonus && r.eligible && !winner.eligible) {
winner = r;
}
}
winner.isWinner = true;
return {
classified,
isVacation,
winner,
allResults: results,
totalBonus: winner.bonus,
totalDuties: duties.length,
dutyDetails: this.buildDutyDetails(duties)
};
}
/**
* Calculate for all employees. vacationMap: { [employeeName]: boolean }
*/
calculateAllEmployees(employeeDuties, vacationMap = {}) {
const results = {};
for (const [name, duties] of Object.entries(employeeDuties)) {
const isVac = Boolean(vacationMap[name]);
results[name] = this.calculateMonthlyBonus(duties, isVac);
}
return results;
}
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 {
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: []
};
}
formatCurrency(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
}
// Make it available globally
window.BonusCalculator = BonusCalculator;