fix(planning): prioritize complete holiday weeks

This commit is contained in:
nessar committed 2026-08-30 17:16:30 +02:00
1 parent 5a81e38b95
commit 31314f3969
7 files changed
+355 -25

No files matched your search

+202 -13
View File
@@ -116,6 +116,7 @@ export interface PreferenceResult {
export interface PlanningMetrics {
unplacedAtRisk: number;
holidayWeekValue: number;
nonWorkingDayGain: number;
longestWorkSequence: number;
shortSequenceDeficit: number;
@@ -246,6 +247,7 @@ interface Requirement {
interface SearchContext {
horizon: { start: string; end: string };
holidays: Set<string>;
holidayWeeks: HolidayWeekCandidate[];
calendarDays: string[];
calendarIndex: Map<string, number>;
workingCalendar: Set<string>;
@@ -375,6 +377,54 @@ function isWorking(key: string, holidays: Set<string>): boolean {
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 {
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
}
@@ -1762,6 +1812,7 @@ function optimizeMovablePlacements(
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
if (
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some(
@@ -1770,19 +1821,24 @@ function optimizeMovablePlacements(
)
continue;
const better =
stage === 'non-working-day-gain'
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
: stage === 'longest-work-sequence'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
: stage === 'short-sequence-deficit'
metrics.holidayWeekValue > bestMetrics.holidayWeekValue ||
(metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
(stage === 'non-working-day-gain'
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
: stage === 'longest-work-sequence'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence === bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence === bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit &&
date < bestDate;
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
: stage === 'short-sequence-deficit'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit ===
bestMetrics.shortSequenceDeficit &&
date < bestDate));
if (better) {
best = feasibleCandidate;
bestMetrics = metrics;
@@ -1794,9 +1850,129 @@ function optimizeMovablePlacements(
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 {
return (
a.unplacedAtRisk === b.unplacedAtRisk &&
a.holidayWeekValue === b.holidayWeekValue &&
a.nonWorkingDayGain === b.nonWorkingDayGain &&
a.longestWorkSequence === b.longestWorkSequence &&
a.shortSequenceDeficit === b.shortSequenceDeficit &&
@@ -1987,6 +2163,7 @@ function qualityMetrics(
const unplacedAtRisk = totalRisk - atRisk;
return {
unplacedAtRisk,
holidayWeekValue: holidayWeekValue(state, context),
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
longestWorkSequence: sequences.length ? Math.max(...sequences) : 0,
shortSequenceDeficit,
@@ -2103,12 +2280,14 @@ function supportingPlacements(
metrics.preferenceSatisfaction.every(
(value, index) => value === before.preferenceSatisfaction[index],
) &&
metrics.holidayWeekValue >= before.holidayWeekValue &&
metrics.nonWorkingDayGain >= before.nonWorkingDayGain &&
metrics.longestWorkSequence <= before.longestWorkSequence &&
metrics.shortSequenceDeficit <= before.shortSequenceDeficit;
const useful =
preservesHigherMetrics &&
(metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
(metrics.holidayWeekValue > before.holidayWeekValue ||
metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
metrics.longestWorkSequence < before.longestWorkSequence ||
metrics.shortSequenceDeficit < before.shortSequenceDeficit);
if (!useful) continue;
@@ -2143,6 +2322,7 @@ function pruneRedundantSupportingPlacements(
const metrics = qualityMetrics(candidate, context, preferences);
if (
metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
metrics.holidayWeekValue >= baseline.holidayWeekValue &&
metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
@@ -2158,6 +2338,7 @@ function pruneRedundantSupportingPlacements(
function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number {
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)
return b.nonWorkingDayGain - a.nonWorkingDayGain;
if (a.longestWorkSequence !== b.longestWorkSequence)
@@ -2205,6 +2386,7 @@ export function calculatePlanningInternal(
const context = {
horizon,
holidays,
holidayWeeks: [],
calendarDays,
calendarIndex,
workingCalendar,
@@ -2223,6 +2405,7 @@ export function calculatePlanningInternal(
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
} as SearchContext;
context.holidayWeeks = holidayWeekCandidates(context);
context.check = () => {
context.nodes++;
if (context.deadline <= 1 || context.nodes % 16 === 0) {
@@ -2371,6 +2554,12 @@ export function calculatePlanningInternal(
checkpoint = makePlan(state, context, preferences, stages);
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');
context.check();
state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain');