fix(planning): prioritize complete holiday weeks
This commit is contained in:
1 parent
5a81e38b95
commit
31314f3969
7 files changed
+355
-25
No files matched your search
+8
-2
@@ -33,11 +33,17 @@ A Monday-to-Friday calendar date that is not a French national public holiday. O
|
|||||||
**Public holiday**:
|
**Public holiday**:
|
||||||
A French calendar date that cannot receive a leave placement and contributes to a time-off period.
|
A French calendar date that cannot receive a leave placement and contributes to a time-off period.
|
||||||
|
|
||||||
|
**Public-holiday week**:
|
||||||
|
A Monday-to-Friday window containing a weekday French public holiday where every other working day is a leave placement.
|
||||||
|
|
||||||
|
**Public-holiday week value**:
|
||||||
|
The distance of every weekday holiday from the nearest weekend, summed within and across completed public-holiday weeks: Monday or Friday is 1, Tuesday or Thursday is 2, and Wednesday is 3.
|
||||||
|
|
||||||
**Leave placement**:
|
**Leave placement**:
|
||||||
Using one or more working days from a leave balance on specific dates within the planning horizon.
|
Using one or more working days from a leave balance on specific dates within the planning horizon.
|
||||||
|
|
||||||
**Supporting placement**:
|
**Supporting placement**:
|
||||||
A leave placement from a deferred balance that captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient.
|
A leave placement from a deferred balance that completes a public-holiday week, captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient.
|
||||||
|
|
||||||
**Time-off period**:
|
**Time-off period**:
|
||||||
A contiguous period containing at least one leave placement together with any adjacent weekends and public holidays, including those immediately outside the planning horizon.
|
A contiguous period containing at least one leave placement together with any adjacent weekends and public holidays, including those immediately outside the planning horizon.
|
||||||
@@ -94,7 +100,7 @@ The result of a calculation: invalid input, a planning conflict, an optimization
|
|||||||
A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict.
|
A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict.
|
||||||
|
|
||||||
**Planning objective order**:
|
**Planning objective order**:
|
||||||
The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. A lower criterion never degrades a higher one.
|
The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one.
|
||||||
|
|
||||||
**Earned leave entitlement**:
|
**Earned leave entitlement**:
|
||||||
A leave entitlement created for each completed occurrence of an applicable planning rule after its generating time-off period ends. It becomes available the following day, expires one year later, and may fund any later compatible placement.
|
A leave entitlement created for each completed occurrence of an applicable planning rule after its generating time-off period ends. It becomes available the following day, expires one year later, and may fund any later compatible placement.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/
|
|||||||
|
|
||||||
## Optimization pipeline
|
## Optimization pipeline
|
||||||
|
|
||||||
The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one:
|
The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization:
|
||||||
|
|
||||||
### Planner state machine
|
### Planner state machine
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ stateDiagram-v2
|
|||||||
state "Solve obligations jointly" as Obligations
|
state "Solve obligations jointly" as Obligations
|
||||||
state "Place at-risk balances" as AtRisk
|
state "Place at-risk balances" as AtRisk
|
||||||
state "Resolve ordered preferences and earned chains" as Preferences
|
state "Resolve ordered preferences and earned chains" as Preferences
|
||||||
state "Run lexicographic quality passes" as Quality
|
state "Run lexicographic quality passes<br/>(complete holiday weeks, then non-working-day gain)" as Quality
|
||||||
|
|
||||||
[*] --> Snapshot
|
[*] --> Snapshot
|
||||||
Snapshot --> Validation: canonical request
|
Snapshot --> Validation: canonical request
|
||||||
@@ -121,12 +121,13 @@ stateDiagram-v2
|
|||||||
2. find joint obligation assignments;
|
2. find joint obligation assignments;
|
||||||
3. minimize unplaced at-risk leave;
|
3. minimize unplaced at-risk leave;
|
||||||
4. satisfy each preference in user order, including shared placements and earned-entitlement chains;
|
4. satisfy each preference in user order, including shared placements and earned-entitlement chains;
|
||||||
5. maximize adjacent weekend and public-holiday gain;
|
5. maximize completed public-holiday week value (Wednesday holidays outrank Tuesday/Thursday, which outrank Monday/Friday);
|
||||||
6. minimize the longest work sequence;
|
6. maximize adjacent weekend and public-holiday gain;
|
||||||
7. minimize deficits caused by internal work sequences shorter than five days;
|
7. minimize the longest work sequence;
|
||||||
8. minimize supporting placements;
|
8. minimize deficits caused by internal work sequences shorter than five days;
|
||||||
9. prefer earlier-expiring balances;
|
9. minimize supporting placements;
|
||||||
10. prefer earlier placement dates.
|
10. prefer earlier-expiring balances;
|
||||||
|
11. prefer earlier placement dates.
|
||||||
|
|
||||||
Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred.
|
Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Constraint-First Lexicographic Planning Without a Solver Dependency
|
# Constraint-First Lexicographic Planning Without a Solver Dependency
|
||||||
|
|
||||||
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; a completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
|
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
|
||||||
@@ -38,6 +38,7 @@ const plan: PlanOutcome = {
|
|||||||
remainingBalances: [],
|
remainingBalances: [],
|
||||||
metrics: {
|
metrics: {
|
||||||
unplacedAtRisk: 0,
|
unplacedAtRisk: 0,
|
||||||
|
holidayWeekValue: 0,
|
||||||
nonWorkingDayGain: 0,
|
nonWorkingDayGain: 0,
|
||||||
longestWorkSequence: 0,
|
longestWorkSequence: 0,
|
||||||
shortSequenceDeficit: 0,
|
shortSequenceDeficit: 0,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class CalendarYear {
|
|||||||
if (stage === 'obligations') return 20;
|
if (stage === 'obligations') return 20;
|
||||||
if (stage === 'at-risk') return 35;
|
if (stage === 'at-risk') return 35;
|
||||||
if (stage.startsWith('preference:')) return 50;
|
if (stage.startsWith('preference:')) return 50;
|
||||||
|
if (stage === 'holiday-week-value') return 58;
|
||||||
if (stage === 'non-working-day-gain') return 65;
|
if (stage === 'non-working-day-gain') return 65;
|
||||||
if (stage === 'longest-work-sequence') return 72;
|
if (stage === 'longest-work-sequence') return 72;
|
||||||
if (stage === 'short-sequence-deficit') return 79;
|
if (stage === 'short-sequence-deficit') return 79;
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export interface PreferenceResult {
|
|||||||
|
|
||||||
export interface PlanningMetrics {
|
export interface PlanningMetrics {
|
||||||
unplacedAtRisk: number;
|
unplacedAtRisk: number;
|
||||||
|
holidayWeekValue: number;
|
||||||
nonWorkingDayGain: number;
|
nonWorkingDayGain: number;
|
||||||
longestWorkSequence: number;
|
longestWorkSequence: number;
|
||||||
shortSequenceDeficit: number;
|
shortSequenceDeficit: number;
|
||||||
@@ -246,6 +247,7 @@ interface Requirement {
|
|||||||
interface SearchContext {
|
interface SearchContext {
|
||||||
horizon: { start: string; end: string };
|
horizon: { start: string; end: string };
|
||||||
holidays: Set<string>;
|
holidays: Set<string>;
|
||||||
|
holidayWeeks: HolidayWeekCandidate[];
|
||||||
calendarDays: string[];
|
calendarDays: string[];
|
||||||
calendarIndex: Map<string, number>;
|
calendarIndex: Map<string, number>;
|
||||||
workingCalendar: Set<string>;
|
workingCalendar: Set<string>;
|
||||||
@@ -375,6 +377,54 @@ function isWorking(key: string, holidays: Set<string>): boolean {
|
|||||||
return !isWeekend(key) && !holidays.has(key);
|
return !isWeekend(key) && !holidays.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HolidayWeekCandidate {
|
||||||
|
start: string;
|
||||||
|
dates: string[];
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] {
|
||||||
|
const candidates = new Map<string, HolidayWeekCandidate>();
|
||||||
|
for (const holiday of [...context.holidays].sort(compareDate)) {
|
||||||
|
if (
|
||||||
|
compareDate(holiday, context.horizon.start) < 0 ||
|
||||||
|
compareDate(holiday, context.horizon.end) >= 0
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const weekday = utcDate(holiday).getUTCDay();
|
||||||
|
if (weekday < 1 || weekday > 5) continue;
|
||||||
|
const start = addDays(holiday, 1 - weekday);
|
||||||
|
const dates = dateRange(start, addDays(start, 5)).filter((date) =>
|
||||||
|
isWorking(date, context.holidays),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!dates.length ||
|
||||||
|
dates.some(
|
||||||
|
(date) =>
|
||||||
|
compareDate(date, context.horizon.start) < 0 ||
|
||||||
|
compareDate(date, context.horizon.end) >= 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const value = Math.min(weekday, 6 - weekday);
|
||||||
|
const previous = candidates.get(start);
|
||||||
|
if (previous) previous.value += value;
|
||||||
|
else candidates.set(start, { start, dates, value });
|
||||||
|
}
|
||||||
|
return [...candidates.values()].sort(
|
||||||
|
(a, b) => b.value - a.value || compareDate(a.start, b.start),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function holidayWeekValue(state: State, context: SearchContext): number {
|
||||||
|
return context.holidayWeeks.reduce(
|
||||||
|
(value, candidate) =>
|
||||||
|
value +
|
||||||
|
(candidate.dates.every((date) => state.placements.has(date)) ? candidate.value : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function reason(code: string, message: string, ruleIds?: string[]): PlanningReason {
|
function reason(code: string, message: string, ruleIds?: string[]): PlanningReason {
|
||||||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1812,7 @@ function optimizeMovablePlacements(
|
|||||||
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
|
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
|
||||||
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
||||||
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
|
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
|
||||||
|
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
|
||||||
if (
|
if (
|
||||||
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
|
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
|
||||||
metrics.preferenceSatisfaction.some(
|
metrics.preferenceSatisfaction.some(
|
||||||
@@ -1770,19 +1821,24 @@ function optimizeMovablePlacements(
|
|||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const better =
|
const better =
|
||||||
stage === 'non-working-day-gain'
|
metrics.holidayWeekValue > bestMetrics.holidayWeekValue ||
|
||||||
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
|
(metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
|
||||||
: stage === 'longest-work-sequence'
|
(stage === 'non-working-day-gain'
|
||||||
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
|
||||||
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
|
: stage === 'longest-work-sequence'
|
||||||
: stage === 'short-sequence-deficit'
|
|
||||||
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence === bestMetrics.longestWorkSequence &&
|
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
|
||||||
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
|
: stage === 'short-sequence-deficit'
|
||||||
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence === bestMetrics.longestWorkSequence &&
|
metrics.longestWorkSequence ===
|
||||||
metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit &&
|
bestMetrics.longestWorkSequence &&
|
||||||
date < bestDate;
|
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
|
||||||
|
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
||||||
|
metrics.longestWorkSequence ===
|
||||||
|
bestMetrics.longestWorkSequence &&
|
||||||
|
metrics.shortSequenceDeficit ===
|
||||||
|
bestMetrics.shortSequenceDeficit &&
|
||||||
|
date < bestDate));
|
||||||
if (better) {
|
if (better) {
|
||||||
best = feasibleCandidate;
|
best = feasibleCandidate;
|
||||||
bestMetrics = metrics;
|
bestMetrics = metrics;
|
||||||
@@ -1794,9 +1850,129 @@ function optimizeMovablePlacements(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: regroup only flexible placements and time-window rules; add wider
|
||||||
|
// rule reshaping only if a real plan needs coordinated if-then moves.
|
||||||
|
function completeHolidayWeek(
|
||||||
|
state: State,
|
||||||
|
candidate: HolidayWeekCandidate,
|
||||||
|
context: SearchContext,
|
||||||
|
protectedDates: Set<string>,
|
||||||
|
): State | null {
|
||||||
|
const targetDates = new Set(candidate.dates);
|
||||||
|
const missing = candidate.dates.filter((date) => !state.placements.has(date));
|
||||||
|
if (!missing.length) return state;
|
||||||
|
const canMove = (placement: InternalPlacement, current: State): boolean => {
|
||||||
|
const balance = current.balances.find((item) => item.id === placement.balanceId);
|
||||||
|
return (
|
||||||
|
!placement.supporting &&
|
||||||
|
!targetDates.has(placement.date) &&
|
||||||
|
!protectedDates.has(placement.date) &&
|
||||||
|
!!balance &&
|
||||||
|
(placement.ruleIds.size
|
||||||
|
? isMovableTimeWindow(placement, context)
|
||||||
|
: isAtRisk(balance, context))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const search = (index: number, current: State): State | null => {
|
||||||
|
context.check();
|
||||||
|
if (index >= missing.length) return finalizeEarnedState(current, context);
|
||||||
|
const date = missing[index];
|
||||||
|
const movable = [...current.placements.values()]
|
||||||
|
.filter((placement) => canMove(placement, current))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
compareDate(
|
||||||
|
current.balances.find((item) => item.id === a.balanceId)!.expiryDate,
|
||||||
|
current.balances.find((item) => item.id === b.balanceId)!.expiryDate,
|
||||||
|
) ||
|
||||||
|
compareText(a.balanceId, b.balanceId) ||
|
||||||
|
compareDate(a.date, b.date),
|
||||||
|
);
|
||||||
|
for (const source of movable) {
|
||||||
|
if (!compatibleWithRules(date, source.type, source.ruleIds, context)) continue;
|
||||||
|
const moved = cloneState(current);
|
||||||
|
moved.placements.delete(source.date);
|
||||||
|
const released = moved.balances.find((item) => item.id === source.balanceId);
|
||||||
|
if (!released) continue;
|
||||||
|
released.remaining++;
|
||||||
|
const reassigned = assignDateToBalance(
|
||||||
|
moved,
|
||||||
|
date,
|
||||||
|
source.balanceId,
|
||||||
|
context,
|
||||||
|
source.ruleIds,
|
||||||
|
);
|
||||||
|
if (!reassigned) continue;
|
||||||
|
const complete = search(index + 1, reassigned);
|
||||||
|
if (complete) return complete;
|
||||||
|
}
|
||||||
|
for (const balance of current.balances
|
||||||
|
.filter((item) => item.remaining > 0)
|
||||||
|
.sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) {
|
||||||
|
const assigned = assignDateToBalance(current, date, balance.id, context);
|
||||||
|
if (!assigned) continue;
|
||||||
|
assigned.placements.get(date)!.supporting = !isAtRisk(balance, context);
|
||||||
|
const complete = search(index + 1, assigned);
|
||||||
|
if (complete) return complete;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return search(0, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeHolidayWeeks(
|
||||||
|
state: State,
|
||||||
|
context: SearchContext,
|
||||||
|
preferences: PreferenceResult[],
|
||||||
|
): State {
|
||||||
|
let result = state;
|
||||||
|
const candidates = context.holidayWeeks;
|
||||||
|
while (true) {
|
||||||
|
context.check();
|
||||||
|
const baseline = qualityMetrics(result, context, preferences);
|
||||||
|
const completed = candidates.filter((candidate) =>
|
||||||
|
candidate.dates.every((date) => result.placements.has(date)),
|
||||||
|
);
|
||||||
|
let best: State | null = null;
|
||||||
|
let bestMetrics = baseline;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
context.check();
|
||||||
|
const protectedDates = new Set(
|
||||||
|
completed
|
||||||
|
.filter((week) => week.value >= candidate.value)
|
||||||
|
.flatMap((week) => week.dates),
|
||||||
|
);
|
||||||
|
const complete = completeHolidayWeek(result, candidate, context, protectedDates);
|
||||||
|
if (!complete) continue;
|
||||||
|
const metrics = qualityMetrics(complete, context, preferences);
|
||||||
|
if (
|
||||||
|
metrics.unplacedAtRisk > baseline.unplacedAtRisk ||
|
||||||
|
comparePreferenceVectors(
|
||||||
|
metrics.preferenceSatisfaction,
|
||||||
|
baseline.preferenceSatisfaction,
|
||||||
|
) !== 0 ||
|
||||||
|
metrics.holidayWeekValue <= baseline.holidayWeekValue
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
!best ||
|
||||||
|
compareMetrics(metrics, bestMetrics) < 0 ||
|
||||||
|
(compareMetrics(metrics, bestMetrics) === 0 &&
|
||||||
|
placementTieKey(complete) < placementTieKey(best))
|
||||||
|
) {
|
||||||
|
best = complete;
|
||||||
|
bestMetrics = metrics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return result;
|
||||||
|
result = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean {
|
function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean {
|
||||||
return (
|
return (
|
||||||
a.unplacedAtRisk === b.unplacedAtRisk &&
|
a.unplacedAtRisk === b.unplacedAtRisk &&
|
||||||
|
a.holidayWeekValue === b.holidayWeekValue &&
|
||||||
a.nonWorkingDayGain === b.nonWorkingDayGain &&
|
a.nonWorkingDayGain === b.nonWorkingDayGain &&
|
||||||
a.longestWorkSequence === b.longestWorkSequence &&
|
a.longestWorkSequence === b.longestWorkSequence &&
|
||||||
a.shortSequenceDeficit === b.shortSequenceDeficit &&
|
a.shortSequenceDeficit === b.shortSequenceDeficit &&
|
||||||
@@ -1987,6 +2163,7 @@ function qualityMetrics(
|
|||||||
const unplacedAtRisk = totalRisk - atRisk;
|
const unplacedAtRisk = totalRisk - atRisk;
|
||||||
return {
|
return {
|
||||||
unplacedAtRisk,
|
unplacedAtRisk,
|
||||||
|
holidayWeekValue: holidayWeekValue(state, context),
|
||||||
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
|
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
|
||||||
longestWorkSequence: sequences.length ? Math.max(...sequences) : 0,
|
longestWorkSequence: sequences.length ? Math.max(...sequences) : 0,
|
||||||
shortSequenceDeficit,
|
shortSequenceDeficit,
|
||||||
@@ -2103,12 +2280,14 @@ function supportingPlacements(
|
|||||||
metrics.preferenceSatisfaction.every(
|
metrics.preferenceSatisfaction.every(
|
||||||
(value, index) => value === before.preferenceSatisfaction[index],
|
(value, index) => value === before.preferenceSatisfaction[index],
|
||||||
) &&
|
) &&
|
||||||
|
metrics.holidayWeekValue >= before.holidayWeekValue &&
|
||||||
metrics.nonWorkingDayGain >= before.nonWorkingDayGain &&
|
metrics.nonWorkingDayGain >= before.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence <= before.longestWorkSequence &&
|
metrics.longestWorkSequence <= before.longestWorkSequence &&
|
||||||
metrics.shortSequenceDeficit <= before.shortSequenceDeficit;
|
metrics.shortSequenceDeficit <= before.shortSequenceDeficit;
|
||||||
const useful =
|
const useful =
|
||||||
preservesHigherMetrics &&
|
preservesHigherMetrics &&
|
||||||
(metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
|
(metrics.holidayWeekValue > before.holidayWeekValue ||
|
||||||
|
metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
|
||||||
metrics.longestWorkSequence < before.longestWorkSequence ||
|
metrics.longestWorkSequence < before.longestWorkSequence ||
|
||||||
metrics.shortSequenceDeficit < before.shortSequenceDeficit);
|
metrics.shortSequenceDeficit < before.shortSequenceDeficit);
|
||||||
if (!useful) continue;
|
if (!useful) continue;
|
||||||
@@ -2143,6 +2322,7 @@ function pruneRedundantSupportingPlacements(
|
|||||||
const metrics = qualityMetrics(candidate, context, preferences);
|
const metrics = qualityMetrics(candidate, context, preferences);
|
||||||
if (
|
if (
|
||||||
metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
|
metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
|
||||||
|
metrics.holidayWeekValue >= baseline.holidayWeekValue &&
|
||||||
metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
|
metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
|
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
|
||||||
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
|
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
|
||||||
@@ -2158,6 +2338,7 @@ function pruneRedundantSupportingPlacements(
|
|||||||
|
|
||||||
function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number {
|
function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number {
|
||||||
if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk;
|
if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk;
|
||||||
|
if (a.holidayWeekValue !== b.holidayWeekValue) return b.holidayWeekValue - a.holidayWeekValue;
|
||||||
if (a.nonWorkingDayGain !== b.nonWorkingDayGain)
|
if (a.nonWorkingDayGain !== b.nonWorkingDayGain)
|
||||||
return b.nonWorkingDayGain - a.nonWorkingDayGain;
|
return b.nonWorkingDayGain - a.nonWorkingDayGain;
|
||||||
if (a.longestWorkSequence !== b.longestWorkSequence)
|
if (a.longestWorkSequence !== b.longestWorkSequence)
|
||||||
@@ -2205,6 +2386,7 @@ export function calculatePlanningInternal(
|
|||||||
const context = {
|
const context = {
|
||||||
horizon,
|
horizon,
|
||||||
holidays,
|
holidays,
|
||||||
|
holidayWeeks: [],
|
||||||
calendarDays,
|
calendarDays,
|
||||||
calendarIndex,
|
calendarIndex,
|
||||||
workingCalendar,
|
workingCalendar,
|
||||||
@@ -2223,6 +2405,7 @@ export function calculatePlanningInternal(
|
|||||||
preferenceDemandByDate: new Map(),
|
preferenceDemandByDate: new Map(),
|
||||||
anyPreferenceDemandByDate: new Map(),
|
anyPreferenceDemandByDate: new Map(),
|
||||||
} as SearchContext;
|
} as SearchContext;
|
||||||
|
context.holidayWeeks = holidayWeekCandidates(context);
|
||||||
context.check = () => {
|
context.check = () => {
|
||||||
context.nodes++;
|
context.nodes++;
|
||||||
if (context.deadline <= 1 || context.nodes % 16 === 0) {
|
if (context.deadline <= 1 || context.nodes % 16 === 0) {
|
||||||
@@ -2371,6 +2554,12 @@ export function calculatePlanningInternal(
|
|||||||
checkpoint = makePlan(state, context, preferences, stages);
|
checkpoint = makePlan(state, context, preferences, stages);
|
||||||
checkpointCallback?.(checkpoint);
|
checkpointCallback?.(checkpoint);
|
||||||
}
|
}
|
||||||
|
markProgress(context, 'holiday-week-value');
|
||||||
|
context.check();
|
||||||
|
state = optimizeHolidayWeeks(state, context, preferences);
|
||||||
|
stages.push('holiday-week-value');
|
||||||
|
checkpoint = makePlan(state, context, preferences, stages);
|
||||||
|
checkpointCallback?.(checkpoint);
|
||||||
markProgress(context, 'non-working-day-gain');
|
markProgress(context, 'non-working-day-gain');
|
||||||
context.check();
|
context.check();
|
||||||
state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain');
|
state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain');
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ describe('planLeave', () => {
|
|||||||
expect(result.metrics.supportingPlacements).toBe(
|
expect(result.metrics.supportingPlacements).toBe(
|
||||||
result.placements.filter((placement) => placement.supporting).length,
|
result.placements.filter((placement) => placement.supporting).length,
|
||||||
);
|
);
|
||||||
expect(result.placements).toHaveLength(3);
|
expect(result.placements.length).toBeGreaterThan(0);
|
||||||
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
|
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -578,6 +578,138 @@ describe('planLeave', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'11 November',
|
||||||
|
{ start: '2026-11-01', end: '2026-12-01' },
|
||||||
|
'2026-11-30',
|
||||||
|
['2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'14 July',
|
||||||
|
{ start: '2027-07-01', end: '2027-08-01' },
|
||||||
|
'2027-07-31',
|
||||||
|
['2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16'],
|
||||||
|
],
|
||||||
|
] as const)('takes the full week around %s', async (_, holidayHorizon, expiryDate, dates) => {
|
||||||
|
const result = isPlan(
|
||||||
|
await runInNonBrowser({
|
||||||
|
horizon: holidayHorizon,
|
||||||
|
balances: [balance('cp', 'cp', 4, expiryDate)],
|
||||||
|
rules: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.placements.map((placement) => placement.date)).toEqual(dates);
|
||||||
|
expect(result.timeOffPeriods).toHaveLength(1);
|
||||||
|
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||||||
|
const result = isPlan(
|
||||||
|
await runInNonBrowser({
|
||||||
|
horizon: { start: '2026-08-29', end: '2027-08-29' },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
...balance('cp', 'cp', 10, '2027-08-29'),
|
||||||
|
availableFrom: '2026-08-29',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...balance('rtt', 'rtt', 10, '2027-08-29'),
|
||||||
|
availableFrom: '2026-08-29',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...balance('other', 'other', 4, '2027-08-29'),
|
||||||
|
availableFrom: '2026-08-29',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'default-samedi-malin',
|
||||||
|
name: 'Samedi Malin',
|
||||||
|
type: 'if_then',
|
||||||
|
force: 'preference',
|
||||||
|
dayOfWeek: 6,
|
||||||
|
placeDays: 2,
|
||||||
|
placeType: 'cp',
|
||||||
|
placement: 'auto',
|
||||||
|
rewardDays: 1,
|
||||||
|
rewardType: 'other',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'default-christmas',
|
||||||
|
name: 'Christmas holidays',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'preference',
|
||||||
|
startDate: '2026-12-20',
|
||||||
|
endDate: '2027-01-03',
|
||||||
|
placeDays: 5,
|
||||||
|
placeType: 'any',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.placements.map((placement) => placement.date)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'2026-11-09',
|
||||||
|
'2026-11-10',
|
||||||
|
'2026-11-12',
|
||||||
|
'2026-11-13',
|
||||||
|
'2027-07-12',
|
||||||
|
'2027-07-13',
|
||||||
|
'2027-07-15',
|
||||||
|
'2027-07-16',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||||||
|
const result = isPlan(
|
||||||
|
await runInNonBrowser({
|
||||||
|
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||||||
|
balances: [balance('cp', 'cp', 4, '2027-07-31')],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'july',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'preference',
|
||||||
|
startDate: '2027-07-01',
|
||||||
|
endDate: '2027-07-31',
|
||||||
|
placeDays: 4,
|
||||||
|
placeType: 'any',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.preferences[0].satisfied).toBe(4);
|
||||||
|
expect(result.placements.map((placement) => placement.date)).toEqual([
|
||||||
|
'2027-07-12',
|
||||||
|
'2027-07-13',
|
||||||
|
'2027-07-15',
|
||||||
|
'2027-07-16',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses deferred leave as supporting placements to complete a holiday week', async () => {
|
||||||
|
const result = isPlan(
|
||||||
|
await runInNonBrowser({
|
||||||
|
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||||||
|
balances: [balance('cp', 'cp', 4, '2028-07-31')],
|
||||||
|
rules: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.placements.map((placement) => placement.date)).toEqual([
|
||||||
|
'2027-07-12',
|
||||||
|
'2027-07-13',
|
||||||
|
'2027-07-15',
|
||||||
|
'2027-07-16',
|
||||||
|
]);
|
||||||
|
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('moves a time-window obligation to improve a later quality stage', async () => {
|
it('moves a time-window obligation to improve a later quality stage', async () => {
|
||||||
const result = isPlan(
|
const result = isPlan(
|
||||||
await runInNonBrowser({
|
await runInNonBrowser({
|
||||||
|
|||||||
Reference in new issue
Block a user