This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
// ── CPAS point roll-off model ────────────────────────────────────────────────
|
||||
// Handbook rule: points roll off only after the employee goes a full
|
||||
// ROLLOFF_DAYS (90) with NO new violations. The clock is shared across all of an
|
||||
// employee's active points and is RESET by any new non-negated violation. Each
|
||||
// completed clean cycle removes ROLLOFF_POINTS (5), oldest violation first, and
|
||||
// every successive roll-off requires another fresh clean cycle.
|
||||
//
|
||||
// This is intentionally NOT a per-violation "expires 90 days after its own
|
||||
// incident date" window — a new violation pushes back roll-off for everything.
|
||||
|
||||
const ROLLOFF_POINTS = 5;
|
||||
const ROLLOFF_DAYS = 90;
|
||||
const MS_PER_DAY = 86400000;
|
||||
|
||||
// All dates are treated as UTC calendar days to match SQLite's DATE('now').
|
||||
function dayValue(dateStr) {
|
||||
const [y, m, d] = String(dateStr).slice(0, 10).split('-').map(Number);
|
||||
return Date.UTC(y, m - 1, d);
|
||||
}
|
||||
function daysBetween(fromStr, toStr) {
|
||||
return Math.round((dayValue(toStr) - dayValue(fromStr)) / MS_PER_DAY);
|
||||
}
|
||||
function addDays(dateStr, n) {
|
||||
return new Date(dayValue(dateStr) + n * MS_PER_DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Compute an employee's CPAS standing as of `asOf` (default today).
|
||||
//
|
||||
// `violations` is an array of rows with at least { id, points, incident_date,
|
||||
// negated }. Negated rows and rows dated after `asOf` are ignored.
|
||||
//
|
||||
// Returns:
|
||||
// activePoints — points still counting against the employee
|
||||
// totalPoints — sum of all (non-negated, on-or-before asOf) points
|
||||
// rolledOffPoints — points already retired by completed clean cycles
|
||||
// cycles — completed 90-day clean cycles since the last violation
|
||||
// violationCount — non-negated violations still retaining points
|
||||
// lastViolationDate — most recent non-negated incident date (anchors the clock)
|
||||
// nextRolloffDate — date the next 5 points retire (null if nothing left)
|
||||
// perViolation — oldest-first breakdown: each row + { rolledOff, activePoints }
|
||||
// schedule — future roll-off events: { date, points_off, points_after, days_remaining }
|
||||
function computeStanding(violations, asOf = todayStr()) {
|
||||
const asOfVal = dayValue(asOf);
|
||||
const active = violations.filter(
|
||||
v => !v.negated && dayValue(v.incident_date) <= asOfVal
|
||||
);
|
||||
|
||||
if (active.length === 0) {
|
||||
return {
|
||||
activePoints: 0, totalPoints: 0, rolledOffPoints: 0, cycles: 0,
|
||||
violationCount: 0, lastViolationDate: null, nextRolloffDate: null,
|
||||
perViolation: [], schedule: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...active].sort((a, b) => {
|
||||
const d = dayValue(a.incident_date) - dayValue(b.incident_date);
|
||||
return d !== 0 ? d : (a.id - b.id);
|
||||
});
|
||||
|
||||
const totalPoints = sorted.reduce((s, v) => s + v.points, 0);
|
||||
const lastViolationDate = String(sorted[sorted.length - 1].incident_date).slice(0, 10);
|
||||
const daysSince = daysBetween(lastViolationDate, asOf);
|
||||
const cycles = daysSince > 0 ? Math.floor(daysSince / ROLLOFF_DAYS) : 0;
|
||||
const rolledOffPoints = Math.min(totalPoints, cycles * ROLLOFF_POINTS);
|
||||
const activePoints = totalPoints - rolledOffPoints;
|
||||
|
||||
// Retire points oldest-first.
|
||||
let toRoll = rolledOffPoints;
|
||||
const perViolation = sorted.map(v => {
|
||||
const off = Math.min(v.points, toRoll);
|
||||
toRoll -= off;
|
||||
return { ...v, rolledOff: off, activePoints: v.points - off };
|
||||
});
|
||||
const violationCount = perViolation.filter(v => v.activePoints > 0).length;
|
||||
|
||||
// Project remaining roll-offs forward. Cycle N retires at lastViolation + N*90.
|
||||
const schedule = [];
|
||||
let remaining = activePoints;
|
||||
let c = cycles;
|
||||
while (remaining > 0) {
|
||||
c += 1;
|
||||
const date = addDays(lastViolationDate, c * ROLLOFF_DAYS);
|
||||
const off = Math.min(ROLLOFF_POINTS, remaining);
|
||||
remaining -= off;
|
||||
schedule.push({
|
||||
date,
|
||||
points_off: off,
|
||||
points_after: remaining,
|
||||
days_remaining: daysBetween(asOf, date),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
activePoints, totalPoints, rolledOffPoints, cycles, violationCount,
|
||||
lastViolationDate,
|
||||
nextRolloffDate: schedule.length ? schedule[0].date : null,
|
||||
perViolation, schedule,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROLLOFF_POINTS, ROLLOFF_DAYS,
|
||||
computeStanding, daysBetween, addDays, todayStr,
|
||||
};
|
||||
Reference in New Issue
Block a user