section-2
Version:
A library for calculating unsocial hours entitlements under the NHS agenda for change's section 2
301 lines (300 loc) • 14.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.calculateUsh = void 0;
const shiftLengths_1 = require("../utils/shiftLengths");
const formatDates_1 = require("../utils/formatDates");
const validateTimestamp_1 = require("../utils/validateTimestamp");
const calculateRawLowerRate = (from, to) => {
// returns in ms the total time within the lower rate windows
if (!from || !to) {
return 0;
}
const fromObj = new Date(from);
const toObj = new Date(to);
const fromTime = fromObj.getTime();
const toTime = toObj.getTime();
const fromDay = fromObj.getDay();
const toDay = toObj.getDay();
if ((fromDay === 0 && toDay === 0) ||
((0, shiftLengths_1.isBankHoliday)(fromObj) && (0, shiftLengths_1.isBankHoliday)(toObj)) ||
(fromDay === 0 && (0, shiftLengths_1.isBankHoliday)(toObj))) {
// only Sunday or BH
return 0;
}
if (fromDay === 6 && toDay === 6) {
//only Saturday
return toTime - fromTime;
}
let lowerRateHours = 0;
if ((fromDay === 6 && toDay === 0) || (0, shiftLengths_1.isBankHoliday)(toObj)) {
// Saturday into Sunday or into a bank holiday
return toObj.setHours(0, 0, 0, 0) - fromTime;
}
if ((fromDay === 0 && toDay === 1) ||
((0, shiftLengths_1.isBankHoliday)(fromObj) && !(0, shiftLengths_1.isBankHoliday)(toObj) && toDay > 0)) {
// Sunday into Monday or BH into non BH
return (Math.min(toTime, toObj.setHours(6, 0, 0, 0)) -
Math.max(fromTime, toObj.setHours(0, 0, 0, 0)));
}
const from2000 = fromObj.setHours(20, 0, 0, 0);
if (fromDay === 5 && toDay === 6) {
// Friday into Saturday
lowerRateHours = toTime - Math.max(from2000, fromTime);
}
else {
const from0600 = fromObj.setHours(6, 0, 0, 0);
if (fromTime < from0600) {
// start before 0600
lowerRateHours += Math.min(from0600, toTime) - fromTime;
}
const from2400 = fromObj.setHours(24, 0, 0, 0);
if ((fromTime <= from2000 && toTime > from2000) ||
fromTime > from2000) {
// from 2000 to midnight
lowerRateHours +=
Math.min(toTime, from2400) - Math.max(from2000, fromTime);
}
if (toTime > from2400) {
// from midnight to 0600
lowerRateHours +=
Math.min(toTime, toObj.setHours(6, 0, 0, 0)) - from2400;
}
const to2000 = toObj.setHours(20, 0, 0, 0);
if (toTime > to2000 && to2000 > from2400) {
// end after 2000
//TODO unreachable?
lowerRateHours += toTime - Math.max(to2000, fromTime);
}
}
return lowerRateHours;
};
const calculateRawHigherRate = (from, to) => {
// calculate in ms the total time within the higher rate windows
if (!from || !to) {
return 0;
}
const fromObj = new Date(from);
const toObj = new Date(to);
const fromTime = fromObj.getTime();
const toTime = toObj.getTime();
const fromDay = fromObj.getDay();
const toDay = toObj.getDay();
if (fromDay !== 0 &&
toDay !== 0 &&
!(0, shiftLengths_1.isBankHoliday)(fromObj) &&
!(0, shiftLengths_1.isBankHoliday)(toObj)) {
// neither Sunday nor BH
return 0;
}
if ((fromDay === 0 && toDay === 0) ||
((0, shiftLengths_1.isBankHoliday)(fromObj) && (0, shiftLengths_1.isBankHoliday)(toObj)) ||
(fromDay === 0 && (0, shiftLengths_1.isBankHoliday)(toObj)) ||
((0, shiftLengths_1.isBankHoliday)(fromObj) && toDay === 0)) {
// all Sunday or BH
return toTime - fromTime;
}
const to0000 = toObj.setHours(0, 0, 0, 0);
if (fromDay === 6 || (!(0, shiftLengths_1.isBankHoliday)(fromObj) && (0, shiftLengths_1.isBankHoliday)(toObj))) {
// Saturday into Sunday or non-BH into BH
return toTime - to0000;
}
if (fromDay === 0 || ((0, shiftLengths_1.isBankHoliday)(fromObj) && !(0, shiftLengths_1.isBankHoliday)(toObj))) {
// Sunday into Monday or BH into non-BH
return to0000 - fromTime;
}
return 0;
};
const calculateAverageUshForRelief = (from, plannedTo, weeks, log, employmentId) => {
const averagePeriodStart = (0, formatDates_1.formatDate)((0, formatDates_1.addDaysToTimestamp)(from, weeks * -7), "yyyy-mm-dd");
const fromSortingString = (0, formatDates_1.formatDate)(from, "yyyy-mm-dd");
const totalUsh = { lower: 0, higher: 0, hours: 0 };
for (const shift of log) {
if (shift.date < averagePeriodStart ||
shift.employment_id !== employmentId ||
shift.type === "AL (relief)" ||
shift.type === "Absent (TOIL relief)" ||
shift.type === "TOIL" ||
shift.type === "Sick") {
continue;
}
if (shift.date >= fromSortingString) {
break;
}
totalUsh.lower = totalUsh.lower + shift.lower_rate;
totalUsh.higher = totalUsh.higher + shift.higher_rate;
//only count hours when USH was possible (i.e. under 37.5/wk threshold)
totalUsh.hours =
totalUsh.hours +
Math.max(0, shift.actual_hours -
shift.time_and_half -
shift.double -
(shift.overrun_type === "TOIL"
? shift.overrun_hours || 0
: 0));
}
const shiftHours = (0, shiftLengths_1.calculateShiftHours)(from, plannedTo);
return {
plannedLowerRaw: totalUsh.hours === 0
? 0
: Math.round((totalUsh.lower / totalUsh.hours) * shiftHours),
plannedHigherRaw: totalUsh.hours === 0
? 0
: Math.round((totalUsh.higher / totalUsh.hours) * shiftHours),
};
};
const ushTypes = ["Normal", "OT", "AL", "Absent (TOIL)", "Bank"];
/**
* Calculates USH object containing calculated unsocial hours in ms (adjusted for additional hours if hoursOverThreshold > 0)
*
* @param shift - shift parameters for calculating
* @param shift.from - the Date object representing of the start of the shift
* @param shift.planned_to - the Date object representing of the planned end of the shift
* @param shift.type - the type of shift, defaults to `"Normal"`
* @param shift.actual_to - the Date object representing of the actual end of the shift, defaults to `null`
* @param shift.overrun_type - the type of overrun, defaults to `"OT"`
* @param shift.employment_id - the unique identifier of the shift's employment, defaults to `undefined`
* @param shift.break_override - the duration of the shift's break in ms to override the default of 30mins every 6hrs, defaults to `null`
* @param shift.shifts - an array of shifts, used to calculate different rate thresholds and unsocial averages, defaults to `[]`
* @param shift.hours_over_threshold - number of milliseconds over the 37.5hrs threshold **including this shift**
* @param shift.half_is_all_ush - if set to `true` shifts that are exactly half unsocial will earn the whole of the shift as unsocial (e.g. a normal Monday 1600-0000 = 7.5hrs lower rate), if `false` only shifts that are **more** than half unsocial will (e.g. a normal Monday 1600-0000 = 4hrs lower rate), defaults to `true`
* @param shift.break_from_higher - if set to `true` breaks will first be deducted from higher rate earnings, if `false` from lower rate first, defaults to `true`
* @param shift.leave_relief_ush_type - specifies the way unsocial hours are calculated when a shift is leave or TOIL on unplanned relief, defaults to `"best"`
*
* @returns USH
*/
const calculateUsh = ({ from, planned_to, type = "Normal", hours_over_threshold = 0, break_override = null, shifts = [], half_is_all_ush = false, break_from_higher = true, overrun_type = "OT", actual_to = null, leave_relief_ush_type = "best", employment_id, }) => {
if (!(0, validateTimestamp_1.validateTimestampParameters)(from, planned_to, actual_to)) {
//times are either invalid or out of sequence - return 0 for everything
return { lower_rate: 0, higher_rate: 0 };
}
let plannedLowerRaw = 0;
let plannedHigherRaw = 0;
let overrunLower = 0;
let overrunHigher = 0;
if (from &&
planned_to &&
(ushTypes.includes(type) || (type === "TOIL" && overrun_type == "OT"))) {
const fromObj = new Date(from);
const plannedToObj = new Date(planned_to);
const actualToObj = actual_to ? new Date(actual_to) : null;
if (ushTypes.includes(type)) {
plannedLowerRaw = calculateRawLowerRate(fromObj, plannedToObj);
plannedHigherRaw = calculateRawHigherRate(fromObj, plannedToObj);
}
const totalPlannedUshRaw = plannedLowerRaw + plannedHigherRaw;
const paidOverrunLength = actualToObj && overrun_type === "OT"
? actualToObj.getTime() - plannedToObj.getTime()
: 0;
if (paidOverrunLength && actualToObj && overrun_type === "OT") {
overrunLower = calculateRawLowerRate(plannedToObj, actualToObj);
overrunHigher = calculateRawHigherRate(plannedToObj, actualToObj);
}
let plannedLength = plannedToObj.getTime() - fromObj.getTime();
const breakLength = (0, shiftLengths_1.calculateBreak)(plannedLength, break_override);
if (hours_over_threshold) {
//recalculate USH based on new end including break
if (type === "OT") {
plannedToObj.setTime(Math.min(plannedToObj.getTime(), Math.max(fromObj.getTime(), plannedToObj.getTime() +
paidOverrunLength -
hours_over_threshold)));
plannedLowerRaw = calculateRawLowerRate(fromObj, plannedToObj);
plannedHigherRaw = calculateRawHigherRate(fromObj, plannedToObj);
}
if ((overrunLower || overrunHigher) && actualToObj) {
const originalPlannedTo = new Date(planned_to);
actualToObj.setTime(Math.max(actualToObj.getTime() - hours_over_threshold, originalPlannedTo.getTime()));
overrunLower = calculateRawLowerRate(originalPlannedTo, actualToObj);
overrunHigher = calculateRawHigherRate(originalPlannedTo, actualToObj);
}
}
if (totalPlannedUshRaw > plannedLength / 2 ||
(half_is_all_ush && totalPlannedUshRaw >= plannedLength / 2)) {
// more than half the shift is USH
if (!plannedHigherRaw) {
// only lower rate
plannedLowerRaw = Math.max(0, plannedLength -
breakLength -
Math.max(hours_over_threshold - paidOverrunLength, 0));
}
else if (!plannedLowerRaw) {
// only higher rate
plannedHigherRaw = Math.max(0, plannedLength -
breakLength -
Math.max(hours_over_threshold - paidOverrunLength, 0));
}
else {
if (hours_over_threshold - paidOverrunLength > 0) {
// update shift length to match USH length with OT hours deducted
plannedLength = plannedToObj.getTime() - fromObj.getTime();
}
if (break_from_higher) {
// take break from more expensive side
const higherBreak = Math.min(plannedHigherRaw, breakLength);
const plannedHigherNoBreak = plannedHigherRaw;
//higher rate can never be increased beyond its raw value
plannedHigherRaw = Math.min(plannedHigherRaw - higherBreak, plannedLength - plannedLowerRaw - higherBreak);
plannedLowerRaw =
plannedLength -
plannedHigherNoBreak -
(breakLength - higherBreak);
}
else {
// take break from less expensive side
const lowerBreak = Math.min(plannedLowerRaw, breakLength);
const plannedLowerNoBreak = plannedLowerRaw;
plannedLowerRaw = Math.min(plannedLowerRaw - lowerBreak, plannedLength - plannedHigherRaw - lowerBreak);
plannedHigherRaw =
plannedLength -
plannedLowerNoBreak -
(breakLength - lowerBreak);
}
}
}
}
else if (type === "AL (relief)" || type === "Absent (TOIL relief)") {
switch (leave_relief_ush_type) {
case "best":
{
const { plannedLowerRaw: lower13, plannedHigherRaw: higher13, } = calculateAverageUshForRelief(from, planned_to, 13, shifts, employment_id);
const { plannedLowerRaw: lower52, plannedHigherRaw: higher52, } = calculateAverageUshForRelief(from, planned_to, 52, shifts, employment_id);
//TODO weight for higher?
if (lower13 + higher13 > lower52 + higher52) {
plannedLowerRaw = lower13;
plannedHigherRaw = higher13;
}
else {
plannedLowerRaw = lower52;
plannedHigherRaw = higher52;
}
}
break;
case "average13":
{
({ plannedLowerRaw, plannedHigherRaw } =
calculateAverageUshForRelief(from, planned_to, 13, shifts, employment_id));
}
break;
case "average52":
{
({ plannedLowerRaw, plannedHigherRaw } =
calculateAverageUshForRelief(from, planned_to, 52, shifts, employment_id));
}
break;
case "flat":
{
if (from.getDay() && !(0, shiftLengths_1.isBankHoliday)(from)) {
plannedLowerRaw = 7200000; // 2hrs;
}
else {
plannedHigherRaw = 7200000; // 2hrs (Sun);
}
}
break;
}
}
return {
lower_rate: Math.max(0, plannedLowerRaw + overrunLower),
higher_rate: Math.max(0, plannedHigherRaw + overrunHigher),
};
};
exports.calculateUsh = calculateUsh;