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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user