Bundle the planning core at a871f8f (develop after PR #278) as a frozen
fixture and compare it with the current engine on 5,000 seeded random
requests. Outcomes, stable explanations, explanation errors and final-state
snapshots must be strictly deep-equal. Requests cover obligations,
preferences, IF_THEN and TIME_WINDOW rules, earned entitlements, composed
optimization rules, declared days, year boundaries and public holidays.
Refs #280
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3223 lines
141 KiB
JavaScript
3223 lines
141 KiB
JavaScript
// src/app/services/declared-non-working-days.ts
|
||
import Holidays from "date-holidays";
|
||
|
||
// src/app/services/planning-date.ts
|
||
function planningDateInParis(value) {
|
||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||
timeZone: "Europe/Paris",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit"
|
||
}).formatToParts(value);
|
||
const get = (type) => parts.find((part) => part.type === type)?.value ?? "";
|
||
return `${get("year").padStart(4, "0")}-${get("month")}-${get("day")}`;
|
||
}
|
||
function addPlanningYears(key, years) {
|
||
const date = new Date(`${key}T00:00:00Z`);
|
||
const month = date.getUTCMonth();
|
||
date.setUTCFullYear(date.getUTCFullYear() + years);
|
||
if (date.getUTCMonth() !== month)
|
||
date.setUTCDate(0);
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
function isPlanningDate(value) {
|
||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||
return false;
|
||
const date = new Date(`${value}T00:00:00Z`);
|
||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||
}
|
||
|
||
// src/app/services/declared-non-working-days.ts
|
||
var holidaysByYear = new Map;
|
||
function frenchPublicHolidaysForYear(year) {
|
||
let holidays = holidaysByYear.get(year);
|
||
if (!holidays) {
|
||
holidays = new Set(new Holidays("FR", { languages: ["en"] }).getHolidays(year, "en").filter((holiday) => holiday.type === "public").map((holiday) => String(holiday.date).slice(0, 10)));
|
||
holidaysByYear.set(year, holidays);
|
||
}
|
||
return holidays;
|
||
}
|
||
function isWorkingPlanningDate(value) {
|
||
if (!isPlanningDate(value))
|
||
return false;
|
||
const day = new Date(`${value}T00:00:00Z`).getUTCDay();
|
||
return day !== 0 && day !== 6 && !frenchPublicHolidaysForYear(Number(value.slice(0, 4))).has(value);
|
||
}
|
||
function parseDeclaredDays(value) {
|
||
if (!Array.isArray(value))
|
||
throw new Error("Declared non-working days must be a list.");
|
||
const dates = new Set;
|
||
return value.map((item) => {
|
||
if (typeof item !== "object" || item === null)
|
||
throw new Error("Each declared non-working day needs a date and leave type.");
|
||
const { date, type } = item;
|
||
if (!isWorkingPlanningDate(date) || dates.has(date))
|
||
throw new Error("Declared dates must be unique working days outside French public holidays.");
|
||
if (type !== "cp" && type !== "rtt" && type !== "other")
|
||
throw new Error("Declared non-working days must have a CP, RTT, or Other type.");
|
||
dates.add(date);
|
||
return { date, type };
|
||
});
|
||
}
|
||
|
||
// src/app/services/planning-objectives.ts
|
||
var OPTIMIZATION_CATALOG = [
|
||
{
|
||
elements: "leave-balances",
|
||
measure: "unplaced-quantity",
|
||
conditions: ["at-risk"],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "working-days"
|
||
},
|
||
{
|
||
elements: "public-holiday-weeks",
|
||
measure: "public-holiday-week-value",
|
||
conditions: ["complete"],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "calendar-days"
|
||
},
|
||
{
|
||
elements: "time-off-periods",
|
||
measure: "non-working-day-gain",
|
||
conditions: [],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "calendar-days"
|
||
},
|
||
{
|
||
elements: "work-sequences",
|
||
measure: "work-sequence-length",
|
||
conditions: ["internal", "non-empty"],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "working-days"
|
||
},
|
||
{
|
||
elements: "work-sequences",
|
||
measure: "work-sequence-deficit",
|
||
conditions: ["internal", "non-empty"],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "working-days"
|
||
},
|
||
{
|
||
elements: "leave-placements",
|
||
measure: "placement-count",
|
||
conditions: ["supporting"],
|
||
aggregations: ["sum", "count", "maximum"],
|
||
unit: "count"
|
||
}
|
||
];
|
||
function validateOptimizationConfiguration(value) {
|
||
const invalid = (message) => ({
|
||
valid: false,
|
||
code: "invalid-optimization-configuration",
|
||
message
|
||
});
|
||
const unsupported = (message) => ({
|
||
valid: false,
|
||
code: "unsupported-optimization-configuration",
|
||
message
|
||
});
|
||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||
return invalid("An optimization configuration must be an object.");
|
||
const config = value;
|
||
const fields = [
|
||
"elements",
|
||
"conditions",
|
||
"measure",
|
||
"aggregation",
|
||
"direction",
|
||
"thresholdDays"
|
||
];
|
||
if (Object.keys(config).some((key) => !fields.includes(key)))
|
||
return unsupported("The optimization configuration contains an unsupported field.");
|
||
if (config["direction"] !== "minimize" && config["direction"] !== "maximize")
|
||
return invalid("The optimization direction must be minimize or maximize.");
|
||
if (!Array.isArray(config["conditions"]) || [...config["conditions"]].some((condition) => typeof condition !== "string") || new Set(config["conditions"]).size !== config["conditions"].length)
|
||
return invalid("Optimization conditions must be a list of distinct conditions.");
|
||
const row = OPTIMIZATION_CATALOG.find((item) => item.elements === config["elements"] && item.measure === config["measure"]);
|
||
if (!row)
|
||
return unsupported("The planning elements and measure are not compatible.");
|
||
const conditions = row.conditions;
|
||
if (config["conditions"].some((condition) => !conditions.includes(condition)))
|
||
return unsupported("A condition is not supported for these planning elements.");
|
||
const aggregations = row.aggregations;
|
||
if (typeof config["aggregation"] !== "string" || !aggregations.includes(config["aggregation"]))
|
||
return unsupported("The aggregation is not supported for this measure.");
|
||
if (config["measure"] === "work-sequence-deficit") {
|
||
if (!Number.isSafeInteger(config["thresholdDays"]) || Number(config["thresholdDays"]) <= 0)
|
||
return invalid("The deficit threshold must be a positive integer in working days.");
|
||
} else if ("thresholdDays" in config)
|
||
return unsupported("Only the work-sequence deficit accepts a working-day threshold.");
|
||
return { valid: true, configuration: value };
|
||
}
|
||
var OPTIMIZATION_PRESETS = {
|
||
unplacedAtRisk: {
|
||
elements: "leave-balances",
|
||
conditions: ["at-risk"],
|
||
measure: "unplaced-quantity",
|
||
aggregation: "sum",
|
||
direction: "minimize"
|
||
},
|
||
holidayWeekValue: {
|
||
elements: "public-holiday-weeks",
|
||
conditions: ["complete"],
|
||
measure: "public-holiday-week-value",
|
||
aggregation: "sum",
|
||
direction: "maximize"
|
||
},
|
||
nonWorkingDayGain: {
|
||
elements: "time-off-periods",
|
||
conditions: [],
|
||
measure: "non-working-day-gain",
|
||
aggregation: "sum",
|
||
direction: "maximize"
|
||
},
|
||
longestWorkSequence: {
|
||
elements: "work-sequences",
|
||
conditions: [],
|
||
measure: "work-sequence-length",
|
||
aggregation: "maximum",
|
||
direction: "minimize"
|
||
},
|
||
shortSequenceDeficit: {
|
||
elements: "work-sequences",
|
||
conditions: ["internal", "non-empty"],
|
||
measure: "work-sequence-deficit",
|
||
thresholdDays: 5,
|
||
aggregation: "sum",
|
||
direction: "minimize"
|
||
},
|
||
supportingPlacements: {
|
||
elements: "leave-placements",
|
||
conditions: ["supporting"],
|
||
measure: "placement-count",
|
||
aggregation: "count",
|
||
direction: "minimize"
|
||
}
|
||
};
|
||
var nonWorkingDateCounts = new WeakMap;
|
||
function evaluateOptimization(configuration, observations) {
|
||
const validation = validateOptimizationConfiguration(configuration);
|
||
if (!validation.valid)
|
||
throw new Error(`${validation.code}: ${validation.message}`);
|
||
const selected = (condition) => configuration.conditions.includes(condition);
|
||
let values;
|
||
switch (configuration.elements) {
|
||
case "leave-balances":
|
||
values = observations.leaveBalances.filter((balance) => !selected("at-risk") || balance.atRisk).map((balance) => balance.unplacedQuantity);
|
||
break;
|
||
case "public-holiday-weeks":
|
||
values = observations.publicHolidayWeeks.filter((week) => !selected("complete") || week.complete).map((week) => week.value);
|
||
break;
|
||
case "time-off-periods":
|
||
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
|
||
const cached = nonWorkingDateCounts.get(nonWorkingDates);
|
||
if (cached !== undefined)
|
||
return cached;
|
||
const count = new Set(nonWorkingDates).size;
|
||
if (Object.isFrozen(nonWorkingDates))
|
||
nonWorkingDateCounts.set(nonWorkingDates, count);
|
||
return count;
|
||
});
|
||
break;
|
||
case "work-sequences":
|
||
values = observations.workSequences.filter((sequence) => (!selected("internal") || sequence.internal) && (!selected("non-empty") || sequence.length > 0)).map((sequence) => configuration.measure === "work-sequence-deficit" ? sequence.length > 0 ? Math.max(0, configuration.thresholdDays - sequence.length) : 0 : sequence.length);
|
||
break;
|
||
case "leave-placements":
|
||
values = observations.leavePlacements.filter((placement) => !selected("supporting") || placement.supporting).map(() => 1);
|
||
}
|
||
switch (configuration.aggregation) {
|
||
case "count":
|
||
return values.length;
|
||
case "sum":
|
||
return values.reduce((total, value) => total + value, 0);
|
||
case "maximum":
|
||
return values.reduce((maximum, value) => Math.max(maximum, value), 0);
|
||
}
|
||
}
|
||
function compareObjectiveValues(direction, left, right) {
|
||
if (left === right)
|
||
return 0;
|
||
const difference = typeof left === "number" && typeof right === "number" ? left - right : left < right ? -1 : left > right ? 1 : 0;
|
||
return direction === "minimize" ? difference : -difference;
|
||
}
|
||
function firstDifferentObjective(left, right) {
|
||
return left.findIndex((objective, index) => objective.value !== right[index]?.value);
|
||
}
|
||
function compareObjectiveLists(left, right) {
|
||
const index = firstDifferentObjective(left, right);
|
||
return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
|
||
}
|
||
|
||
// src/app/services/leave-planner.core.ts
|
||
var obligationRequirementCache = new WeakMap;
|
||
var occurrenceCache = new WeakMap;
|
||
var calendarPatternCache = new WeakMap;
|
||
var preferencePatternCache = new WeakMap;
|
||
var declaredWindowCache = new WeakMap;
|
||
var calendarObservationCache = new WeakMap;
|
||
var calendarPeriodCache = new WeakMap;
|
||
function now() {
|
||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||
}
|
||
function observeCallback(context, callback) {
|
||
const started = now();
|
||
try {
|
||
callback();
|
||
} finally {
|
||
context.observerTime += now() - started;
|
||
}
|
||
}
|
||
|
||
class DeadlineReached extends Error {
|
||
}
|
||
|
||
class UserCancelled extends Error {
|
||
}
|
||
var DAY_MS = 86400000;
|
||
var OBLIGATION_SOLUTION_LIMIT = 4096;
|
||
var EXACT_OBLIGATION_BRANCH_LIMIT = 1e4;
|
||
var WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
|
||
var VALID_TYPES = new Set(["cp", "rtt", "other"]);
|
||
var VALID_SELECTORS = new Set(["cp", "rtt", "other", "any"]);
|
||
var VALID_MODES = new Set(["before", "after", "around", "auto"]);
|
||
function dateKey(value) {
|
||
if (typeof value === "string") {
|
||
const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1];
|
||
if (dateOnly)
|
||
return isPlanningDate(dateOnly) ? dateOnly : null;
|
||
const parsed = new Date(value);
|
||
return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed);
|
||
}
|
||
if (value instanceof Date && !Number.isNaN(value.getTime()))
|
||
return planningDateInParis(value);
|
||
return null;
|
||
}
|
||
function runtimeRequest(value, calculationDate) {
|
||
const source = recordValue(value) ?? {};
|
||
return {
|
||
calculationDate: calculationDate ?? (isPlanningDate(source.calculationDate) ? source.calculationDate : planningDateInParis(new Date)),
|
||
horizon: source.horizon,
|
||
balances: source.balances,
|
||
declaredDays: source.declaredDays,
|
||
rules: source.rules,
|
||
identities: source.identities,
|
||
labels: source.labels,
|
||
deadlineMs: source.deadlineMs,
|
||
progress: source.progress
|
||
};
|
||
}
|
||
function recordValue(value) {
|
||
return typeof value === "object" && value !== null ? value : undefined;
|
||
}
|
||
function utcDate(key) {
|
||
return new Date(`${key}T00:00:00Z`);
|
||
}
|
||
function addDays(key, days) {
|
||
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||
}
|
||
function compareDate(a, b) {
|
||
return a < b ? -1 : a > b ? 1 : 0;
|
||
}
|
||
function compareText(a, b) {
|
||
return a < b ? -1 : a > b ? 1 : 0;
|
||
}
|
||
function dateRange(start, endExclusive) {
|
||
const result = [];
|
||
for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1))
|
||
result.push(date);
|
||
return result;
|
||
}
|
||
function defaultHorizon(start) {
|
||
return { start, end: addPlanningYears(start, 1) };
|
||
}
|
||
function holidaySet(horizon) {
|
||
const first = Number(addDays(horizon.start, -400).slice(0, 4));
|
||
const last = Number(addDays(horizon.end, 400).slice(0, 4));
|
||
const result = new Set;
|
||
for (let year = first;year <= last; year++) {
|
||
for (const holiday of frenchPublicHolidaysForYear(year))
|
||
result.add(holiday);
|
||
}
|
||
return result;
|
||
}
|
||
function isWeekend(key) {
|
||
const day = utcDate(key).getUTCDay();
|
||
return day === 0 || day === 6;
|
||
}
|
||
function isWorking(key, holidays) {
|
||
return !isWeekend(key) && !holidays.has(key);
|
||
}
|
||
function holidayWeekCandidates(context) {
|
||
const candidates = new Map;
|
||
const inHorizon = (date) => date >= context.horizon.start && date < context.horizon.end;
|
||
for (const holiday of [...context.holidays].sort(compareDate)) {
|
||
const weekday = utcDate(holiday).getUTCDay();
|
||
if (weekday < 1 || weekday > 5)
|
||
continue;
|
||
const start = addDays(holiday, 1 - weekday);
|
||
const weekdays = dateRange(start, addDays(start, 5));
|
||
const dates = weekdays.filter((date) => isWorking(date, context.holidays));
|
||
if (!dates.length || !dates.some(inHorizon) || !inHorizon(holiday) && !dates.some((date) => context.declaredDates.has(date)) || dates.some((date) => !inHorizon(date) && !context.declaredDates.has(date)))
|
||
continue;
|
||
const value = weekdays.reduce((sum, date, index) => sum + (context.holidays.has(date) ? Math.min(index + 1, 5 - index) : 0), 0);
|
||
candidates.set(start, { start, dates, value });
|
||
}
|
||
return [...candidates.values()].sort((a, b) => b.value - a.value || compareDate(a.start, b.start));
|
||
}
|
||
function reason(code, message, ruleIds) {
|
||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||
}
|
||
function cloneState(state, placements) {
|
||
return {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: placements ?? new Map([...state.placements.entries()].map(([date, placement]) => [
|
||
date,
|
||
{
|
||
...placement,
|
||
ruleIds: new Set(placement.ruleIds)
|
||
}
|
||
])),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates]
|
||
}))
|
||
};
|
||
}
|
||
function planningStateSnapshot(state, context) {
|
||
return {
|
||
calculationDate: context.request.calculationDate,
|
||
horizon: context.horizon,
|
||
...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({
|
||
...placement,
|
||
ruleIds: [...placement.ruleIds].sort()
|
||
})),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates]
|
||
}))
|
||
};
|
||
}
|
||
function normalizeType(value) {
|
||
const normalized = String(value ?? "").toLowerCase();
|
||
return VALID_TYPES.has(normalized) ? normalized : null;
|
||
}
|
||
function normalizeSelector(value) {
|
||
const normalized = String(value ?? "").toLowerCase();
|
||
return VALID_SELECTORS.has(normalized) ? normalized : null;
|
||
}
|
||
function normalizeForce(value) {
|
||
if (value === undefined || value === "")
|
||
return "preference";
|
||
const normalized = String(value).toLowerCase();
|
||
if (normalized === "obligation" || normalized === "preference")
|
||
return normalized;
|
||
return null;
|
||
}
|
||
function normalizeRuleType(value) {
|
||
const normalized = String(value ?? "").toLowerCase();
|
||
return normalized === "time_window" || normalized === "if_then" || normalized === "optimization" ? normalized : null;
|
||
}
|
||
function normalizeMode(value) {
|
||
const normalized = String(value ?? "").toLowerCase();
|
||
return VALID_MODES.has(normalized) ? normalized : null;
|
||
}
|
||
function compareDateLists(a, b) {
|
||
for (let index = 0;index < Math.min(a.length, b.length); index++) {
|
||
const compared = compareDate(a[index], b[index]);
|
||
if (compared)
|
||
return compared;
|
||
}
|
||
return a.length - b.length;
|
||
}
|
||
function asInteger(value) {
|
||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||
}
|
||
function normalizeBalances(request, horizon) {
|
||
const rawInput = request.balances;
|
||
const reasons = [];
|
||
if (!Array.isArray(rawInput)) {
|
||
return {
|
||
balances: [],
|
||
reasons: [reason("balances", "Leave balances must be an array.")]
|
||
};
|
||
}
|
||
const raw = rawInput;
|
||
const ids = new Set;
|
||
const balances = [];
|
||
for (const input of raw) {
|
||
const value = recordValue(input);
|
||
const id = typeof value?.id === "string" ? value.id : "";
|
||
const type = normalizeType(value?.type);
|
||
const quantity = asInteger(value?.quantity);
|
||
const rawAvailableFrom = value?.availableFrom;
|
||
const availableFrom = dateKey(rawAvailableFrom) ?? horizon.start;
|
||
const expiryDate = dateKey(value?.expiryDate);
|
||
if (!id)
|
||
reasons.push(reason("balance-id", "Each leave balance needs a stable identity."));
|
||
else if (ids.has(id))
|
||
reasons.push(reason("duplicate-balance-id", `Leave balance id "${id}" is duplicated.`));
|
||
else
|
||
ids.add(id);
|
||
if (!type)
|
||
reasons.push(reason("balance-type", `Leave balance "${id}" has an invalid leave type.`));
|
||
if (quantity === null || quantity < 0) {
|
||
reasons.push(reason("balance-quantity", `Leave balance "${id}" needs a non-negative integer quantity.`));
|
||
}
|
||
if (!expiryDate)
|
||
reasons.push(reason("balance-expiry", `Leave balance "${id}" needs a valid expiry date.`));
|
||
if (rawAvailableFrom !== undefined && !dateKey(rawAvailableFrom))
|
||
reasons.push(reason("balance-availability", `Leave balance "${id}" needs a valid availability date.`));
|
||
if (expiryDate && compareDate(availableFrom, expiryDate) > 0 && rawAvailableFrom !== undefined) {
|
||
reasons.push(reason("balance-dates", `Leave balance "${id}" must be available on or before expiry.`));
|
||
}
|
||
if (type && quantity !== null && quantity >= 0 && expiryDate) {
|
||
balances.push({
|
||
id,
|
||
type,
|
||
quantity,
|
||
availableFrom,
|
||
expiryDate,
|
||
earned: false
|
||
});
|
||
}
|
||
}
|
||
return { balances, reasons };
|
||
}
|
||
function normalizeRules(request) {
|
||
const rawInput = request.rules;
|
||
const reasons = [];
|
||
if (!Array.isArray(rawInput)) {
|
||
return { rules: [], reasons: [reason("rules", "Planning rules must be an array.")] };
|
||
}
|
||
const raw = rawInput;
|
||
const ids = new Set;
|
||
const rules = [];
|
||
for (const input of raw) {
|
||
const value = recordValue(input);
|
||
if (value?.enabled === false)
|
||
continue;
|
||
const id = typeof value?.id === "string" ? value.id : "";
|
||
const type = normalizeRuleType(value?.type);
|
||
const forceValue = normalizeForce(value?.force);
|
||
const force = forceValue ?? "preference";
|
||
if (!id)
|
||
reasons.push(reason("rule-id", "Each planning rule needs a stable identity."));
|
||
else if (ids.has(id))
|
||
reasons.push(reason("duplicate-rule-id", `Planning rule id "${id}" is duplicated.`));
|
||
else
|
||
ids.add(id);
|
||
if (!forceValue)
|
||
reasons.push(reason("rule-force", `Planning rule "${id}" has an invalid force.`));
|
||
if (!type) {
|
||
reasons.push(reason("rule-type", `Planning rule "${id}" has an unsupported type.`));
|
||
continue;
|
||
}
|
||
if (type === "optimization") {
|
||
const validated = validateOptimizationConfiguration(value?.configuration);
|
||
if (forceValue !== "preference")
|
||
reasons.push(reason("rule-force", "Optimization rules support preference mode only.", [id]));
|
||
if (!validated.valid) {
|
||
reasons.push(reason(validated.code, validated.message, [id]));
|
||
continue;
|
||
}
|
||
rules.push({
|
||
id,
|
||
name: typeof value?.name === "string" ? value.name : id,
|
||
type,
|
||
enabled: true,
|
||
force: "preference",
|
||
configuration: structuredClone(validated.configuration)
|
||
});
|
||
continue;
|
||
}
|
||
const placeDays = asInteger(value?.placeDays);
|
||
const placeType = normalizeSelector(value?.placeType);
|
||
if (placeDays === null || placeDays < 0) {
|
||
reasons.push(reason("rule-quantity", `Planning rule "${id}" needs a non-negative integer quantity.`));
|
||
continue;
|
||
}
|
||
if (!placeType)
|
||
reasons.push(reason("rule-selector", `Planning rule "${id}" has an invalid leave selector.`));
|
||
const base = {
|
||
id,
|
||
name: typeof value?.name === "string" ? value.name : id,
|
||
type,
|
||
enabled: value?.enabled !== false,
|
||
force,
|
||
placeDays,
|
||
placeType: placeType ?? "any"
|
||
};
|
||
if (type === "time_window") {
|
||
const startDate = dateKey(value?.startDate);
|
||
const endDate = dateKey(value?.endDate);
|
||
if (!startDate || !endDate || compareDate(startDate, endDate) > 0) {
|
||
reasons.push(reason("rule-window", `TIME_WINDOW rule "${id}" needs an inclusive, ordered date window.`));
|
||
continue;
|
||
}
|
||
rules.push({ ...base, startDate, endDate, type: "time_window" });
|
||
continue;
|
||
}
|
||
const dayOfWeek = asInteger(value?.dayOfWeek);
|
||
const mode = normalizeMode(value?.placement);
|
||
const rewardDays = asInteger(value?.rewardDays);
|
||
const rawRewardType = value?.rewardType;
|
||
const rewardType = normalizeType(rawRewardType);
|
||
if (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6) {
|
||
reasons.push(reason("rule-weekday", `IF_THEN rule "${id}" needs a weekday from 0 to 6.`));
|
||
}
|
||
if (!mode)
|
||
reasons.push(reason("rule-mode", `IF_THEN rule "${id}" has an unsupported placement mode.`));
|
||
if (rewardDays === null || rewardDays < 0) {
|
||
reasons.push(reason("earned-quantity", `IF_THEN rule "${id}" needs a non-negative earned quantity.`));
|
||
}
|
||
if ((rewardDays ?? 0) > 0 && !rewardType) {
|
||
reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`));
|
||
} else if (rawRewardType !== undefined && !rewardType) {
|
||
reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`));
|
||
}
|
||
if (placeType && mode && dayOfWeek !== null && rewardDays !== null && (rewardDays === 0 ? rawRewardType === undefined || rewardType : rewardType)) {
|
||
rules.push({
|
||
...base,
|
||
type: "if_then",
|
||
dayOfWeek,
|
||
placement: mode,
|
||
rewardDays,
|
||
rewardType: rewardType ?? undefined
|
||
});
|
||
}
|
||
}
|
||
return { rules, reasons };
|
||
}
|
||
function normalizeRequest(request) {
|
||
const source = runtimeRequest(request);
|
||
const rawHorizon = recordValue(source.horizon);
|
||
const fallbackHorizon = defaultHorizon(source.calculationDate);
|
||
const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start;
|
||
const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end;
|
||
const reasons = [];
|
||
if (!rawHorizon) {
|
||
reasons.push(reason("horizon", "The planning horizon is required."));
|
||
} else if (!dateKey(rawHorizon.start)) {
|
||
reasons.push(reason("horizon-start", "The planning horizon start must be a valid calendar date."));
|
||
}
|
||
if (rawHorizon && !dateKey(rawHorizon.end)) {
|
||
reasons.push(reason("horizon-end", "The planning horizon end must be a valid calendar date."));
|
||
}
|
||
if (rawHorizon && compareDate(start, end) >= 0) {
|
||
reasons.push(reason("horizon-order", "The planning horizon must have start before end."));
|
||
}
|
||
const configuredDeadline = source.deadlineMs;
|
||
if (configuredDeadline !== undefined && (typeof configuredDeadline !== "number" || !Number.isFinite(configuredDeadline) || configuredDeadline < 0)) {
|
||
reasons.push(reason("deadline", "The planning deadline must be a finite non-negative number."));
|
||
}
|
||
let declaredDays = [];
|
||
try {
|
||
declaredDays = parseDeclaredDays(source.declaredDays === undefined ? [] : source.declaredDays);
|
||
} catch (error) {
|
||
reasons.push(reason("declared-days", error instanceof Error ? error.message : String(error)));
|
||
}
|
||
const balanceResult = normalizeBalances(source, { start, end });
|
||
const ruleResult = normalizeRules(source);
|
||
reasons.push(...balanceResult.reasons, ...ruleResult.reasons);
|
||
return {
|
||
horizon: reasons.some((item) => item.code === "horizon" || item.code.startsWith("horizon-")) ? undefined : { start, end },
|
||
balances: balanceResult.balances,
|
||
declaredDays,
|
||
rules: ruleResult.rules,
|
||
reasons
|
||
};
|
||
}
|
||
function invalidInputFor(request) {
|
||
const normalized = normalizeRequest(request);
|
||
if (!normalized.reasons.length)
|
||
return;
|
||
return {
|
||
kind: "InvalidInput",
|
||
reasons: normalized.reasons
|
||
};
|
||
}
|
||
function futurePreferenceProtection(rules, startIndex, context) {
|
||
const protectedTypes = new Map;
|
||
context.preferenceDemandByDate = new Map;
|
||
context.anyPreferenceDemandByDate = new Map;
|
||
const futureRules = rules.slice(startIndex);
|
||
for (const [offset, rule] of futureRules.entries()) {
|
||
if (!rule.enabled || rule.force !== "preference" || rule.type === "optimization")
|
||
continue;
|
||
if (rule.type === "time_window" && declaredWindowDays(rule, context).length >= rule.placeDays)
|
||
continue;
|
||
const priorityWeight = futureRules.length - offset;
|
||
const dates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => {
|
||
const patterns = patternsFor(rule, occurrence, context);
|
||
return patterns.some((pattern) => pattern.every((date) => declaredPatternDay(date, rule.placeType, context))) ? [] : patterns.flat().filter((date) => !context.declaredDates.has(date));
|
||
});
|
||
for (const date of dates) {
|
||
context.preferenceDemandByDate.set(date, (context.preferenceDemandByDate.get(date) ?? 0) + priorityWeight);
|
||
if (rule.placeType === "any") {
|
||
context.anyPreferenceDemandByDate.set(date, (context.anyPreferenceDemandByDate.get(date) ?? 0) + priorityWeight);
|
||
continue;
|
||
}
|
||
const types = protectedTypes.get(date) ?? new Set;
|
||
types.add(rule.placeType);
|
||
protectedTypes.set(date, types);
|
||
}
|
||
}
|
||
return protectedTypes;
|
||
}
|
||
function occurrences(rule, context) {
|
||
if (rule.type !== "if_then")
|
||
return [];
|
||
let cached = occurrenceCache.get(context);
|
||
if (!cached) {
|
||
cached = new Map;
|
||
occurrenceCache.set(context, cached);
|
||
}
|
||
let dates = cached.get(rule.id);
|
||
if (!dates) {
|
||
dates = [...context.holidays].filter((date) => date >= context.horizon.start && date < context.horizon.end && utcDate(date).getUTCDay() === rule.dayOfWeek).sort(compareDate);
|
||
cached.set(rule.id, dates);
|
||
}
|
||
return dates;
|
||
}
|
||
function workingNeighbors(date, direction, count, context) {
|
||
const result = [];
|
||
for (let current = addDays(date, direction);result.length < count; current = addDays(current, direction)) {
|
||
context.check?.();
|
||
if (isWorking(current, context.holidays))
|
||
result.push(current);
|
||
if (compareDate(current, addDays(context.horizon.start, -400)) < 0 || compareDate(current, addDays(context.horizon.end, 400)) > 0)
|
||
break;
|
||
}
|
||
return result;
|
||
}
|
||
function patternsFor(rule, holiday, context) {
|
||
let cached = calendarPatternCache.get(context);
|
||
if (!cached) {
|
||
cached = new Map;
|
||
calendarPatternCache.set(context, cached);
|
||
}
|
||
const key = `${rule.id}:${holiday}`;
|
||
const existing = cached.get(key);
|
||
if (existing)
|
||
return existing;
|
||
const count = rule.placeDays;
|
||
const make = (before, after) => [
|
||
...workingNeighbors(holiday, -1, before, context),
|
||
...workingNeighbors(holiday, 1, after, context).reverse()
|
||
].sort(compareDate);
|
||
const patterns = [];
|
||
if (rule.placement === "before")
|
||
patterns.push(make(count, 0));
|
||
else if (rule.placement === "after")
|
||
patterns.push(make(0, count));
|
||
else if (rule.placement === "around") {
|
||
for (let before = 1;before < count; before++)
|
||
patterns.push(make(before, count - before));
|
||
} else {
|
||
for (let before = 0;before <= count; before++)
|
||
patterns.push(make(before, count - before));
|
||
}
|
||
const eligible = patterns.filter((pattern) => pattern.length === count && pattern.every((date) => compareDate(date, context.horizon.start) >= 0 && compareDate(date, context.horizon.end) < 0)).sort(compareDateLists);
|
||
cached.set(key, eligible);
|
||
return eligible;
|
||
}
|
||
function buildRequirements(rules, context, force) {
|
||
if (force === "obligation" && rules === context.rules) {
|
||
const cached = obligationRequirementCache.get(context);
|
||
if (cached)
|
||
return cached;
|
||
}
|
||
const requirements = [];
|
||
let index = 0;
|
||
for (const rule of rules) {
|
||
if (!rule.enabled || rule.force !== force || rule.type === "optimization")
|
||
continue;
|
||
if (rule.type === "time_window") {
|
||
const start = compareDate(rule.startDate, context.horizon.start) < 0 ? context.horizon.start : rule.startDate;
|
||
const end = compareDate(rule.endDate, context.horizon.end) >= 0 ? addDays(context.horizon.end, -1) : rule.endDate;
|
||
const candidates = compareDate(start, end) <= 0 ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, start) >= 0 && compareDate(date, end) <= 0) : [];
|
||
const orderedCandidates = candidates.sort((a, b) => preferenceDateConflictScore(rule.placeType, a, context) - preferenceDateConflictScore(rule.placeType, b, context) || compareDate(a, b));
|
||
requirements.push({
|
||
rule,
|
||
requiredPlacements: rule.placeDays - declaredWindowDays(rule, context).length,
|
||
anchor: start,
|
||
index: index++,
|
||
candidates: orderedCandidates
|
||
});
|
||
} else {
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
const patterns = patternsFor(rule, occurrence, context).sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b));
|
||
requirements.push({
|
||
rule,
|
||
requiredPlacements: Math.min(rule.placeDays, ...patterns.map((pattern) => pattern.filter((date) => !declaredPatternDay(date, rule.placeType, context)).length)),
|
||
anchor: occurrence,
|
||
occurrenceDate: occurrence,
|
||
index: index++,
|
||
patterns
|
||
});
|
||
}
|
||
}
|
||
}
|
||
const ordered = requirements.sort(compareRequirements);
|
||
if (force === "obligation" && rules === context.rules)
|
||
obligationRequirementCache.set(context, ordered);
|
||
return ordered;
|
||
}
|
||
function compareRequirements(a, b) {
|
||
return Number(a.rule.type !== "if_then") - Number(b.rule.type !== "if_then") || compareDate(a.anchor, b.anchor) || a.index - b.index;
|
||
}
|
||
function obligationBranchEstimate(requirements) {
|
||
let estimate = 1;
|
||
for (const requirement of requirements) {
|
||
let branches = requirement.patterns?.length ?? 1;
|
||
if (!requirement.patterns) {
|
||
const candidates = requirement.candidates?.length ?? 0;
|
||
const needed = requirement.requiredPlacements;
|
||
branches = 1;
|
||
for (let index = 1;index <= needed; index++) {
|
||
if (index > candidates) {
|
||
branches = 1;
|
||
break;
|
||
}
|
||
branches = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, branches * (candidates - needed + index) / index);
|
||
}
|
||
}
|
||
estimate = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, estimate * branches);
|
||
if (estimate > EXACT_OBLIGATION_BRANCH_LIMIT)
|
||
return estimate;
|
||
}
|
||
return estimate;
|
||
}
|
||
function selectorMatches(selector, type) {
|
||
return selector === "any" || selector === type;
|
||
}
|
||
function declaredWindowDays(rule, context) {
|
||
if (rule.type !== "time_window" || !context.declaredDays.length)
|
||
return [];
|
||
let byRule = declaredWindowCache.get(context);
|
||
if (!byRule) {
|
||
byRule = new Map;
|
||
declaredWindowCache.set(context, byRule);
|
||
}
|
||
let days = byRule.get(rule.id);
|
||
if (!days) {
|
||
days = context.declaredDays.filter((day) => selectorMatches(rule.placeType, day.type) && day.date >= context.horizon.start && day.date < context.horizon.end && day.date >= rule.startDate && day.date <= rule.endDate).sort((a, b) => compareDate(a.date, b.date)).slice(0, rule.placeDays);
|
||
byRule.set(rule.id, days);
|
||
}
|
||
return days;
|
||
}
|
||
function declaredPatternDay(date, selector, context) {
|
||
if (!context.declaredDates.has(date))
|
||
return;
|
||
return context.declaredDays.find((day) => day.date === date && selectorMatches(selector, day.type));
|
||
}
|
||
function patternDateSatisfied(date, rule, state, context) {
|
||
return Boolean(declaredPatternDay(date, rule.placeType, context) || state.placements.has(date) && selectorMatches(rule.placeType, state.placements.get(date).type));
|
||
}
|
||
function selectedOccurrencePattern(rule, occurrence, state, context) {
|
||
const activation = state.earnedActivations.find((item) => item.ruleId === rule.id && item.occurrence === occurrence);
|
||
return activation?.dates.every((date) => patternDateSatisfied(date, rule, state, context)) ? activation.dates : patternsFor(rule, occurrence, context).find((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context)));
|
||
}
|
||
function assignDateVariants(state, date, selector, ruleId, context) {
|
||
context.check();
|
||
if (context.declaredDates.has(date))
|
||
return [];
|
||
const existing = state.placements.get(date);
|
||
if (existing) {
|
||
if (!selectorMatches(selector, existing.type)) {
|
||
return [];
|
||
}
|
||
if (ruleId) {
|
||
const rule = context.rules.find((item) => item.id === ruleId);
|
||
if (rule?.type === "time_window" && (compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0)) {
|
||
return [];
|
||
}
|
||
}
|
||
const result = cloneState(state);
|
||
if (ruleId)
|
||
result.placements.get(date).ruleIds.add(ruleId);
|
||
return [result];
|
||
}
|
||
const balances = state.balances.filter((balance) => balance.remaining > 0 && selectorMatches(selector, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0).sort((a, b) => {
|
||
if (selector === "any") {
|
||
const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set;
|
||
if (sharedTypes.has(a.type) !== sharedTypes.has(b.type))
|
||
return sharedTypes.has(a.type) ? -1 : 1;
|
||
const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set;
|
||
if (protectedTypes.size && protectedTypes.has(a.type) !== protectedTypes.has(b.type))
|
||
return protectedTypes.has(a.type) ? -1 : 1;
|
||
if (context.reservedTypes.has(a.type) !== context.reservedTypes.has(b.type)) {
|
||
return context.reservedTypes.has(a.type) ? 1 : -1;
|
||
}
|
||
}
|
||
return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id);
|
||
});
|
||
return balances.map((balance) => {
|
||
const result = cloneState(state);
|
||
const target = result.balances.find((item) => item.id === balance.id);
|
||
target.remaining--;
|
||
result.placements.set(date, {
|
||
date,
|
||
balanceId: target.id,
|
||
type: target.type,
|
||
ruleIds: ruleId ? new Set([ruleId]) : new Set,
|
||
supporting: false
|
||
});
|
||
return result;
|
||
});
|
||
}
|
||
function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) {
|
||
context.check();
|
||
if (context.declaredDates.has(date))
|
||
return null;
|
||
if (state.placements.has(date)) {
|
||
return null;
|
||
}
|
||
const balance = state.balances.find((item) => item.id === balanceId);
|
||
if (!balance || balance.remaining <= 0 || compareDate(balance.availableFrom, date) > 0 || compareDate(date, balance.expiryDate) > 0) {
|
||
return null;
|
||
}
|
||
if (!mandatoryRelocationDate(date, balance, ruleIds, context))
|
||
return null;
|
||
const result = cloneState(state, new Map(state.placements));
|
||
const target = result.balances.find((item) => item.id === balanceId);
|
||
target.remaining--;
|
||
result.placements.set(date, {
|
||
date,
|
||
balanceId,
|
||
type: balance.type,
|
||
ruleIds: new Set(ruleIds),
|
||
supporting: false
|
||
});
|
||
return result;
|
||
}
|
||
function addEarnedBalance(state, rule, occurrence, dates, context) {
|
||
if (!rule.rewardDays || !rule.rewardType)
|
||
return state;
|
||
const result = cloneState(state);
|
||
const id = `earned:${rule.id}:${occurrence}`;
|
||
const activation = { id, ruleId: rule.id, occurrence, dates: [...dates] };
|
||
if (!result.earnedActivations.some((item) => item.id === id))
|
||
result.earnedActivations.push(activation);
|
||
const bounds = earnedPeriodBounds(state, activation, context);
|
||
if (bounds.availableFrom <= context.request.calculationDate)
|
||
return result;
|
||
if (!result.balances.some((balance) => balance.id === id)) {
|
||
result.balances.push({
|
||
id,
|
||
type: rule.rewardType,
|
||
quantity: rule.rewardDays,
|
||
remaining: rule.rewardDays,
|
||
...bounds,
|
||
earned: true,
|
||
sourceRuleId: rule.id
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
function earnedPeriodBounds(state, activation, context) {
|
||
const allDates = [...activation.dates, activation.occurrence].sort(compareDate);
|
||
let start = allDates[0] ?? activation.occurrence;
|
||
let end = allDates[allDates.length - 1] ?? activation.occurrence;
|
||
const mergedPeriod = periodData(state, context).find((period) => activation.dates.some((date) => period.placementDates.includes(date) || period.declaredDates?.includes(date)));
|
||
if (mergedPeriod) {
|
||
start = mergedPeriod.start;
|
||
end = mergedPeriod.end;
|
||
}
|
||
while (!isWorking(addDays(start, -1), context.holidays))
|
||
start = addDays(start, -1);
|
||
while (!isWorking(addDays(end, 1), context.holidays))
|
||
end = addDays(end, 1);
|
||
const availableFrom = addDays(end, 1);
|
||
return { availableFrom, expiryDate: addPlanningYears(availableFrom, 1) };
|
||
}
|
||
function finalizeEarnedState(state, context) {
|
||
let reconciled = state;
|
||
for (const rule of context.rules) {
|
||
if (rule.type !== "if_then" || !rule.rewardDays)
|
||
continue;
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
if (reconciled.earnedActivations.some((activation) => activation.ruleId === rule.id && activation.occurrence === occurrence))
|
||
continue;
|
||
const pattern = selectedOccurrencePattern(rule, occurrence, reconciled, context);
|
||
if (pattern)
|
||
reconciled = addEarnedBalance(reconciled, rule, occurrence, pattern, context);
|
||
}
|
||
}
|
||
if (!reconciled.earnedActivations.length)
|
||
return reconciled;
|
||
let result = cloneState(reconciled);
|
||
for (const activation of result.earnedActivations) {
|
||
context.check();
|
||
const bounds = earnedPeriodBounds(result, activation, context);
|
||
if (bounds.availableFrom <= context.request.calculationDate) {
|
||
if ([...result.placements.values()].some((placement) => placement.balanceId === activation.id))
|
||
return null;
|
||
result.balances = result.balances.filter((balance2) => balance2.id !== activation.id);
|
||
continue;
|
||
}
|
||
let balance = result.balances.find((item) => item.id === activation.id);
|
||
if (!balance) {
|
||
const rule = context.rules.find((rule2) => rule2.type === "if_then" && rule2.id === activation.ruleId);
|
||
if (!rule)
|
||
return null;
|
||
result = addEarnedBalance(result, rule, activation.occurrence, activation.dates, context);
|
||
balance = result.balances.find((item) => item.id === activation.id);
|
||
if (!balance)
|
||
continue;
|
||
}
|
||
balance.availableFrom = bounds.availableFrom;
|
||
balance.expiryDate = bounds.expiryDate;
|
||
for (const placement of result.placements.values()) {
|
||
if (placement.balanceId === activation.id && (compareDate(placement.date, balance.availableFrom) < 0 || compareDate(placement.date, balance.expiryDate) > 0))
|
||
return null;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
function* assignDatesVariants(state, dates, rule, context) {
|
||
if (!dates.length)
|
||
return [state];
|
||
const [date, ...rest] = dates;
|
||
if (!isWorking(date, context.holidays) || compareDate(date, context.horizon.start) < 0 || compareDate(date, context.horizon.end) >= 0)
|
||
return [];
|
||
if (context.declaredDates.has(date)) {
|
||
if (declaredPatternDay(date, rule.placeType, context))
|
||
return yield* assignDatesVariants(state, rest, rule, context);
|
||
return [];
|
||
}
|
||
const result = [];
|
||
for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) {
|
||
yield;
|
||
result.push(...yield* assignDatesVariants(next, rest, rule, context));
|
||
}
|
||
return result;
|
||
}
|
||
function* satisfyObligations(requirements, state, context, index = 0, solutions, solutionLimit = OBLIGATION_SOLUTION_LIMIT, chooseOrder = true) {
|
||
yield;
|
||
context.check();
|
||
if (index >= requirements.length) {
|
||
const finalized = finalizeRuleCandidate(state, context, state);
|
||
if (!finalized)
|
||
return null;
|
||
if (solutions)
|
||
solutions.push(finalized);
|
||
context.retain?.(finalized);
|
||
return solutions ? null : finalized;
|
||
}
|
||
const requirement = requirements[index];
|
||
const extensionDates = chooseOrder && context.horizon.end > context.request.calculationDate ? state.earnedActivations.map((activation) => state.balances.find((balance) => balance.id === activation.id)?.availableFrom ?? earnedPeriodBounds(state, activation, context).availableFrom) : [];
|
||
const extensions = extensionDates.length ? requirements.slice(index + 1).flatMap((candidate, offset) => (candidate.patterns ? candidate.patterns.some((pattern) => pattern.some((date) => extensionDates.includes(date))) : candidate.requiredPlacements > 0 && candidate.candidates?.some((date) => extensionDates.includes(date))) ? [index + offset + 1] : []) : [];
|
||
if (extensions.length) {
|
||
for (const next of [index, ...extensions]) {
|
||
yield;
|
||
const ordered = next === index ? requirements : [
|
||
...requirements.slice(0, index),
|
||
requirements[next],
|
||
...requirements.slice(index, next),
|
||
...requirements.slice(next + 1)
|
||
];
|
||
const complete = yield* satisfyObligations(ordered, state, context, index, solutions, solutionLimit, false);
|
||
if (complete && !solutions)
|
||
return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit)
|
||
break;
|
||
}
|
||
return null;
|
||
}
|
||
if (requirement.optional) {
|
||
const complete = yield* satisfyObligations(requirements, state, context, index + 1, solutions, solutionLimit);
|
||
if (complete && !solutions)
|
||
return complete;
|
||
}
|
||
if (requirement.patterns) {
|
||
for (const pattern of requirement.patterns) {
|
||
yield;
|
||
for (const assigned of yield* assignDatesVariants(state, pattern, requirement.rule, context)) {
|
||
yield;
|
||
const candidate = finalizeEarnedState(addEarnedBalance(assigned, requirement.rule, requirement.occurrenceDate, pattern, context), context);
|
||
if (!candidate)
|
||
continue;
|
||
const complete = yield* satisfyObligations(requirements, candidate, context, index + 1, solutions, solutionLimit);
|
||
if (complete && !solutions)
|
||
return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const candidates = requirement.candidates ?? [];
|
||
const needed = requirement.requiredPlacements;
|
||
const choose = function* (position, picked, current) {
|
||
yield;
|
||
context.check();
|
||
if (picked.length === needed) {
|
||
const finalized = finalizeEarnedState(current, context);
|
||
if (!finalized)
|
||
return null;
|
||
const complete = yield* satisfyObligations(requirements, finalized, context, index + 1, solutions, solutionLimit);
|
||
if (complete && !solutions)
|
||
return complete;
|
||
return null;
|
||
}
|
||
if (candidates.length - position < needed - picked.length) {
|
||
return null;
|
||
}
|
||
for (let i = position;i < candidates.length; i++) {
|
||
yield;
|
||
for (const next of assignDateVariants(current, candidates[i], requirement.rule.placeType, requirement.rule.id, context)) {
|
||
yield;
|
||
const complete = yield* choose(i + 1, [...picked, candidates[i]], next);
|
||
if (complete && !solutions)
|
||
return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
return yield* choose(0, [], state);
|
||
}
|
||
function releasePlacement(state, date) {
|
||
const result = {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(state.placements),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates]
|
||
}))
|
||
};
|
||
const placement = result.placements.get(date);
|
||
result.placements.delete(date);
|
||
result.balances.find((balance) => balance.id === placement.balanceId).remaining++;
|
||
return result;
|
||
}
|
||
function reconcileOptimizationProvenance(state, context, before, objectives = activeObjectiveVector(state, context)) {
|
||
const optimizations = objectives.filter(({ id }) => id.startsWith("optimization:"));
|
||
if (!optimizations.length)
|
||
return state;
|
||
let result = state;
|
||
let observations;
|
||
for (const placement of state.placements.values()) {
|
||
const previous = before.placements.get(placement.date);
|
||
const creditedIds = new Set([...placement.ruleIds, ...previous?.ruleIds ?? []]);
|
||
const credits = optimizations.filter(({ id }) => creditedIds.has(id.slice("optimization:".length)));
|
||
if (!credits.length)
|
||
continue;
|
||
const withoutState = releasePlacement(state, placement.date);
|
||
const withoutPlacement = activeObjectiveVector(withoutState, context);
|
||
const ruleIds = new Set([...placement.ruleIds].filter((id) => !credits.some((objective) => objective.id === `optimization:${id}`)));
|
||
for (const objective of credits) {
|
||
const without = withoutPlacement.find(({ id: id2 }) => id2 === objective.id);
|
||
const id = objective.id.slice("optimization:".length);
|
||
const rule = context.rules.find((rule2) => rule2.id === id && rule2.type === "optimization");
|
||
const compared = compareObjectiveValues(objective.direction, objective.value, without.value);
|
||
let contributes = compared < 0;
|
||
if (compared === 0 && rule.configuration.aggregation === "maximum") {
|
||
observations ??= optimizationObservations(state, context);
|
||
const withoutObservations = optimizationObservations(withoutState, context);
|
||
const summed = { ...rule.configuration, aggregation: "sum" };
|
||
const summedComparison = compareObjectiveValues(objective.direction, evaluateOptimization(summed, observations), evaluateOptimization(summed, withoutObservations));
|
||
contributes = summedComparison < 0 || summedComparison === 0 && rule.configuration.elements === "time-off-periods" && objective.direction === "maximize" && observations.timeOffPeriods.length < withoutObservations.timeOffPeriods.length;
|
||
}
|
||
if (contributes)
|
||
ruleIds.add(id);
|
||
}
|
||
if (ruleIds.size === placement.ruleIds.size && [...ruleIds].every((id) => placement.ruleIds.has(id)))
|
||
continue;
|
||
if (result === state)
|
||
result = cloneState(state);
|
||
result.placements.get(placement.date).ruleIds = ruleIds;
|
||
}
|
||
return result;
|
||
}
|
||
function supportingCalendarBenefit(before, after, context) {
|
||
const observations = optimizationObservations(after, context);
|
||
return evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) || evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) || evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) || evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before);
|
||
}
|
||
function mandatoryRelocationDate(date, balance, ruleIds, context) {
|
||
return [...ruleIds].every((id) => {
|
||
const rule = context.rules.find((item) => item.id === id);
|
||
if (rule?.force !== "obligation")
|
||
return true;
|
||
if (!selectorMatches(rule.placeType, balance.type))
|
||
return false;
|
||
return rule.type === "time_window" ? date >= rule.startDate && date <= rule.endDate : occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date)));
|
||
});
|
||
}
|
||
function relocationCanReachFree(state, context, balanceId, ruleIds, visited, failed) {
|
||
const key = JSON.stringify([balanceId, [...ruleIds].sort(), [...visited].sort()]);
|
||
if (failed.has(key))
|
||
return false;
|
||
const queue = [{ balanceId, ruleIds }];
|
||
const seen = new Set;
|
||
for (let index = 0;index < queue.length; index++) {
|
||
const entry = queue[index];
|
||
const identity = JSON.stringify([entry.balanceId, [...entry.ruleIds].sort()]);
|
||
if (seen.has(identity))
|
||
continue;
|
||
seen.add(identity);
|
||
const balance = state.balances.find((item) => item.id === entry.balanceId);
|
||
for (const date of context.workingDays) {
|
||
context.check();
|
||
if (visited.has(date) || context.declaredDates.has(date) || date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, entry.ruleIds, context))
|
||
continue;
|
||
const placement = state.placements.get(date);
|
||
if (!placement)
|
||
return true;
|
||
queue.push({ balanceId: placement.balanceId, ruleIds: placement.ruleIds });
|
||
}
|
||
}
|
||
failed.add(key);
|
||
return false;
|
||
}
|
||
function* relocateForAddition(state, context, date, balanceId, ruleIds, supporting, accept, visited = new Set, failedReachability = new Set, failedSearches = new Set) {
|
||
yield;
|
||
context.check();
|
||
if (visited.has(date) || context.declaredDates.has(date))
|
||
return false;
|
||
const existing = state.placements.get(date);
|
||
if (existing?.balanceId === balanceId && existing.supporting === supporting && existing.ruleIds.size === ruleIds.size && [...existing.ruleIds].every((id) => ruleIds.has(id)))
|
||
return false;
|
||
const key = JSON.stringify([
|
||
date,
|
||
balanceId,
|
||
[...ruleIds].sort(),
|
||
supporting,
|
||
[...visited].sort(),
|
||
state.balances.map((balance) => [balance.id, balance.remaining]),
|
||
[...state.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [
|
||
placement.date,
|
||
placement.balanceId,
|
||
[...placement.ruleIds].sort(),
|
||
placement.supporting
|
||
])
|
||
]);
|
||
if (failedSearches.has(key)) {
|
||
return false;
|
||
}
|
||
const assigned = assignDateToBalance(existing ? releasePlacement(state, date) : state, date, balanceId, context, ruleIds);
|
||
if (!assigned)
|
||
return false;
|
||
assigned.placements.get(date).supporting = supporting;
|
||
if (!existing) {
|
||
const accepted = accept(assigned);
|
||
if (!accepted)
|
||
failedSearches.add(key);
|
||
return accepted;
|
||
}
|
||
const displaced = assigned.balances.find((balance) => balance.id === existing.balanceId);
|
||
const nextVisited = new Set([...visited, date]);
|
||
if (!relocationCanReachFree(assigned, context, existing.balanceId, existing.ruleIds, nextVisited, failedReachability))
|
||
return false;
|
||
for (const destination of context.workingDays) {
|
||
yield;
|
||
if (destination < displaced.availableFrom || destination > displaced.expiryDate)
|
||
continue;
|
||
if (!mandatoryRelocationDate(destination, displaced, existing.ruleIds, context))
|
||
continue;
|
||
if (yield* relocateForAddition(assigned, context, destination, existing.balanceId, existing.ruleIds, existing.supporting, accept, nextVisited, failedReachability, failedSearches))
|
||
return true;
|
||
}
|
||
failedSearches.add(key);
|
||
return false;
|
||
}
|
||
function finalizeRuleCandidate(state, context, before) {
|
||
let result = state;
|
||
const windowCredits = new Map;
|
||
const patternCredits = new Map;
|
||
for (const placement of state.placements.values()) {
|
||
let lostCalendarRequest = false;
|
||
let hasCalendarRequest = false;
|
||
for (const id of placement.ruleIds) {
|
||
const rule = context.rules.find((item) => item.id === id);
|
||
if (rule?.type === "optimization")
|
||
continue;
|
||
const valid = counterfactualRuleValid(placement.date, placement.type, [id], context, state);
|
||
const credited = windowCredits.get(id) ?? 0;
|
||
let withinRequest = true;
|
||
if (rule?.type === "time_window")
|
||
withinRequest = credited < Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length);
|
||
else if (rule?.type === "if_then") {
|
||
if (!patternCredits.has(id))
|
||
patternCredits.set(id, new Set(occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, state, context) ?? [])));
|
||
withinRequest = patternCredits.get(id).has(placement.date);
|
||
}
|
||
if (valid && withinRequest) {
|
||
hasCalendarRequest = true;
|
||
if (rule?.type === "time_window")
|
||
windowCredits.set(id, credited + 1);
|
||
continue;
|
||
}
|
||
if (!valid && rule?.force === "obligation")
|
||
return null;
|
||
if (result === state)
|
||
result = cloneState(state);
|
||
result.placements.get(placement.date).ruleIds.delete(id);
|
||
lostCalendarRequest = true;
|
||
}
|
||
const current = result.placements.get(placement.date);
|
||
const previous = before.placements.get(placement.date);
|
||
let supporting = current.supporting;
|
||
if (hasCalendarRequest)
|
||
supporting = false;
|
||
else if (lostCalendarRequest || previous && (previous.balanceId !== placement.balanceId || previous.supporting !== current.supporting)) {
|
||
const balance = result.balances.find((item) => item.id === placement.balanceId);
|
||
supporting = !isAtRisk(balance, context) && (current.supporting || previous?.balanceId === placement.balanceId && previous.supporting || supportingCalendarBenefit(optimizationObservations(releasePlacement(result, placement.date), context), result, context));
|
||
}
|
||
if (supporting !== current.supporting) {
|
||
if (result === state)
|
||
result = cloneState(state);
|
||
result.placements.get(placement.date).supporting = supporting;
|
||
}
|
||
}
|
||
for (const activation of state.earnedActivations) {
|
||
const rule = context.rules.find((item) => item.type === "if_then" && item.id === activation.ruleId);
|
||
if (activation.dates.every((date) => patternDateSatisfied(date, rule, state, context)))
|
||
continue;
|
||
if ([...state.placements.values()].some((placement) => placement.balanceId === activation.id))
|
||
return null;
|
||
result = cloneState(result);
|
||
result.earnedActivations = result.earnedActivations.filter((item) => item.id !== activation.id);
|
||
result.balances = result.balances.filter((balance) => balance.id !== activation.id);
|
||
}
|
||
const finalized = finalizeEarnedState(result, context);
|
||
return finalized && !unsatisfiedObligationCount(finalized, context) ? finalized : null;
|
||
}
|
||
function typedCalendarDates(context) {
|
||
const typedDates = new Set;
|
||
for (const rule of context.rules) {
|
||
if (rule.type === "optimization" || rule.placeType === "any")
|
||
continue;
|
||
const dates = rule.type === "time_window" ? context.workingDays.filter((date) => rule.startDate <= date && date <= rule.endDate) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat());
|
||
for (const date of dates)
|
||
typedDates.add(date);
|
||
}
|
||
return typedDates;
|
||
}
|
||
function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept) {
|
||
const missing = targetDates.filter((date) => !state.placements.has(date) && !context.declaredDates.has(date));
|
||
if (!missing.length)
|
||
return false;
|
||
const before = optimizationObservations(state, context);
|
||
const typedDates = typedCalendarDates(context);
|
||
const failed = new Set;
|
||
const rankedDonors = [];
|
||
for (const placement of state.placements.values()) {
|
||
yield;
|
||
context.check();
|
||
if (targetDates.includes(placement.date))
|
||
continue;
|
||
rankedDonors.push({
|
||
date: placement.date,
|
||
objectives: activeObjectiveVector(releasePlacement(state, placement.date), context)
|
||
});
|
||
}
|
||
rankedDonors.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date));
|
||
const fill = function* (index, current, additions = []) {
|
||
yield;
|
||
context.check();
|
||
const key = JSON.stringify([
|
||
index,
|
||
current.balances.map((balance) => [balance.id, balance.remaining]),
|
||
[...current.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [
|
||
placement.date,
|
||
targetDates.includes(placement.date) ? typedDates.has(placement.date) ? placement.type : null : placement.type,
|
||
targetDates.includes(placement.date) && !current.balances.find((balance) => balance.id === placement.balanceId).earned ? null : placement.balanceId,
|
||
[...placement.ruleIds].sort(),
|
||
placement.supporting,
|
||
additions.includes(placement.date)
|
||
])
|
||
]);
|
||
if (failed.has(key))
|
||
return false;
|
||
if (index === missing.length) {
|
||
const benefit = supportingCalendarBenefit(before, current, context);
|
||
for (const date2 of additions) {
|
||
const placement = { ...current.placements.get(date2) };
|
||
current.placements.set(date2, placement);
|
||
const balance = current.balances.find((item) => item.id === placement.balanceId);
|
||
placement.supporting = benefit && !isAtRisk(balance, context);
|
||
}
|
||
const accepted = accept(current, additions.length > 0);
|
||
if (!accepted)
|
||
failed.add(key);
|
||
return accepted;
|
||
}
|
||
const date = missing[index];
|
||
for (const balance of current.balances) {
|
||
if (balance.remaining <= 0 || date < balance.availableFrom || date > balance.expiryDate)
|
||
continue;
|
||
const assigned = assignDateToBalance(current, date, balance.id, context, new Set([ruleId]));
|
||
if (!assigned)
|
||
continue;
|
||
if (yield* fill(index + 1, assigned, [...additions, date]))
|
||
return true;
|
||
}
|
||
const donors = new Set;
|
||
for (const donor of rankedDonors) {
|
||
yield;
|
||
const source = current.placements.get(donor.date);
|
||
if (!source)
|
||
continue;
|
||
const balance = current.balances.find((item) => item.id === source.balanceId);
|
||
if (date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, source.ruleIds, context))
|
||
continue;
|
||
const without = releasePlacement(current, source.date);
|
||
const identity = JSON.stringify([
|
||
source.balanceId,
|
||
[...source.ruleIds].sort(),
|
||
source.supporting,
|
||
activeObjectiveVector(without, context)
|
||
]);
|
||
if (donors.has(identity))
|
||
continue;
|
||
donors.add(identity);
|
||
const assigned = assignDateToBalance(without, date, source.balanceId, context, source.ruleIds);
|
||
if (!assigned)
|
||
continue;
|
||
assigned.placements.get(date).supporting = source.supporting;
|
||
if (yield* fill(index + 1, assigned, additions))
|
||
return true;
|
||
}
|
||
failed.add(key);
|
||
return false;
|
||
};
|
||
return yield* fill(0, state);
|
||
}
|
||
function* holidayWeekPlacementCandidates(state, ruleId, context, accept) {
|
||
for (const week of context.holidayWeeks)
|
||
yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept);
|
||
}
|
||
function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) {
|
||
const periods = periodData(state, context);
|
||
for (let index = 1;index < periods.length; index++) {
|
||
const dates = context.workingDays.filter((date) => date > periods[index - 1].end && date < periods[index].start);
|
||
if (dates.length > 1)
|
||
yield* calendarPlacementCandidates(state, dates, ruleId, context, accept);
|
||
}
|
||
}
|
||
function evaluateRuleCandidate(before, candidate, context, throughIndex, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context)) {
|
||
if (!candidate)
|
||
return;
|
||
const state = finalizeRuleCandidate(candidate, context, before);
|
||
if (!state)
|
||
return;
|
||
const active = [];
|
||
let comparison = 0;
|
||
let incumbentComparison = 0;
|
||
for (const objective of activeObjectives(state, context)) {
|
||
const index = active.length;
|
||
active.push(objective);
|
||
if (index <= throughIndex && comparison === 0) {
|
||
comparison = compareObjectiveValues(objective.direction, objective.value, baseline[index].value);
|
||
if (comparison > 0)
|
||
return;
|
||
}
|
||
if (incumbentComparison === 0)
|
||
incumbentComparison = compareObjectiveValues(objective.direction, objective.value, incumbentVector.objectives[index + 1].value);
|
||
if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex))
|
||
return addition && comparison === 0 ? undefined : { state };
|
||
}
|
||
const vector = objectiveVector(state, context, active);
|
||
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
|
||
context.retain?.(state, undefined, vector);
|
||
if (addition && comparison === 0)
|
||
return;
|
||
return { state, vector };
|
||
}
|
||
function* workSequencePlacementCandidates(state, rule, context, consider) {
|
||
const configuration = rule.configuration;
|
||
if (configuration.direction !== "minimize")
|
||
return;
|
||
const sequences = workSequenceDates(state, context).filter(({ dates, internal }) => dates.length > 0 && (!configuration.conditions.includes("internal") || internal));
|
||
const bundles = [];
|
||
if (configuration.aggregation === "count")
|
||
bundles.push(...sequences.map(({ dates }) => dates.map((date) => [date])));
|
||
else if (configuration.measure === "work-sequence-deficit") {
|
||
const short = sequences.filter(({ dates }) => dates.length < configuration.thresholdDays);
|
||
if (configuration.aggregation === "maximum") {
|
||
const shortest = Math.min(...short.map(({ dates }) => dates.length));
|
||
bundles.push(short.filter(({ dates }) => dates.length === shortest).flatMap(({ dates }) => dates.map((date) => [date])));
|
||
} else
|
||
bundles.push(...short.map(({ dates }) => dates.map((date) => [date])));
|
||
} else if (configuration.measure === "work-sequence-length" && configuration.aggregation === "maximum") {
|
||
const maximum = Math.max(0, ...sequences.map(({ dates }) => dates.length));
|
||
const tied = sequences.filter(({ dates }) => dates.length === maximum);
|
||
if (tied.length > 1) {
|
||
for (const offset of [0, 1]) {
|
||
const bundle = [];
|
||
for (const { dates } of tied) {
|
||
const middle = Math.floor((dates.length - 1 + offset) / 2);
|
||
const centered = dates.map((date, index) => ({ date, index })).sort((a, b) => Math.abs(a.index - middle) - Math.abs(b.index - middle) || compareDate(a.date, b.date));
|
||
const eligible = [
|
||
...new Set(state.balances.filter((balance) => balance.remaining > 0).map((balance) => centered.find(({ date }) => balance.availableFrom <= date && date <= balance.expiryDate)?.date).filter((date) => date !== undefined))
|
||
];
|
||
if (!eligible.length)
|
||
break;
|
||
bundle.push(eligible.sort((a, b) => centered.findIndex(({ date }) => date === a) - centered.findIndex(({ date }) => date === b)));
|
||
}
|
||
if (bundle.length === tied.length)
|
||
bundles.push(bundle);
|
||
}
|
||
}
|
||
}
|
||
const before = optimizationObservations(state, context);
|
||
const available = state.balances.reduce((total, balance) => total + balance.remaining, 0);
|
||
const typedDates = typedCalendarDates(context);
|
||
const failedAssignments = new Set;
|
||
function* assignBundle(candidate, slots, dates = []) {
|
||
yield;
|
||
context.check();
|
||
const key = JSON.stringify([
|
||
slots,
|
||
dates,
|
||
candidate.balances.map(({ id, remaining }) => [id, remaining]),
|
||
dates.map((date) => {
|
||
const placement = candidate.placements.get(date);
|
||
const balance = candidate.balances.find((item) => item.id === placement.balanceId);
|
||
return [
|
||
typedDates.has(date) ? placement.type : undefined,
|
||
balance.earned ? balance.id : undefined
|
||
];
|
||
})
|
||
]);
|
||
if (failedAssignments.has(key))
|
||
return false;
|
||
if (dates.length === slots.length) {
|
||
const supporting = supportingCalendarBenefit(before, candidate, context);
|
||
for (const date of dates) {
|
||
const placement = candidate.placements.get(date);
|
||
const balance = candidate.balances.find((item) => item.id === placement.balanceId);
|
||
placement.supporting = supporting && !isAtRisk(balance, context);
|
||
}
|
||
const accepted = consider(candidate);
|
||
if (!accepted)
|
||
failedAssignments.add(key);
|
||
return accepted;
|
||
}
|
||
for (const date of slots[dates.length]) {
|
||
for (const next of assignDateVariants(candidate, date, "any", rule.id, context))
|
||
if (yield* assignBundle(next, slots, [...dates, date]))
|
||
return true;
|
||
}
|
||
failedAssignments.add(key);
|
||
return false;
|
||
}
|
||
const examined = new Set;
|
||
for (const slots of bundles) {
|
||
yield;
|
||
const key = JSON.stringify(slots);
|
||
if (slots.length <= 1 || slots.length > available || examined.has(key))
|
||
continue;
|
||
examined.add(key);
|
||
yield* assignBundle(state, slots);
|
||
}
|
||
}
|
||
function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) {
|
||
let result = state;
|
||
const addingBalanceQuantity = allowAdd && rule?.type === "optimization" && rule.configuration.elements === "leave-balances" && rule.configuration.measure === "unplaced-quantity" && rule.configuration.aggregation === "sum" && rule.configuration.direction === "minimize";
|
||
const equivalentAdditionFunding = addingBalanceQuantity && throughIndex === 0 && !context.rules.some((item) => item.enabled && item.type === "if_then") && context.rules.every((item) => !item.enabled || item.type !== "optimization" || item.configuration.elements !== "leave-balances" || item.configuration.aggregation !== "maximum");
|
||
const fundingWindows = context.rules.filter((item) => item.enabled && item.type === "time_window");
|
||
const preferencePrefix = context.rules.filter((item) => item.enabled && item.force === "preference").slice(0, throughIndex + 1);
|
||
const prefixBounds = preferencePrefix.map((item, index) => {
|
||
if (index === 0)
|
||
return item.type === "optimization" && item.configuration.elements === "leave-balances" && item.configuration.measure === "unplaced-quantity" && item.configuration.aggregation === "sum" && item.configuration.direction === "minimize" ? 0 : undefined;
|
||
if (item.type === "time_window")
|
||
return item.placeDays;
|
||
if (item.type === "optimization" && item.configuration.elements === "public-holiday-weeks" && item.configuration.measure === "public-holiday-week-value" && item.configuration.direction === "maximize")
|
||
return evaluateOptimization(item.configuration, {
|
||
leaveBalances: [],
|
||
publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({
|
||
value,
|
||
complete: true
|
||
})),
|
||
timeOffPeriods: [],
|
||
workSequences: [],
|
||
leavePlacements: []
|
||
});
|
||
return;
|
||
});
|
||
const dateInvariantObjectives = context.rules.filter((item) => item.force === "preference").every((item) => item.type === "optimization" && (item.configuration.elements === "leave-balances" || item.configuration.elements === "leave-placements"));
|
||
while (true) {
|
||
yield;
|
||
context.check();
|
||
const active = activeObjectiveVector(result, context);
|
||
const baseline = active.slice(0, throughIndex + 1);
|
||
const beforeVector = objectiveVector(result, context, active);
|
||
context.retain?.(result, undefined, beforeVector);
|
||
if (allowAdd && prefixBounds.length > 0 && prefixBounds.every((bound, index) => bound !== undefined && active[index].value === bound)) {
|
||
return result;
|
||
}
|
||
const genericStateKey = allowAdd && result.balances.every(({ remaining }) => remaining === 0) && !context.rules.some((item) => item.enabled && item.type === "if_then") ? preferenceStateKey(result) : undefined;
|
||
const reuseGeneric = genericStateKey !== undefined && context.completedGenericState === genericStateKey;
|
||
const compareEquivalentFunding = equivalentAdditionFunding && result.earnedActivations.length === 0 && Number.isSafeInteger(result.balances.reduce((sum, balance) => sum + balance.remaining, 0));
|
||
let beforeCalendar;
|
||
let best = result;
|
||
let bestVector = beforeVector;
|
||
const consider = (candidate, addition = false) => {
|
||
const evaluated = evaluateRuleCandidate(result, candidate, context, throughIndex, addition, baseline, bestVector);
|
||
if (!evaluated)
|
||
return false;
|
||
if (!evaluated.vector)
|
||
return true;
|
||
const compared = compareObjectiveLists(evaluated.vector.objectives, bestVector.objectives);
|
||
if (compared >= 0)
|
||
return true;
|
||
best = evaluated.state;
|
||
bestVector = evaluated.vector;
|
||
return true;
|
||
};
|
||
const dates = [...context.workingDays].sort((a, b) => Number(result.placements.has(a)) - Number(result.placements.has(b)));
|
||
const moveDates = result.placements.size <= 24 ? context.workingDays : context.workingDays.filter((date, index) => !isWorking(addDays(date, -1), context.holidays) || !isWorking(addDays(date, 1), context.holidays) || context.declaredDates.has(addDays(date, -1)) || context.declaredDates.has(addDays(date, 1)) || index % 7 === 0 || context.rules.some((item) => item.type === "time_window" && date >= item.startDate && date <= item.endDate));
|
||
if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "work-sequences")
|
||
yield* workSequencePlacementCandidates(result, rule, context, (candidate) => consider(candidate, true));
|
||
if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "public-holiday-weeks" && rule.configuration.conditions.includes("complete") && rule.configuration.direction === "maximize")
|
||
yield* holidayWeekPlacementCandidates(result, rule.id, context, consider);
|
||
if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "time-off-periods" && (rule.configuration.aggregation === "maximum" && rule.configuration.direction === "maximize" || rule.configuration.aggregation === "count" && rule.configuration.direction === "minimize"))
|
||
yield* timeOffPeriodPlacementCandidates(result, rule.id, context, consider);
|
||
const deferGeneric = allowAdd && best !== result && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) < 0;
|
||
for (const date of reuseGeneric || deferGeneric ? [] : dates) {
|
||
yield;
|
||
context.check();
|
||
if (context.declaredDates.has(date))
|
||
continue;
|
||
const existing = result.placements.get(date);
|
||
if (!existing && allowAdd) {
|
||
if (rule?.type === "time_window" && (date < rule.startDate || date > rule.endDate))
|
||
continue;
|
||
const evaluatedFunding = new Set;
|
||
for (const balance of result.balances) {
|
||
yield;
|
||
if (rule?.type === "time_window" && !selectorMatches(rule.placeType, balance.type))
|
||
continue;
|
||
if (balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date)
|
||
continue;
|
||
const fundingKey = compareEquivalentFunding && isAtRisk(balance, context) ? `${balance.expiryDate}:${fundingWindows.map((window) => Number(date >= window.startDate && date <= window.endDate && selectorMatches(window.placeType, balance.type))).join("")}` : undefined;
|
||
if (fundingKey !== undefined && evaluatedFunding.has(fundingKey)) {
|
||
continue;
|
||
}
|
||
const assigned = assignDateToBalance(result, date, balance.id, context, rule ? new Set([rule.id]) : new Set);
|
||
if (assigned && rule?.type === "optimization" && !isAtRisk(balance, context)) {
|
||
beforeCalendar ??= optimizationObservations(result, context);
|
||
assigned.placements.get(date).supporting = supportingCalendarBenefit(beforeCalendar, assigned, context);
|
||
}
|
||
const evaluated = consider(assigned, true);
|
||
if (evaluated && fundingKey !== undefined)
|
||
evaluatedFunding.add(fundingKey);
|
||
}
|
||
}
|
||
if (!existing || addingBalanceQuantity && best !== result)
|
||
continue;
|
||
const without = releasePlacement(result, date);
|
||
consider(without);
|
||
for (const balance of without.balances) {
|
||
yield;
|
||
if (balance.id === existing.balanceId || balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date)
|
||
continue;
|
||
const reassigned = assignDateToBalance(without, date, balance.id, context, existing.ruleIds);
|
||
if (reassigned)
|
||
reassigned.placements.get(date).supporting = existing.supporting && !isAtRisk(balance, context);
|
||
consider(reassigned);
|
||
}
|
||
for (const destination of moveDates) {
|
||
yield;
|
||
context.check();
|
||
if (destination === date || context.declaredDates.has(destination))
|
||
continue;
|
||
const other = without.placements.get(destination);
|
||
if (other) {
|
||
if (other.balanceId === existing.balanceId)
|
||
continue;
|
||
const swapped = releasePlacement(without, destination);
|
||
const first = assignDateToBalance(swapped, destination, existing.balanceId, context, existing.ruleIds);
|
||
const second = first && assignDateToBalance(first, date, other.balanceId, context, other.ruleIds);
|
||
if (second) {
|
||
second.placements.get(destination).supporting = existing.supporting;
|
||
second.placements.get(date).supporting = other.supporting;
|
||
}
|
||
consider(second);
|
||
} else {
|
||
const moved = assignDateToBalance(without, destination, existing.balanceId, context, existing.ruleIds);
|
||
if (moved)
|
||
moved.placements.get(destination).supporting = existing.supporting;
|
||
if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved), beforeVector.objectives.slice(-2)) >= 0) {
|
||
continue;
|
||
}
|
||
consider(moved);
|
||
}
|
||
}
|
||
}
|
||
if (allowAdd && (addingBalanceQuantity || rule?.type === "time_window" && Number(active[throughIndex].value) < rule.placeDays) && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) === 0) {
|
||
for (const balance of result.balances) {
|
||
if (balance.remaining <= 0)
|
||
continue;
|
||
for (const date of result.placements.keys()) {
|
||
yield;
|
||
if (date < balance.availableFrom || date > balance.expiryDate)
|
||
continue;
|
||
const sourceRuleApplies = rule?.type !== "time_window" || date >= rule.startDate && date <= rule.endDate && selectorMatches(rule.placeType, balance.type);
|
||
yield* relocateForAddition(result, context, date, balance.id, rule && sourceRuleApplies ? new Set([rule.id]) : new Set, false, (candidate) => {
|
||
if (rule?.type === "optimization" && !isAtRisk(balance, context)) {
|
||
beforeCalendar ??= optimizationObservations(result, context);
|
||
candidate.placements.set(date, {
|
||
...candidate.placements.get(date),
|
||
supporting: supportingCalendarBenefit(beforeCalendar, candidate, context)
|
||
});
|
||
}
|
||
return consider(candidate, true);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (best === result) {
|
||
if (genericStateKey !== undefined)
|
||
context.completedGenericState = genericStateKey;
|
||
return result;
|
||
}
|
||
best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives);
|
||
context.retain?.(best);
|
||
result = best;
|
||
}
|
||
}
|
||
function associateCalendarPreference(state, rule, context) {
|
||
const result = cloneState(state);
|
||
const dates = rule.type === "time_window" ? [...result.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).sort((a, b) => Number(b.ruleIds.has(rule.id)) - Number(a.ruleIds.has(rule.id))).slice(0, Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length)).map((placement) => placement.date) : occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, result, context) ?? []);
|
||
for (const date of dates) {
|
||
const placement = result.placements.get(date);
|
||
if (!placement)
|
||
continue;
|
||
placement.ruleIds.add(rule.id);
|
||
placement.supporting = false;
|
||
}
|
||
return result;
|
||
}
|
||
function* preferencePatternVariants(state, pattern, rule, context) {
|
||
let variants = [state];
|
||
for (const date of pattern) {
|
||
yield;
|
||
const next = [];
|
||
for (const current of variants) {
|
||
yield;
|
||
const existing = current.placements.get(date);
|
||
if (!existing || selectorMatches(rule.placeType, existing.type)) {
|
||
if (declaredPatternDay(date, rule.placeType, context))
|
||
next.push(current);
|
||
else
|
||
next.push(...assignDateVariants(current, date, rule.placeType, rule.id, context));
|
||
continue;
|
||
}
|
||
const without = releasePlacement(current, date);
|
||
for (const balance of without.balances) {
|
||
yield;
|
||
if (!selectorMatches(rule.placeType, balance.type))
|
||
continue;
|
||
const reassigned = assignDateToBalance(without, date, balance.id, context, new Set([rule.id]));
|
||
if (reassigned)
|
||
next.push(reassigned);
|
||
}
|
||
for (const other of without.placements.values()) {
|
||
yield;
|
||
if (pattern.includes(other.date) || !selectorMatches(rule.placeType, other.type))
|
||
continue;
|
||
const released = releasePlacement(without, other.date);
|
||
const first = assignDateToBalance(released, date, other.balanceId, context, new Set([...other.ruleIds, rule.id]));
|
||
const swapped = first && assignDateToBalance(first, other.date, existing.balanceId, context, existing.ruleIds);
|
||
if (!swapped)
|
||
continue;
|
||
swapped.placements.get(date).supporting = other.supporting;
|
||
swapped.placements.get(other.date).supporting = existing.supporting;
|
||
next.push(swapped);
|
||
}
|
||
}
|
||
variants = next;
|
||
if (!variants.length)
|
||
break;
|
||
}
|
||
return variants;
|
||
}
|
||
function* preferenceIfThen(state, rule, context, preferenceIndex) {
|
||
const matchingOccurrences = occurrences(rule, context);
|
||
let bestState = state;
|
||
let bestCount = 0;
|
||
const baseline = activeObjectiveVector(state, context, preferenceIndex);
|
||
const search = function* (index, current, count) {
|
||
yield;
|
||
context.check();
|
||
const maximumCount = count + matchingOccurrences.length - index;
|
||
if (maximumCount < bestCount && baseline.slice(0, preferenceIndex).every((objective) => {
|
||
if (objective.direction === "minimize")
|
||
return objective.value === 0;
|
||
const earlier = context.rules.find((item) => `preference:${item.id}` === objective.id);
|
||
return earlier && objective.value === (earlier.type === "time_window" ? earlier.placeDays : occurrences(earlier, context).length);
|
||
})) {
|
||
return;
|
||
}
|
||
if (index >= matchingOccurrences.length) {
|
||
const finalized = finalizeRuleCandidate(current, context, state);
|
||
if (!finalized) {
|
||
return;
|
||
}
|
||
if (unsatisfiedObligationCount(finalized, context) || compareObjectiveLists(activeObjectiveVector(finalized, context, preferenceIndex), baseline) > 0)
|
||
return;
|
||
context.retain?.(finalized);
|
||
const better = compareObligationPreviews({ state: finalized }, { state: bestState }, context) < 0;
|
||
if (better) {
|
||
bestCount = count;
|
||
bestState = finalized;
|
||
}
|
||
return;
|
||
}
|
||
const occurrence = matchingOccurrences[index];
|
||
const patterns = [...patternsFor(rule, occurrence, context)].sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b));
|
||
for (const pattern of patterns) {
|
||
yield;
|
||
const variants = yield* preferencePatternVariants(current, pattern, rule, context);
|
||
for (const variant of variants) {
|
||
yield;
|
||
const finalized = finalizeEarnedState(addEarnedBalance(variant, rule, occurrence, pattern, context), context);
|
||
if (!finalized)
|
||
continue;
|
||
yield* search(index + 1, finalized, count + 1);
|
||
}
|
||
}
|
||
yield* search(index + 1, current, count);
|
||
};
|
||
yield* search(0, state, 0);
|
||
context.retain?.(bestState);
|
||
return { state: bestState, count: bestCount };
|
||
}
|
||
function preferenceDateConflictScore(selector, date, context) {
|
||
const protectedTypes = context.protectedTypesByDate.get(date);
|
||
const demand = context.preferenceDemandByDate.get(date) ?? 0;
|
||
const anyDemand = context.anyPreferenceDemandByDate.get(date) ?? 0;
|
||
if (!protectedTypes?.size && !demand)
|
||
return 0;
|
||
if (selector === "any")
|
||
return -(demand * 2 + (protectedTypes?.size ?? 0));
|
||
if (protectedTypes?.has(selector))
|
||
return -(demand * 2 + 1);
|
||
if (protectedTypes?.size)
|
||
return anyDemand ? -anyDemand : protectedTypes.size;
|
||
return demand ? -demand : 0;
|
||
}
|
||
function patternConflictScore(selector, pattern, context) {
|
||
return pattern.reduce((score, date) => score + preferenceDateConflictScore(selector, date, context), 0);
|
||
}
|
||
function preferenceReason(rule, count, context, state) {
|
||
const requested = rule.type === "if_then" ? occurrences(rule, context).length : rule.placeDays;
|
||
if (count >= requested)
|
||
return;
|
||
const candidateDates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat());
|
||
if (!candidateDates.length)
|
||
return reason("horizon", `Preference "${rule.name}" has no placeable date in the horizon.`, [rule.id]);
|
||
const matchingBalances = state.balances.filter((balance) => selectorMatches(rule.placeType, balance.type));
|
||
const availableBalances = matchingBalances.filter((balance) => candidateDates.some((date) => compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0));
|
||
const currentRuleIndex = context.rules.findIndex((item) => item.id === rule.id);
|
||
const consumedByHigherPriority = matchingBalances.some((balance) => balance.remaining === 0 && [...state.placements.values()].some((placement) => placement.balanceId === balance.id && [...placement.ruleIds].some((id) => {
|
||
const index = context.rules.findIndex((item) => item.id === id);
|
||
return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex);
|
||
})));
|
||
const freeEligibleDate = candidateDates.some((date) => !state.placements.has(date) && state.balances.some((balance) => balance.remaining > 0 && selectorMatches(rule.placeType, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0));
|
||
const compatibleExisting = candidateDates.some((date) => {
|
||
const placement = state.placements.get(date);
|
||
return placement && selectorMatches(rule.placeType, placement.type);
|
||
});
|
||
const blockedByHigherPriority = candidateDates.some((date) => {
|
||
const placement = state.placements.get(date);
|
||
return placement && !selectorMatches(rule.placeType, placement.type) && [...placement.ruleIds].some((id) => {
|
||
const index = context.rules.findIndex((item) => item.id === id);
|
||
return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex);
|
||
});
|
||
});
|
||
let code;
|
||
if (blockedByHigherPriority && !freeEligibleDate && !compatibleExisting)
|
||
code = "higher-priority";
|
||
else if (!matchingBalances.length && !compatibleExisting)
|
||
code = "type";
|
||
else if (!availableBalances.length)
|
||
code = "availability";
|
||
else if (consumedByHigherPriority && !freeEligibleDate && !compatibleExisting)
|
||
code = "higher-priority";
|
||
else
|
||
code = "capacity";
|
||
return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]);
|
||
}
|
||
function* applyPreferenceRules(state, preferenceRules, context) {
|
||
let result = state;
|
||
const preferences = [];
|
||
for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
||
yield;
|
||
result = yield* applyPreferenceStep(result, preferenceRules, preferenceIndex, preferences, context);
|
||
}
|
||
return { state: result, preferences };
|
||
}
|
||
function* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context) {
|
||
let result = state;
|
||
const apply = function* (index) {
|
||
const rule = preferenceRules[index];
|
||
context.protectedTypesByDate = futurePreferenceProtection(preferenceRules, index + 1, context);
|
||
if (context.completedPreferenceStates.get(index) === preferenceStateKey(result)) {
|
||
return;
|
||
}
|
||
if (rule.type === "optimization")
|
||
result = yield* optimizeRulePlacements(result, context, index, true, rule);
|
||
else if (rule.type === "time_window")
|
||
result = associateCalendarPreference(yield* optimizeRulePlacements(result, context, index, true, rule), rule, context);
|
||
else
|
||
result = associateCalendarPreference((yield* preferenceIfThen(result, rule, context, index)).state, rule, context);
|
||
context.completedPreferenceStates.set(index, preferenceStateKey(result));
|
||
};
|
||
yield* apply(preferenceIndex);
|
||
let previous;
|
||
do {
|
||
previous = activeObjectiveVector(result, context);
|
||
const entitlements = result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|");
|
||
for (let index = 0;index < preferenceIndex; index++) {
|
||
yield;
|
||
yield* apply(index);
|
||
}
|
||
if (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0 || result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|") !== entitlements)
|
||
yield* apply(preferenceIndex);
|
||
} while (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0);
|
||
preferences.splice(0, preferences.length, ...preferenceResultsForState(result, context).map((preference) => ({
|
||
...preference,
|
||
reason: preferenceReason(context.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, result)
|
||
})));
|
||
return result;
|
||
}
|
||
function preferenceStateKey(state) {
|
||
return JSON.stringify([
|
||
state.balances,
|
||
[...state.placements].map(([date, placement]) => [
|
||
date,
|
||
{ ...placement, ruleIds: [...placement.ruleIds] }
|
||
]),
|
||
state.earnedActivations
|
||
]);
|
||
}
|
||
function placementTieKey(state) {
|
||
return [...state.placements.keys()].sort().join("|");
|
||
}
|
||
function expiryTieKey(state) {
|
||
return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|");
|
||
}
|
||
function activeObjectiveVector(state, context, throughIndex = Number.POSITIVE_INFINITY) {
|
||
return [...activeObjectives(state, context, throughIndex)];
|
||
}
|
||
function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY) {
|
||
let preferences;
|
||
let balances;
|
||
let calendar;
|
||
let index = 0;
|
||
for (const rule of context.rules) {
|
||
if (!rule.enabled || rule.force !== "preference")
|
||
continue;
|
||
if (index++ > throughIndex)
|
||
return;
|
||
if (rule.type === "optimization") {
|
||
const observations = rule.configuration.elements === "leave-balances" ? balances ??= {
|
||
leaveBalances: optimizationBalanceObservations(state, context),
|
||
publicHolidayWeeks: [],
|
||
timeOffPeriods: [],
|
||
workSequences: [],
|
||
leavePlacements: []
|
||
} : calendar ??= optimizationObservations(state, context);
|
||
yield {
|
||
id: `optimization:${rule.id}`,
|
||
direction: rule.configuration.direction,
|
||
value: evaluateStateOptimization(rule.configuration, state, context, observations),
|
||
label: rule.name
|
||
};
|
||
} else {
|
||
preferences ??= preferenceResultsForState(state, context);
|
||
yield {
|
||
id: `preference:${rule.id}`,
|
||
direction: "maximize",
|
||
value: preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
|
||
label: rule.name
|
||
};
|
||
}
|
||
}
|
||
}
|
||
function tieBreakObjectives(state) {
|
||
return [
|
||
{
|
||
id: "balance-expiry",
|
||
direction: "minimize",
|
||
value: expiryTieKey(state),
|
||
label: "Balance expiry tie-break"
|
||
},
|
||
{
|
||
id: "placement-date",
|
||
direction: "minimize",
|
||
value: placementTieKey(state),
|
||
label: "Placement date tie-break"
|
||
}
|
||
];
|
||
}
|
||
function obligationPreviewObjectives(preview, context) {
|
||
return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
|
||
}
|
||
function compareObligationPreviews(a, b, context) {
|
||
return compareObjectiveLists(obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context));
|
||
}
|
||
function isAtRisk(balance, context) {
|
||
return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0;
|
||
}
|
||
function isNaturalNonWorkingDate(date, context) {
|
||
return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date);
|
||
}
|
||
function calendarDateAt(index, context) {
|
||
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
|
||
}
|
||
function periodBounds(state, context) {
|
||
const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate);
|
||
if (!dates.length)
|
||
return [];
|
||
const groups = [];
|
||
let group = [dates[0]];
|
||
for (let i = 1;i < dates.length; i++) {
|
||
context.check();
|
||
const previousIndex = context.calendarIndex.get(dates[i - 1]);
|
||
const currentIndex = context.calendarIndex.get(dates[i]);
|
||
const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
|
||
if (workingInGap === 0)
|
||
group.push(dates[i]);
|
||
else {
|
||
groups.push(group);
|
||
group = [dates[i]];
|
||
}
|
||
}
|
||
groups.push(group);
|
||
return groups.map((anchors) => {
|
||
context.check();
|
||
let startIndex = context.calendarIndex.get(anchors[0]);
|
||
let endIndex = context.calendarIndex.get(anchors[anchors.length - 1]);
|
||
while (context.declaredDates.has(calendarDateAt(startIndex - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)) {
|
||
context.check();
|
||
startIndex--;
|
||
}
|
||
while (context.declaredDates.has(calendarDateAt(endIndex + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)) {
|
||
context.check();
|
||
endIndex++;
|
||
}
|
||
return { anchors, startIndex, endIndex };
|
||
});
|
||
}
|
||
function calendarPeriod(startIndex, endIndex, context) {
|
||
let periods = calendarPeriodCache.get(context);
|
||
if (!periods) {
|
||
periods = new Map;
|
||
calendarPeriodCache.set(context, periods);
|
||
}
|
||
const key = `${startIndex}:${endIndex}`;
|
||
let period = periods.get(key);
|
||
if (period)
|
||
periods.delete(key);
|
||
else {
|
||
const start = calendarDateAt(startIndex, context);
|
||
const end = calendarDateAt(endIndex, context);
|
||
const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1));
|
||
period = Object.freeze({
|
||
start,
|
||
end,
|
||
duration: dates.length,
|
||
nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))),
|
||
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date)))
|
||
});
|
||
}
|
||
periods.set(key, period);
|
||
if (periods.size > 4096)
|
||
periods.delete(periods.keys().next().value);
|
||
return period;
|
||
}
|
||
function periodData(state, context) {
|
||
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
|
||
const period = calendarPeriod(startIndex, endIndex, context);
|
||
return {
|
||
start: period.start,
|
||
end: period.end,
|
||
duration: period.duration,
|
||
placementDates: anchors.filter((date) => state.placements.has(date)),
|
||
...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {},
|
||
nonWorkingDates: [...period.nonWorkingDates],
|
||
nonWorkingDayGain: period.nonWorkingDates.length
|
||
};
|
||
});
|
||
}
|
||
function optimizationBalanceObservations(state, context) {
|
||
return state.balances.map((balance) => ({
|
||
unplacedQuantity: balance.remaining,
|
||
atRisk: isAtRisk(balance, context)
|
||
}));
|
||
}
|
||
function optimizationObservations(state, context, periods) {
|
||
const key = placementTieKey(state);
|
||
let byDates = calendarObservationCache.get(context);
|
||
if (!byDates) {
|
||
byDates = new Map;
|
||
calendarObservationCache.set(context, byDates);
|
||
}
|
||
const calendar = byDates.get(key) ?? {};
|
||
if (byDates.has(key))
|
||
byDates.delete(key);
|
||
else if (byDates.size >= 4096)
|
||
byDates.delete(byDates.keys().next().value);
|
||
byDates.set(key, calendar);
|
||
let bounds;
|
||
return {
|
||
leaveBalances: optimizationBalanceObservations(state, context),
|
||
get publicHolidayWeeks() {
|
||
return calendar.publicHolidayWeeks ??= Object.freeze(context.holidayWeeks.map((week) => Object.freeze({
|
||
value: week.value,
|
||
complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date))
|
||
})));
|
||
},
|
||
get timeOffPeriods() {
|
||
return calendar.timeOffPeriods ??= Object.freeze(periods ? periods.map((period) => Object.freeze({
|
||
nonWorkingDates: Object.freeze([...period.nonWorkingDates])
|
||
})) : (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) => Object.freeze({
|
||
nonWorkingDates: calendarPeriod(startIndex, endIndex, context).nonWorkingDates
|
||
})));
|
||
},
|
||
get workSequences() {
|
||
return calendar.workSequences ??= Object.freeze(workSequenceLengths(bounds ??= periodBounds(state, context), context).map((sequence) => Object.freeze(sequence)));
|
||
},
|
||
leavePlacements: [...state.placements.values()]
|
||
};
|
||
}
|
||
function workSequenceLengths(bounds, context) {
|
||
if (!context.workingDays.length)
|
||
return [];
|
||
let nextIndex = context.calendarIndex.get(context.horizon.start);
|
||
const sequences = bounds.map(({ anchors }, index) => {
|
||
const previousIndex = nextIndex;
|
||
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1;
|
||
return {
|
||
length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex],
|
||
internal: index > 0
|
||
};
|
||
});
|
||
sequences.push({
|
||
length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex],
|
||
internal: false
|
||
});
|
||
return sequences;
|
||
}
|
||
function workSequenceDates(state, context) {
|
||
const sequences = [];
|
||
let current = [];
|
||
let inTimeOffPeriod = false;
|
||
for (const date of context.workingDays) {
|
||
context.check();
|
||
if (state.placements.has(date) || context.declaredDates.has(date)) {
|
||
if (!inTimeOffPeriod)
|
||
sequences.push(current);
|
||
current = [];
|
||
inTimeOffPeriod = true;
|
||
} else {
|
||
current.push(date);
|
||
inTimeOffPeriod = false;
|
||
}
|
||
}
|
||
if (context.workingDays.length)
|
||
sequences.push(current);
|
||
return sequences.map((dates, index) => ({
|
||
dates,
|
||
internal: index > 0 && index < sequences.length - 1
|
||
}));
|
||
}
|
||
function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) {
|
||
return evaluateOptimization(configuration, observations);
|
||
}
|
||
function qualityMetrics(state, context, preferences) {
|
||
const periods = periodData(state, context);
|
||
const observations = optimizationObservations(state, context, periods);
|
||
const nonEmptySequences = observations.workSequences.filter((sequence) => sequence.length > 0);
|
||
return {
|
||
unplacedAtRisk: evaluateStateOptimization(OPTIMIZATION_PRESETS.unplacedAtRisk, state, context, observations),
|
||
holidayWeekValue: evaluateStateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, state, context, observations),
|
||
nonWorkingDayGain: evaluateStateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, state, context, observations),
|
||
longestWorkSequence: evaluateStateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, state, context, observations),
|
||
averageWorkSequence: nonEmptySequences.length ? nonEmptySequences.reduce((sum, sequence) => sum + sequence.length, 0) / nonEmptySequences.length : null,
|
||
averageTimeOffPeriod: periods.length ? periods.reduce((sum, period) => sum + period.duration, 0) / periods.length : null,
|
||
longestTimeOffPeriod: periods.length ? Math.max(...periods.map((period) => period.duration)) : null,
|
||
shortSequenceDeficit: evaluateStateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, state, context, observations),
|
||
supportingPlacements: evaluateStateOptimization(OPTIMIZATION_PRESETS.supportingPlacements, state, context, observations),
|
||
preferenceSatisfaction: preferences.map((preference) => preference.satisfied)
|
||
};
|
||
}
|
||
function nonPlacementReason(balance, state, context, completed) {
|
||
if (balance.expiryDate < context.horizon.start)
|
||
return reason("unavoidable-expiry", "This balance expired before the planning horizon.");
|
||
if (balance.availableFrom >= context.horizon.end)
|
||
return reason("available-after-horizon", "This balance becomes available after the last planning date.");
|
||
const dates = context.workingDays.filter((date) => date >= balance.availableFrom && date <= balance.expiryDate);
|
||
if (!dates.length)
|
||
return reason("no-working-date", "There is no working date within both the horizon and this balance’s validity period.");
|
||
if (dates.every((date) => context.declaredDates.has(date) || state.placements.has(date)))
|
||
return reason("all-dates-occupied", "Every working date within this balance’s validity period is already occupied in the displayed plan.");
|
||
if (!completed)
|
||
return reason("calculation-incomplete", "The calculation stopped before completing its planning priorities.");
|
||
if (balance.expiryDate > context.horizon.end)
|
||
return reason("preserved", "This balance expires after the horizon; the planner retained no additional placement under its planning priorities.");
|
||
return reason("active-priorities", "The calculation retained no additional placement under the active planning rules.");
|
||
}
|
||
function remainingBalances(state, context, completed) {
|
||
return state.balances.filter((balance) => balance.remaining > 0).map((balance) => {
|
||
const classification = compareDate(balance.expiryDate, context.horizon.start) < 0 ? "unavoidable-expiry" : compareDate(balance.expiryDate, context.horizon.end) <= 0 ? "at-risk" : "deferred";
|
||
return {
|
||
id: balance.id,
|
||
type: balance.type,
|
||
quantity: balance.remaining,
|
||
availableFrom: balance.availableFrom,
|
||
expiryDate: balance.expiryDate,
|
||
classification,
|
||
reason: nonPlacementReason(balance, state, context, completed),
|
||
earned: balance.earned,
|
||
sourceRuleId: balance.sourceRuleId
|
||
};
|
||
});
|
||
}
|
||
function unsatisfiedObligationCount(state, context) {
|
||
const placementCounts = new Map;
|
||
for (const placement of state.placements.values())
|
||
for (const ruleId of placement.ruleIds)
|
||
placementCounts.set(ruleId, (placementCounts.get(ruleId) ?? 0) + 1);
|
||
let unsatisfied = 0;
|
||
for (const requirement of buildRequirements(context.rules, context, "obligation")) {
|
||
if (!requirement.patterns) {
|
||
if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.requiredPlacements)
|
||
unsatisfied++;
|
||
continue;
|
||
}
|
||
let complete = false;
|
||
for (const pattern of requirement.patterns) {
|
||
if (pattern.every((date) => patternDateSatisfied(date, requirement.rule, state, context))) {
|
||
complete = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!complete)
|
||
unsatisfied++;
|
||
}
|
||
return unsatisfied;
|
||
}
|
||
function objectiveVector(state, context, active = activeObjectiveVector(state, context)) {
|
||
return {
|
||
objectives: [
|
||
{
|
||
id: "obligation-validity",
|
||
direction: "minimize",
|
||
value: unsatisfiedObligationCount(state, context),
|
||
label: "Unsatisfied obligations"
|
||
},
|
||
...active,
|
||
...tieBreakObjectives(state)
|
||
]
|
||
};
|
||
}
|
||
function displayLabel(value, fallback) {
|
||
return typeof value === "string" && value.trim() ? value : fallback;
|
||
}
|
||
function timeWindowContributions(state, context) {
|
||
if (!context.declaredDays.length)
|
||
return [];
|
||
const labels = recordValue(context.request.labels);
|
||
const identities = recordValue(context.request.identities);
|
||
return context.rules.filter((rule) => rule.enabled && rule.type === "time_window").flatMap((rule) => {
|
||
const declaredDays = declaredWindowDays(rule, context);
|
||
return declaredDays.length ? [
|
||
{
|
||
ruleId: rule.id,
|
||
label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id),
|
||
requested: rule.placeDays,
|
||
declaredDays,
|
||
placementDates: [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).map(({ date }) => date).sort(compareDate)
|
||
}
|
||
] : [];
|
||
});
|
||
}
|
||
function ifThenContributions(state, context) {
|
||
const labels = recordValue(context.request.labels);
|
||
const identities = recordValue(context.request.identities);
|
||
return context.rules.filter((rule) => rule.enabled && rule.type === "if_then").flatMap((rule) => occurrences(rule, context).flatMap((occurrence) => {
|
||
const id = `earned:${rule.id}:${occurrence}`;
|
||
const activation = state.earnedActivations.find((item) => item.id === id);
|
||
const pattern = selectedOccurrencePattern(rule, occurrence, state, context);
|
||
if (!pattern)
|
||
return [];
|
||
const declaredDays = pattern.flatMap((date) => {
|
||
const day = declaredPatternDay(date, rule.placeType, context);
|
||
return day ? [day] : [];
|
||
});
|
||
if (!declaredDays.length && !activation)
|
||
return [];
|
||
const bounds = activation ? earnedPeriodBounds(state, activation, context) : undefined;
|
||
return [
|
||
{
|
||
ruleId: rule.id,
|
||
label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id),
|
||
occurrence,
|
||
requested: rule.placeDays,
|
||
declaredDays,
|
||
placementDates: pattern.filter((date) => !context.declaredDates.has(date)),
|
||
...activation && bounds ? {
|
||
earnedEntitlement: {
|
||
id,
|
||
type: rule.rewardType,
|
||
quantity: rule.rewardDays,
|
||
...bounds,
|
||
added: bounds.availableFrom > context.request.calculationDate
|
||
}
|
||
} : {}
|
||
}
|
||
];
|
||
}));
|
||
}
|
||
function planExplanation(state, context, placements, periods, preferences, remaining, metrics) {
|
||
const source = context.request;
|
||
const rawBalances = Array.isArray(source.balances) ? source.balances : [];
|
||
const rawRules = Array.isArray(source.rules) ? source.rules : [];
|
||
const labels = recordValue(source.labels);
|
||
const identities = recordValue(source.identities);
|
||
const vector = objectiveVector(state, context);
|
||
const windowContributions = timeWindowContributions(state, context);
|
||
const patternContributions = ifThenContributions(state, context);
|
||
return {
|
||
kind: "PlanExplanation",
|
||
calculationDate: context.request.calculationDate,
|
||
...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
|
||
timeOffPeriods: periods,
|
||
...windowContributions.length ? { timeWindowContributions: windowContributions } : {},
|
||
...patternContributions.length ? { ifThenContributions: patternContributions } : {},
|
||
placements: placements.map((placement) => {
|
||
const balance = rawBalances.map(recordValue).find((candidate) => candidate?.id === placement.balanceId);
|
||
const normalizedBalance = state.balances.find((candidate) => candidate.id === placement.balanceId);
|
||
const period = periods.find((candidate) => candidate.placementDates.includes(placement.date));
|
||
const sourceRules = placement.ruleIds.map((id) => {
|
||
const rule = context.rules.find((candidate) => candidate.id === id);
|
||
const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id);
|
||
const label = displayLabel(labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name, id);
|
||
return { id, label };
|
||
});
|
||
const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false;
|
||
const withoutPlacement = cloneState(state);
|
||
withoutPlacement.placements.delete(placement.date);
|
||
const released = withoutPlacement.balances.find((candidate) => candidate.id === placement.balanceId);
|
||
if (released)
|
||
released.remaining++;
|
||
const comparison = objectiveComparison(objectiveVector(withoutPlacement, context), vector);
|
||
const contributions = [
|
||
{
|
||
kind: "entitlement",
|
||
code: "balance",
|
||
label: displayLabel(labels?.[placement.balanceId] ?? identities?.[placement.balanceId] ?? balance?.label ?? balance?.name ?? balance?.id, placement.balanceId)
|
||
},
|
||
...sourceRules.map((rule) => ({
|
||
kind: "rule",
|
||
code: "rule",
|
||
label: rule.label,
|
||
ruleId: rule.id
|
||
}))
|
||
];
|
||
if (atRisk)
|
||
contributions.push({
|
||
kind: "at-risk",
|
||
code: "at-risk",
|
||
label: "Protects leave at risk of expiry."
|
||
});
|
||
if (placement.supporting)
|
||
contributions.push({
|
||
kind: "supporting",
|
||
code: "supporting",
|
||
label: "Supports the surrounding time-off plan."
|
||
});
|
||
if (period)
|
||
contributions.push({
|
||
kind: "time-off-period",
|
||
code: "time-off-period",
|
||
label: `Contributes to the time-off period ${period.start}–${period.end}.`,
|
||
dates: period.nonWorkingDates
|
||
});
|
||
for (const delta of comparison.delta) {
|
||
if (typeof delta.value !== "number" || delta.value === 0)
|
||
continue;
|
||
const objective = vector.objectives.find((candidate) => candidate.id === delta.id);
|
||
contributions.push({
|
||
kind: "metric",
|
||
code: objective.id,
|
||
label: objective.label ?? objective.id,
|
||
value: delta.value
|
||
});
|
||
}
|
||
const balanceSnapshot = balance ? {
|
||
id: typeof balance.id === "string" ? balance.id : placement.balanceId,
|
||
type: typeof balance.type === "string" ? balance.type : placement.type,
|
||
quantity: typeof balance.quantity === "number" ? balance.quantity : 0,
|
||
name: typeof balance.name === "string" ? balance.name : undefined,
|
||
label: typeof balance.label === "string" ? balance.label : undefined,
|
||
availableFrom: typeof balance.availableFrom === "string" || balance.availableFrom instanceof Date ? balance.availableFrom : normalizedBalance?.availableFrom,
|
||
expiryDate: typeof balance.expiryDate === "string" || balance.expiryDate instanceof Date ? balance.expiryDate : normalizedBalance?.expiryDate ?? ""
|
||
} : normalizedBalance ? {
|
||
id: normalizedBalance.id,
|
||
type: normalizedBalance.type,
|
||
quantity: normalizedBalance.quantity,
|
||
name: normalizedBalance.sourceRuleId ? `Earned from ${displayLabel(labels?.[normalizedBalance.sourceRuleId], normalizedBalance.sourceRuleId)}` : undefined,
|
||
availableFrom: normalizedBalance.availableFrom,
|
||
expiryDate: normalizedBalance.expiryDate
|
||
} : undefined;
|
||
return {
|
||
placement: { ...placement, ruleIds: [...placement.ruleIds] },
|
||
balance: balanceSnapshot,
|
||
ruleIds: [...placement.ruleIds],
|
||
supporting: placement.supporting,
|
||
timeOffPeriod: period,
|
||
remainingBalance: remaining.find((candidate) => candidate.id === placement.balanceId),
|
||
remainingQuantity: normalizedBalance?.remaining ?? 0,
|
||
contributions,
|
||
finalMetrics: metrics,
|
||
objectiveVector: vector,
|
||
comparison,
|
||
firstDecisiveObjective: comparison.firstDecisiveObjective
|
||
};
|
||
})
|
||
};
|
||
}
|
||
function makePlan(state, context, preferences, stages) {
|
||
const periods = periodData(state, context);
|
||
const metrics = qualityMetrics(state, context, preferences);
|
||
const placements = [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({
|
||
date: placement.date,
|
||
balanceId: placement.balanceId,
|
||
type: placement.type,
|
||
ruleIds: [...placement.ruleIds].sort(),
|
||
supporting: placement.supporting
|
||
}));
|
||
const remaining = remainingBalances(state, context, stages.includes("dates"));
|
||
const windowContributions = timeWindowContributions(state, context);
|
||
const patternContributions = ifThenContributions(state, context);
|
||
const plan = {
|
||
kind: "Plan",
|
||
calculationDate: context.request.calculationDate,
|
||
...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
|
||
...windowContributions.length ? { timeWindowContributions: windowContributions } : {},
|
||
...patternContributions.length ? { ifThenContributions: patternContributions } : {},
|
||
horizon: context.horizon,
|
||
placements,
|
||
timeOffPeriods: periods,
|
||
preferences,
|
||
remainingBalances: remaining,
|
||
metrics,
|
||
completedStages: [...stages],
|
||
lastCompletedStage: stages[stages.length - 1] ?? "validation"
|
||
};
|
||
context.retain?.(state);
|
||
return plan;
|
||
}
|
||
function markProgress(context, stage) {
|
||
if (typeof context.request.progress === "function")
|
||
observeCallback(context, () => context.request.progress(stage));
|
||
}
|
||
function ruleStage(rule) {
|
||
return `${rule.type === "optimization" ? "optimization" : "preference"}:${rule.id}`;
|
||
}
|
||
function planningStages(context) {
|
||
return [
|
||
"validation",
|
||
"obligations",
|
||
...context.rules.filter((rule) => rule.enabled && rule.force === "preference").map(ruleStage),
|
||
"expiry",
|
||
"dates"
|
||
];
|
||
}
|
||
function conflictExplanation(outcome, context) {
|
||
const labels = recordValue(context.request.labels);
|
||
return {
|
||
kind: "ConflictExplanation",
|
||
conflictingRules: outcome.conflictingRuleIds.map((id) => ({
|
||
id,
|
||
label: displayLabel(labels?.[id] ?? context.rules.find((rule) => rule.id === id)?.name, id)
|
||
})),
|
||
bottleneck: outcome.reasons[0]
|
||
};
|
||
}
|
||
function obligationConflict(requirements, context) {
|
||
const ruleIds = [...new Set(requirements.map((requirement) => requirement.rule.id))];
|
||
const generatedTypes = new Set(context.rules.filter((rule) => rule.enabled && rule.type === "if_then" && (rule.rewardDays ?? 0) > 0 && !!rule.rewardType).map((rule) => rule.rewardType));
|
||
let generatedCapacityBottleneck = false;
|
||
for (const requirement of requirements) {
|
||
if (requirement.rule.placeType === "any")
|
||
continue;
|
||
const required = requirement.requiredPlacements;
|
||
const capacity = context.initialBalances.filter((balance) => balance.type === requirement.rule.placeType).reduce((sum, balance) => sum + balance.quantity, 0);
|
||
if (capacity < required) {
|
||
if (!generatedTypes.has(requirement.rule.placeType))
|
||
return {
|
||
kind: "PlanningConflict",
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason("typed-capacity", `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, ruleIds)
|
||
]
|
||
};
|
||
generatedCapacityBottleneck = true;
|
||
}
|
||
}
|
||
if (requirements.some((requirement) => requirement.patterns !== undefined && requirement.patterns.length === 0 || requirement.candidates !== undefined && requirement.requiredPlacements > 0 && requirement.candidates.length === 0))
|
||
return {
|
||
kind: "PlanningConflict",
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason("horizon-bottleneck", "The planning horizon contains no eligible dates for at least one obligation.", ruleIds)
|
||
]
|
||
};
|
||
if (generatedCapacityBottleneck)
|
||
return {
|
||
kind: "PlanningConflict",
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason("earned-entitlement-bottleneck", "An earned leave entitlement is not available early enough to satisfy every obligation.", ruleIds)
|
||
]
|
||
};
|
||
return {
|
||
kind: "PlanningConflict",
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason("joint-date-conflict", "The obligation date patterns cannot be satisfied together.", ruleIds)
|
||
]
|
||
};
|
||
}
|
||
function calculatePlanningInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback) {
|
||
const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback);
|
||
let next = steps.next();
|
||
while (!next.done)
|
||
next = steps.next();
|
||
return next.value;
|
||
}
|
||
async function calculatePlanningAsyncInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, isCancelled = () => false) {
|
||
const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, true);
|
||
let next = steps.next();
|
||
let pauseAt = now() + 8;
|
||
while (!next.done) {
|
||
if (next.value || now() >= pauseAt) {
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
pauseAt = now() + 8;
|
||
}
|
||
next = isCancelled() ? steps.throw(new UserCancelled) : steps.next();
|
||
}
|
||
return next.value;
|
||
}
|
||
function* prepareCalendar(horizon, progress) {
|
||
const start = addDays(horizon.start, -400);
|
||
const end = addDays(horizon.end, 400);
|
||
const holidays = new Set;
|
||
for (let year = Number(start.slice(0, 4));year <= Number(end.slice(0, 4)); year++) {
|
||
yield true;
|
||
for (const holiday of frenchPublicHolidaysForYear(year))
|
||
holidays.add(holiday);
|
||
progress();
|
||
}
|
||
const workingDays = [];
|
||
const calendarDays = [];
|
||
const calendarIndex = new Map;
|
||
const workingCalendar = new Set;
|
||
const workingPrefix = [0];
|
||
for (let date = start;compareDate(date, end) < 0; date = addDays(date, 1)) {
|
||
yield;
|
||
calendarIndex.set(date, calendarDays.length);
|
||
calendarDays.push(date);
|
||
const working = isWorking(date, holidays);
|
||
if (working) {
|
||
workingCalendar.add(date);
|
||
if (date >= horizon.start && date < horizon.end)
|
||
workingDays.push(date);
|
||
}
|
||
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
|
||
}
|
||
return { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix };
|
||
}
|
||
function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, retainIncumbents = false) {
|
||
const source = runtimeRequest(request);
|
||
const normalized = normalizeRequest(source);
|
||
if (normalized.reasons.length) {
|
||
const outcome = { ...invalidInputFor(source), calculationDate: source.calculationDate };
|
||
outcomeCallback?.(outcome);
|
||
artifactsCallback?.({});
|
||
return outcome;
|
||
}
|
||
const horizon = normalized.horizon;
|
||
let calendar;
|
||
try {
|
||
if (typeof source.progress === "function")
|
||
source.progress("preparation");
|
||
yield true;
|
||
calendar = yield* prepareCalendar(horizon, () => {
|
||
if (typeof source.progress === "function")
|
||
source.progress("preparation");
|
||
});
|
||
} catch (error) {
|
||
if (!(error instanceof UserCancelled))
|
||
throw error;
|
||
const outcome = {
|
||
kind: "PlanningIncomplete",
|
||
calculationDate: source.calculationDate,
|
||
completedStages: ["validation"],
|
||
lastCompletedStage: "validation",
|
||
reasons: [reason("user-cancelled", "The calculation was cancelled by the user.")]
|
||
};
|
||
const explanation = {
|
||
kind: "IncompleteExplanation",
|
||
calculationDate: source.calculationDate,
|
||
reason: outcome.reasons[0],
|
||
completedStages: outcome.completedStages,
|
||
uncompletedStages: ["preparation", "obligations"]
|
||
};
|
||
outcomeCallback?.(outcome);
|
||
explanationCallback?.(explanation, undefined, undefined);
|
||
artifactsCallback?.({
|
||
explanation
|
||
});
|
||
return outcome;
|
||
}
|
||
const { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix } = calendar;
|
||
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||
const deadline = typeof source.deadlineMs === "number" ? source.deadlineMs : Number.POSITIVE_INFINITY;
|
||
const context = {
|
||
horizon,
|
||
holidays,
|
||
holidayWeeks: [],
|
||
calendarDays,
|
||
calendarIndex,
|
||
workingCalendar,
|
||
workingPrefix,
|
||
workingDays,
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline,
|
||
started,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => {
|
||
return;
|
||
},
|
||
reservedTypes: new Set,
|
||
sharedTypesByDate: new Map,
|
||
protectedTypesByDate: new Map,
|
||
preferenceDemandByDate: new Map,
|
||
anyPreferenceDemandByDate: new Map,
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map
|
||
};
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
context.check = () => {
|
||
context.nodes++;
|
||
if (context.deadline <= 1 || context.nodes % 16 === 0) {
|
||
if (now() - context.started - context.observerTime >= context.deadline)
|
||
throw new DeadlineReached;
|
||
}
|
||
};
|
||
const initialState = {
|
||
balances: normalized.balances.map((balance) => ({
|
||
...balance,
|
||
remaining: balance.quantity
|
||
})),
|
||
placements: new Map,
|
||
earnedActivations: []
|
||
};
|
||
const stages = ["validation"];
|
||
let state = initialState;
|
||
const preferences = [];
|
||
let checkpoint;
|
||
let checkpointState;
|
||
let retained;
|
||
if (retainIncumbents)
|
||
context.retain = (candidate, finalizedMetrics, candidateVector) => {
|
||
const check = context.check;
|
||
context.check = () => {
|
||
return;
|
||
};
|
||
try {
|
||
const finalized = finalizedMetrics || candidateVector ? candidate : finalizeEarnedState(candidate, context);
|
||
if (!finalized)
|
||
return;
|
||
if (unsatisfiedObligationCount(finalized, context))
|
||
return;
|
||
if (retained && preferenceStateKey(retained.state) === preferenceStateKey(finalized))
|
||
return;
|
||
const vector = candidateVector ?? objectiveVector(finalized, context);
|
||
const compared = retained ? compareObjectiveLists(vector.objectives, retained.vector.objectives) : -1;
|
||
const provenanceRefresh = compared === 0 && !!retained && JSON.stringify(retained.state.balances) === JSON.stringify(finalized.balances) && retained.state.placements.size === finalized.placements.size && [...retained.state.placements].every(([date, previous]) => {
|
||
const placement = finalized.placements.get(date);
|
||
return placement && placement.balanceId === previous.balanceId && placement.type === previous.type && placement.supporting === previous.supporting;
|
||
}) && JSON.stringify(retained.state.earnedActivations) === JSON.stringify(finalized.earnedActivations);
|
||
const chosen = compared < 0 || provenanceRefresh;
|
||
if (chosen) {
|
||
const firstValidCandidate = !retained;
|
||
const candidatePreferences = preferenceResultsForState(finalized, context);
|
||
retained = {
|
||
state: cloneState(reconcileOptimizationProvenance(finalized, context, retained?.state ?? initialState, vector.objectives)),
|
||
vector,
|
||
preferences: candidatePreferences,
|
||
metrics: finalizedMetrics ? {
|
||
...finalizedMetrics,
|
||
preferenceSatisfaction: candidatePreferences.map(({ satisfied }) => satisfied)
|
||
} : qualityMetrics(finalized, context, candidatePreferences)
|
||
};
|
||
if (firstValidCandidate && checkpointCallback)
|
||
observeCallback(context, () => checkpointCallback(makePlan(retained.state, context, retained.preferences, stages), planningStateSnapshot(retained.state, context)));
|
||
}
|
||
} finally {
|
||
context.check = check;
|
||
}
|
||
};
|
||
const finish = (outcome, explanationState) => {
|
||
outcome.calculationDate = source.calculationDate;
|
||
const planningFinishedAt = now();
|
||
const planningExcludedTime = context.observerTime;
|
||
outcomeCallback?.(outcome);
|
||
context.check = () => {
|
||
return;
|
||
};
|
||
let explanation;
|
||
let explanationError;
|
||
let finalState;
|
||
try {
|
||
if (outcome.kind === "Plan" && explanationState)
|
||
explanation = planExplanation(explanationState, context, outcome.placements, outcome.timeOffPeriods, outcome.preferences, outcome.remainingBalances, outcome.metrics);
|
||
else if (outcome.kind === "PlanningIncomplete") {
|
||
if (outcome.incumbent && explanationState) {
|
||
const incumbentExplanation = planExplanation(explanationState, context, outcome.incumbent.placements, outcome.incumbent.timeOffPeriods, outcome.incumbent.preferences, outcome.incumbent.remainingBalances, outcome.incumbent.metrics);
|
||
explanation = {
|
||
...incumbentExplanation,
|
||
incomplete: {
|
||
kind: "IncompleteExplanation",
|
||
reason: outcome.reasons[0],
|
||
completedStages: [...outcome.completedStages],
|
||
uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage))
|
||
}
|
||
};
|
||
} else
|
||
explanation = {
|
||
kind: "IncompleteExplanation",
|
||
reason: outcome.reasons[0],
|
||
completedStages: [...outcome.completedStages],
|
||
uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage))
|
||
};
|
||
} else if (outcome.kind === "PlanningConflict")
|
||
explanation = conflictExplanation(outcome, context);
|
||
} catch (error) {
|
||
explanationError = reason("explanation-error", error instanceof Error ? error.message : String(error));
|
||
}
|
||
try {
|
||
if (explanationState)
|
||
finalState = planningStateSnapshot(explanationState, context);
|
||
} catch (error) {
|
||
explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error));
|
||
}
|
||
if (explanation)
|
||
explanation.calculationDate = source.calculationDate;
|
||
try {
|
||
explanationCallback?.(explanation, explanationError, finalState);
|
||
} catch (error) {
|
||
explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error));
|
||
}
|
||
artifactsCallback?.({
|
||
explanation,
|
||
finalState,
|
||
explanationError,
|
||
timings: {
|
||
planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime)
|
||
}
|
||
});
|
||
return outcome;
|
||
};
|
||
const publishCheckpoint = () => {
|
||
state = reconcileOptimizationProvenance(state, context, checkpointState ?? initialState);
|
||
context.retain?.(state);
|
||
checkpoint = makePlan(state, context, preferences, stages);
|
||
checkpointState = cloneState(state);
|
||
if (checkpointCallback) {
|
||
observeCallback(context, () => checkpointCallback(checkpoint, planningStateSnapshot(checkpointState, context)));
|
||
}
|
||
};
|
||
try {
|
||
markProgress(context, "validation");
|
||
yield true;
|
||
if (context.declaredDays.length) {
|
||
for (const rule of context.rules) {
|
||
if (!rule.enabled || rule.type !== "if_then" || !rule.rewardDays)
|
||
continue;
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
yield;
|
||
const pattern = patternsFor(rule, occurrence, context).find((dates) => dates.every((date) => declaredPatternDay(date, rule.placeType, context)));
|
||
if (pattern)
|
||
state = addEarnedBalance(state, rule, occurrence, pattern, context);
|
||
}
|
||
}
|
||
if (state !== initialState)
|
||
context.retain?.(state);
|
||
}
|
||
context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context);
|
||
markProgress(context, "obligations");
|
||
yield true;
|
||
const obligationRequirements = buildRequirements(normalized.rules, context, "obligation");
|
||
context.reservedTypes = new Set(obligationRequirements.filter((requirement) => requirement.requiredPlacements > 0).map((requirement) => requirement.rule.placeType).filter((selector) => selector !== "any"));
|
||
for (const requirement of obligationRequirements) {
|
||
yield;
|
||
if (!requirement.candidates || !requirement.requiredPlacements || requirement.candidates.length !== 1 || requirement.rule.placeType === "any")
|
||
continue;
|
||
const types = context.sharedTypesByDate.get(requirement.candidates[0]) ?? new Set;
|
||
types.add(requirement.rule.placeType);
|
||
context.sharedTypesByDate.set(requirement.candidates[0], types);
|
||
}
|
||
const obligationSolutions = [];
|
||
const optionalProducers = obligationRequirements.some((requirement) => requirement.requiredPlacements > 0) ? buildRequirements(normalized.rules, context, "preference").filter((requirement) => requirement.patterns?.length && requirement.rule.rewardDays && requirement.requiredPlacements > 0).map((requirement) => ({ ...requirement, optional: true })) : [];
|
||
const jointRequirements = optionalProducers.length ? [...obligationRequirements, ...optionalProducers].sort(compareRequirements) : obligationRequirements;
|
||
const hasEarnedObligation = optionalProducers.length > 0 || obligationRequirements.some((requirement) => (requirement.rule.rewardDays ?? 0) > 0);
|
||
const obligationSolutionLimit = hasEarnedObligation || obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT;
|
||
yield* satisfyObligations(jointRequirements, state, context, 0, obligationSolutions, obligationSolutionLimit);
|
||
if (!obligationSolutions.length) {
|
||
stages.push("obligations");
|
||
return finish(obligationConflict(obligationRequirements, context));
|
||
}
|
||
const preferenceRules = normalized.rules.filter((item) => item.enabled && item.force === "preference");
|
||
let previewSolutions = obligationSolutions;
|
||
if (obligationSolutionLimit > 0 && obligationRequirements.every((requirement) => requirement.rule.type === "time_window") && obligationSolutions.length > WINDOW_OBLIGATION_PREVIEW_LIMIT) {
|
||
const ranked = [];
|
||
for (const candidate of obligationSolutions) {
|
||
yield;
|
||
context.check();
|
||
ranked.push({
|
||
state: candidate,
|
||
objectives: obligationPreviewObjectives({ state: candidate }, context)
|
||
});
|
||
}
|
||
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
|
||
previewSolutions = ranked.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT).map(({ state: state2 }) => state2);
|
||
}
|
||
const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined;
|
||
const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : [];
|
||
let bestPreview = soleSolution ? {
|
||
obligationState: soleSolution,
|
||
state: soleSolution,
|
||
preferences: solePreferences,
|
||
metrics: qualityMetrics(soleSolution, context, solePreferences)
|
||
} : null;
|
||
for (const obligationSolution of soleSolution ? [] : previewSolutions) {
|
||
yield;
|
||
context.check();
|
||
const preview = yield* applyPreferenceRules(cloneState(obligationSolution), preferenceRules, context);
|
||
const previewState = finalizeEarnedState(preview.state, context);
|
||
if (!previewState)
|
||
continue;
|
||
const candidate = {
|
||
obligationState: obligationSolution,
|
||
state: previewState,
|
||
preferences: preview.preferences,
|
||
metrics: qualityMetrics(previewState, context, preview.preferences)
|
||
};
|
||
const compared = bestPreview ? compareObligationPreviews(candidate, bestPreview, context) : undefined;
|
||
if (!bestPreview || compared < 0)
|
||
bestPreview = candidate;
|
||
}
|
||
if (!bestPreview) {
|
||
stages.push("obligations");
|
||
return finish(obligationConflict(obligationRequirements, context));
|
||
}
|
||
state = bestPreview.obligationState;
|
||
stages.push("obligations");
|
||
publishCheckpoint();
|
||
for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
||
yield;
|
||
const rule = preferenceRules[preferenceIndex];
|
||
markProgress(context, ruleStage(rule));
|
||
yield true;
|
||
state = yield* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context);
|
||
stages.push(ruleStage(rule));
|
||
publishCheckpoint();
|
||
}
|
||
markProgress(context, "expiry");
|
||
yield true;
|
||
state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false);
|
||
stages.push("expiry");
|
||
publishCheckpoint();
|
||
markProgress(context, "dates");
|
||
yield true;
|
||
stages.push("dates");
|
||
const finalized = finalizeEarnedState(state, context);
|
||
if (!finalized) {
|
||
return finish({
|
||
kind: "PlanningConflict",
|
||
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
|
||
reasons: [
|
||
reason("earned-entitlement-order", "Generated leave became available only after its final merged time-off period.")
|
||
]
|
||
});
|
||
}
|
||
state = finalized;
|
||
publishCheckpoint();
|
||
markProgress(context, "complete");
|
||
yield true;
|
||
return finish(checkpoint, state);
|
||
} catch (error) {
|
||
if (error instanceof UserCancelled && retained) {
|
||
context.check = () => {
|
||
return;
|
||
};
|
||
context.retain = undefined;
|
||
checkpointState = retained.state;
|
||
const partialPreferences = retained.preferences.map((preference) => ({
|
||
...preference,
|
||
reason: preferenceReason(normalized.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, retained.state)
|
||
}));
|
||
checkpoint = makePlan(checkpointState, context, partialPreferences, stages);
|
||
}
|
||
const interruption = error instanceof UserCancelled ? reason("user-cancelled", "The calculation was cancelled by the user.") : error instanceof DeadlineReached ? reason("deadline", `Planning stopped after ${context.deadline}ms before all quality stages completed.`) : reason("planner-error", error instanceof Error ? error.message : String(error));
|
||
return finish({
|
||
kind: "PlanningIncomplete",
|
||
incumbent: checkpoint,
|
||
completedStages: [...stages],
|
||
lastCompletedStage: stages[stages.length - 1],
|
||
reasons: [interruption]
|
||
}, checkpointState);
|
||
}
|
||
}
|
||
function explainIncompleteCheckpoint(request, incumbent, checkpoint, interruption) {
|
||
try {
|
||
const source = runtimeRequest(request, incumbent.calculationDate ?? checkpoint.calculationDate);
|
||
const normalized = normalizeRequest(source);
|
||
if (normalized.reasons.length || !normalized.horizon)
|
||
throw new Error("The checkpoint explanation request is invalid.");
|
||
const horizon = normalized.horizon;
|
||
const holidays = holidaySet(horizon);
|
||
const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
|
||
const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
|
||
const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
|
||
const workingPrefix = [0];
|
||
for (const date of calendarDays)
|
||
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)));
|
||
const context = {
|
||
horizon,
|
||
holidayWeeks: [],
|
||
holidays,
|
||
calendarDays,
|
||
calendarIndex,
|
||
workingCalendar,
|
||
workingPrefix,
|
||
workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)),
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline: Number.POSITIVE_INFINITY,
|
||
started: 0,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => {
|
||
return;
|
||
},
|
||
reservedTypes: new Set,
|
||
sharedTypesByDate: new Map,
|
||
protectedTypesByDate: new Map,
|
||
preferenceDemandByDate: new Map,
|
||
anyPreferenceDemandByDate: new Map,
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map
|
||
};
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
const state = {
|
||
balances: checkpoint.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(checkpoint.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) }
|
||
])),
|
||
earnedActivations: checkpoint.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates]
|
||
}))
|
||
};
|
||
const explanation = planExplanation(state, context, incumbent.placements, incumbent.timeOffPeriods, incumbent.preferences, incumbent.remainingBalances, incumbent.metrics);
|
||
const uncompletedStages = planningStages(context).filter((stage) => !incumbent.completedStages.includes(stage));
|
||
return {
|
||
explanation: uncompletedStages.length === 0 ? explanation : {
|
||
...explanation,
|
||
incomplete: {
|
||
kind: "IncompleteExplanation",
|
||
reason: interruption,
|
||
completedStages: [...incumbent.completedStages],
|
||
uncompletedStages
|
||
}
|
||
},
|
||
finalState: planningStateSnapshot(state, context)
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
explanationError: reason("explanation-error", error instanceof Error ? error.message : String(error))
|
||
};
|
||
}
|
||
}
|
||
function counterfactualReason(code, message) {
|
||
return { code, message };
|
||
}
|
||
function counterfactualContext(source, normalized, horizon) {
|
||
const holidays = holidaySet(horizon);
|
||
const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
|
||
const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
|
||
const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
|
||
const workingPrefix = [0];
|
||
for (const date of calendarDays)
|
||
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)));
|
||
const context = {
|
||
horizon,
|
||
holidayWeeks: [],
|
||
holidays,
|
||
calendarDays,
|
||
calendarIndex,
|
||
workingCalendar,
|
||
workingPrefix,
|
||
workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)),
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline: Number.POSITIVE_INFINITY,
|
||
started: 0,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => {
|
||
return;
|
||
},
|
||
reservedTypes: new Set,
|
||
sharedTypesByDate: new Map,
|
||
protectedTypesByDate: new Map,
|
||
preferenceDemandByDate: new Map,
|
||
anyPreferenceDemandByDate: new Map,
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map
|
||
};
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
return context;
|
||
}
|
||
function stateFromPlanningSnapshot(state) {
|
||
return {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(state.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) }
|
||
])),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates]
|
||
}))
|
||
};
|
||
}
|
||
function sameObjectiveValue(a, b) {
|
||
return a === b;
|
||
}
|
||
function objectiveComparison(before, after) {
|
||
const delta = after.objectives.map((objective, index) => {
|
||
const previous = before.objectives[index];
|
||
const value = typeof objective.value === "number" && typeof previous?.value === "number" ? objective.value - previous.value : sameObjectiveValue(objective.value, previous?.value ?? objective.value) ? 0 : null;
|
||
return {
|
||
id: objective.id,
|
||
direction: objective.direction,
|
||
value,
|
||
label: objective.label
|
||
};
|
||
});
|
||
const first = firstDifferentObjective(after.objectives, before.objectives);
|
||
const decisive = first >= 0 ? after.objectives[first].id : undefined;
|
||
return {
|
||
before,
|
||
after,
|
||
delta,
|
||
firstDecisiveObjective: decisive,
|
||
tieBreak: decisive === "balance-expiry" ? "balance-expiry" : decisive === "placement-date" ? "placement-date" : undefined
|
||
};
|
||
}
|
||
function counterfactualRuleValid(date, type, ruleIds, context, baseline) {
|
||
for (const ruleId of ruleIds) {
|
||
const rule = context.rules.find((candidate) => candidate.id === ruleId);
|
||
if (!rule)
|
||
return false;
|
||
if (rule.type === "optimization")
|
||
continue;
|
||
if (rule.type === "time_window") {
|
||
if (!selectorMatches(rule.placeType, type) || compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0)
|
||
return false;
|
||
continue;
|
||
}
|
||
const matchesPattern = occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date) && pattern.every((candidateDate) => candidateDate === date || patternDateSatisfied(candidateDate, rule, baseline, context))));
|
||
if (!matchesPattern || !selectorMatches(rule.placeType, type))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
function counterfactualObligationsValid(state, context, previous) {
|
||
return context.rules.filter((rule) => rule.enabled && rule.force === "obligation").every((rule) => {
|
||
if (rule.type === "time_window") {
|
||
const count = (candidate) => [...candidate.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).length;
|
||
const requiredPlacements = rule.placeDays - declaredWindowDays(rule, context).length;
|
||
return count(previous) !== requiredPlacements || count(state) === requiredPlacements;
|
||
}
|
||
return occurrences(rule, context).every((occurrence) => {
|
||
const patterns = patternsFor(rule, occurrence, context);
|
||
const complete = (candidate) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, candidate, context)));
|
||
return !complete(previous) || complete(state);
|
||
});
|
||
});
|
||
}
|
||
function preferenceResultsForState(state, context) {
|
||
let patternsByRule = preferencePatternCache.get(context);
|
||
if (!patternsByRule) {
|
||
patternsByRule = new Map(context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type === "if_then").map((rule) => [
|
||
rule.id,
|
||
occurrences(rule, context).map((occurrence) => patternsFor(rule, occurrence, context))
|
||
]));
|
||
preferencePatternCache.set(context, patternsByRule);
|
||
}
|
||
return context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type !== "optimization").map((rule) => {
|
||
const occurrencePatterns = patternsByRule.get(rule.id) ?? [];
|
||
const requested = rule.type === "time_window" ? rule.placeDays : occurrencePatterns.length;
|
||
const satisfied = rule.type === "time_window" ? Math.min(rule.placeDays, declaredWindowDays(rule, context).length + [...state.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).length) : occurrencePatterns.filter((patterns) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context)))).length;
|
||
return {
|
||
ruleId: rule.id,
|
||
requested,
|
||
satisfied,
|
||
completeOccurrences: rule.type === "if_then" ? satisfied : 0
|
||
};
|
||
});
|
||
}
|
||
function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapshot) {
|
||
const source = runtimeRequest(request, plan.calculationDate ?? finalStateSnapshot?.calculationDate);
|
||
const normalized = normalizeRequest(source);
|
||
const horizon = normalized.horizon ?? plan.horizon;
|
||
const context = counterfactualContext(source, normalized, horizon);
|
||
const finalState = finalStateSnapshot ? stateFromPlanningSnapshot(finalStateSnapshot) : (() => {
|
||
const balances = normalized.balances.map((balance) => ({
|
||
...balance,
|
||
remaining: balance.quantity
|
||
}));
|
||
for (const placement of plan.placements) {
|
||
let balance = balances.find((candidate) => candidate.id === placement.balanceId);
|
||
if (!balance) {
|
||
const remaining = plan.remainingBalances.find((candidate) => candidate.id === placement.balanceId);
|
||
balance = {
|
||
id: placement.balanceId,
|
||
type: placement.type,
|
||
quantity: 0,
|
||
remaining: 0,
|
||
availableFrom: remaining?.availableFrom ?? horizon.start,
|
||
expiryDate: remaining?.expiryDate ?? horizon.end,
|
||
earned: remaining?.earned ?? true,
|
||
sourceRuleId: remaining?.sourceRuleId
|
||
};
|
||
balances.push(balance);
|
||
}
|
||
balance.remaining = Math.max(0, balance.remaining - 1);
|
||
}
|
||
return {
|
||
balances,
|
||
placements: new Map(plan.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) }
|
||
])),
|
||
earnedActivations: []
|
||
};
|
||
})();
|
||
const comparisonState = cloneState(finalState);
|
||
const baseline = cloneState(finalState);
|
||
const removeDate = selected.date;
|
||
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
|
||
if (removeDate)
|
||
baseline.placements.delete(removeDate);
|
||
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
|
||
if (released && removed?.balanceId === selected.balanceId)
|
||
released.remaining++;
|
||
const beforeState = baseline;
|
||
const before = objectiveVector(beforeState, context);
|
||
const results = [];
|
||
for (const date of dateRange(horizon.start, horizon.end)) {
|
||
let ineligible;
|
||
if (!isWorking(date, context.holidays))
|
||
ineligible = counterfactualReason("non-working", "The date is a weekend or public holiday.");
|
||
else if (context.declaredDates.has(date))
|
||
ineligible = counterfactualReason("declared-day", "The date is a declared non-working day fixed by the user.");
|
||
else if (baseline.placements.has(date))
|
||
ineligible = counterfactualReason("occupied", "Another final placement already uses this date.");
|
||
else if (!released)
|
||
ineligible = counterfactualReason("unavailable", "The selected leave entitlement is unavailable.");
|
||
else if (compareDate(released.availableFrom, date) > 0)
|
||
ineligible = counterfactualReason("unavailable", "The selected entitlement is not available yet.");
|
||
else if (compareDate(date, released.expiryDate) > 0)
|
||
ineligible = counterfactualReason("expired", "The selected entitlement has expired by this date.");
|
||
else if (released.type !== selected.type)
|
||
ineligible = counterfactualReason("type-incompatible", "The date cannot host this leave type.");
|
||
else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline))
|
||
ineligible = counterfactualReason("rule-invalid", "The date does not preserve the placement rules.");
|
||
if (ineligible) {
|
||
results.push({ date, status: "ineligible", reason: ineligible });
|
||
continue;
|
||
}
|
||
const candidate = assignDateToBalance(baseline, date, selected.balanceId, context, new Set(selected.ruleIds));
|
||
if (!candidate) {
|
||
results.push({
|
||
date,
|
||
status: "ineligible",
|
||
reason: counterfactualReason("unavailable", "The selected entitlement cannot be placed on this date.")
|
||
});
|
||
continue;
|
||
}
|
||
candidate.placements.get(date).supporting = selected.supporting;
|
||
if (removeDate && removeDate !== date)
|
||
for (const activation of candidate.earnedActivations) {
|
||
if (!selected.ruleIds.includes(activation.ruleId))
|
||
continue;
|
||
activation.dates = activation.dates.map((activationDate) => activationDate === removeDate ? date : activationDate);
|
||
}
|
||
const finalized = finalizeEarnedState(candidate, context);
|
||
if (!finalized) {
|
||
results.push({
|
||
date,
|
||
status: "ineligible",
|
||
reason: counterfactualReason("unavailable", "This date would make generated leave unavailable until its producing period ends.")
|
||
});
|
||
continue;
|
||
}
|
||
if (!counterfactualObligationsValid(finalized, context, comparisonState)) {
|
||
results.push({
|
||
date,
|
||
status: "ineligible",
|
||
reason: counterfactualReason("rule-invalid", "The date would leave an obligation unsatisfied.")
|
||
});
|
||
continue;
|
||
}
|
||
const after = objectiveVector(finalized, context);
|
||
const comparison = objectiveComparison(before, after);
|
||
results.push({ date, status: "eligible", comparison });
|
||
}
|
||
return {
|
||
kind: "Counterfactual",
|
||
calculationId: "",
|
||
placement: { ...selected, ruleIds: [...selected.ruleIds] },
|
||
hypothetical: true,
|
||
baseline: before,
|
||
results
|
||
};
|
||
}
|
||
export {
|
||
invalidInputFor,
|
||
explainIncompleteCheckpoint,
|
||
calculatePlanningInternal,
|
||
calculatePlanningAsyncInternal,
|
||
analyzeCounterfactualInternal
|
||
};
|