This commit is contained in:
nessar committed 2026-10-07 00:28:29 +02:00
1 parent 03841c6e3d
commit 036a8c0e1e
32 files changed
+1805 -89

No files matched your search

+102 -25
View File
@@ -1,5 +1,10 @@
import Holidays from 'date-holidays';
import {
DeclaredNonWorkingDay,
parseDeclaredDays,
frenchPublicHolidaysForYear,
} from './declared-non-working-days';
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
export type { DeclaredNonWorkingDay } from './declared-non-working-days';
/** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */
export type PlanningDate = string | Date;
@@ -55,6 +60,7 @@ export interface PlanningRequest {
horizon: PlanningHorizon;
balances: readonly LeaveBalanceInput[];
rules: readonly PlanningRule[];
declaredDays?: readonly DeclaredNonWorkingDay[];
/** Optional caller-owned display identities copied into a calculation snapshot. */
identities?: unknown;
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
@@ -186,6 +192,7 @@ export interface PlacementExplanation {
export interface PlanExplanation {
kind: 'PlanExplanation';
declaredDays?: readonly DeclaredNonWorkingDay[];
placements: readonly PlacementExplanation[];
/** Present when the explained plan is the incumbent of an interrupted calculation. */
incomplete?: IncompleteExplanation;
@@ -207,6 +214,7 @@ export interface IncompleteExplanation {
export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation;
export interface PlanningSnapshot {
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: Readonly<{ start: string; end: string }>;
balances: readonly Readonly<LeaveBalanceInput>[];
rules: readonly Readonly<PlanningRule>[];
@@ -222,6 +230,7 @@ export type TraceCandidateStatus =
| 'ineligible';
export interface PlanningTraceState {
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: Readonly<{ start: string; end: string }>;
balances: readonly Readonly<{
id: string;
@@ -361,6 +370,7 @@ export interface PlanningArtifactTimings {
export type CounterfactualReasonCode =
| 'non-working'
| 'declared-day'
| 'occupied'
| 'unavailable'
| 'expired'
@@ -424,6 +434,7 @@ export interface PlanningMetrics {
export interface PlanOutcome {
kind: 'Plan';
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: { start: string; end: string };
placements: LeavePlacement[];
timeOffPeriods: TimeOffPeriod[];
@@ -502,6 +513,7 @@ interface State {
interface RuntimePlanningRequest {
horizon?: unknown;
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -515,6 +527,7 @@ interface RuntimeRecord {
start?: unknown;
end?: unknown;
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -600,11 +613,17 @@ class TraceRecorder {
private readonly workingDays: ReadonlySet<string>,
private readonly streaming: boolean,
) {
const declaredDates = new Set(initialState.declaredDays?.map(({ date }) => date));
this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end)
.filter((date) => !workingDays.has(date))
.filter((date) => !workingDays.has(date) || declaredDates.has(date))
.map((date) => ({
date,
reason: reason('non-working', 'The date is a weekend or public holiday.'),
reason: declaredDates.has(date)
? reason(
'declared-day',
'The date is a declared non-working day fixed by the user.',
)
: reason('non-working', 'The date is a weekend or public holiday.'),
}));
this.previousBalances = new Map(
initialState.balances.map((balance) => [balance.id, { ...balance }]),
@@ -723,6 +742,13 @@ class TraceRecorder {
? undefined
: {
horizon: { ...this.initialState.horizon },
...(this.initialState.declaredDays
? {
declaredDays: this.initialState.declaredDays.map((day) => ({
...day,
})),
}
: {}),
balances: this.initialState.balances.map((balance) => ({ ...balance })),
placements: this.initialState.placements.map((placement) => ({
...placement,
@@ -1080,6 +1106,8 @@ class TraceRecorder {
}
interface SearchContext {
declaredDays: readonly DeclaredNonWorkingDay[];
declaredDates: ReadonlySet<string>;
horizon: { start: string; end: string };
holidays: Set<string>;
holidayWeeks: HolidayWeekCandidate[];
@@ -1243,7 +1271,6 @@ const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
const holidaysByYear = new Map<number, Set<string>>();
function dateKey(value: unknown): string | null {
if (typeof value === 'string') {
@@ -1265,6 +1292,7 @@ function runtimeRequest(value: unknown): RuntimePlanningRequest {
return {
horizon: source.horizon,
balances: source.balances,
declaredDays: source.declaredDays,
rules: source.rules,
identities: source.identities,
labels: source.labels,
@@ -1306,26 +1334,12 @@ function defaultHorizon(now = new Date()): { start: string; end: string } {
return { start, end: addPlanningYears(start, 1) };
}
function holidaysForYear(year: number): Set<string> {
const cached = holidaysByYear.get(year);
if (cached) return new Set(cached);
const calendar = new Holidays('FR', { languages: ['en'] });
const holidays = new Set<string>();
for (const holiday of calendar.getHolidays(year, 'en')) {
if (holiday.type !== 'public') continue;
const key = dateKey(holiday.date);
if (key) holidays.add(key);
}
holidaysByYear.set(year, holidays);
return new Set(holidays);
}
function holidaySet(horizon: { start: string; end: string }): Set<string> {
const first = Number(horizon.start.slice(0, 4)) - 1;
const last = Number(horizon.end.slice(0, 4)) + 1;
const result = new Set<string>();
for (let year = first; year <= last; year++) {
for (const holiday of holidaysForYear(year)) result.add(holiday);
for (const holiday of frenchPublicHolidaysForYear(year)) result.add(holiday);
}
return result;
}
@@ -1413,9 +1427,11 @@ function cloneState(state: State): State {
function planningTraceState(
state: State,
horizon: { start: string; end: string },
declaredDays: readonly DeclaredNonWorkingDay[] = [],
): PlanningTraceState {
return {
horizon,
...(declaredDays.length ? { declaredDays } : {}),
balances: state.balances.map((balance) => ({ ...balance })),
placements: [...state.placements.values()]
.sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))
@@ -1634,7 +1650,7 @@ function retainAndRecordState(
acceptedCandidate?: MutableTraceCandidate,
): void {
observeTrace(context, (trace) => {
const snapshot = planningTraceState(state, context.horizon);
const snapshot = planningTraceState(state, context.horizon, context.declaredDays);
if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
trace.recordPlan(
snapshot.placements,
@@ -1893,6 +1909,7 @@ function normalizeRules(request: RuntimePlanningRequest): {
}
function normalizeRequest(request: unknown): {
declaredDays: DeclaredNonWorkingDay[];
horizon?: { start: string; end: string };
balances: NormalizedBalance[];
rules: NormalizedRule[];
@@ -1930,6 +1947,16 @@ function normalizeRequest(request: unknown): {
reason('deadline', 'The planning deadline must be a finite non-negative number.'),
);
}
let declaredDays: DeclaredNonWorkingDay[] = [];
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);
@@ -1938,6 +1965,7 @@ function normalizeRequest(request: unknown): {
? undefined
: { start, end },
balances: balanceResult.balances,
declaredDays,
rules: ruleResult.rules,
reasons,
};
@@ -2168,6 +2196,17 @@ function assignDate(
);
}
function rejectDeclaredDate(context: SearchContext, date: string): boolean {
if (!context.declaredDates.has(date)) return false;
recordTraceIneligible(
context,
date,
'declared-day',
'The date is a declared non-working day fixed by the user.',
);
return true;
}
function assignDateVariants(
state: State,
date: string,
@@ -2178,6 +2217,7 @@ function assignDateVariants(
selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
): State[] {
context.check();
if (rejectDeclaredDate(context, date)) return [];
const existing = state.placements.get(date);
if (existing) {
if (!selectorMatches(selector, existing.type)) {
@@ -2338,6 +2378,7 @@ function assignDateToBalance(
>,
): State | null {
context.check();
if (rejectDeclaredDate(context, date)) return null;
if (state.placements.has(date)) {
recordTraceCandidate(context, {
status: 'ineligible',
@@ -2400,6 +2441,7 @@ function assignDateToBalanceInPlace(
>,
): boolean {
context.check();
if (rejectDeclaredDate(context, date)) return false;
if (state.placements.has(date)) {
recordTraceCandidate(context, {
status: 'ineligible',
@@ -4894,6 +4936,7 @@ function planExplanation(
const vector = objectiveVector(metrics, preferences, state, context);
return {
kind: 'PlanExplanation',
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
placements: placements.map((placement) => {
const balance = rawBalances
.map(recordValue)
@@ -5051,6 +5094,7 @@ function makePlan(
const remaining = remainingBalances(state, context);
const plan: PlanOutcome = {
kind: 'Plan',
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
horizon: context.horizon,
placements,
timeOffPeriods: periods,
@@ -5642,7 +5686,7 @@ function* prepareCalendar(
year++
) {
yield true;
for (const holiday of holidaysForYear(year)) holidays.add(holiday);
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
progress();
}
const workingDays: string[] = [];
@@ -5719,6 +5763,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -5744,6 +5791,8 @@ function* calculatePlanningSteps(
workingCalendar,
workingPrefix,
workingDays,
declaredDays: normalized.declaredDays,
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
initialBalances: normalized.balances,
rules: normalized.rules,
deadline,
@@ -5772,6 +5821,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -5893,7 +5945,7 @@ function* calculatePlanningSteps(
observeCallback(context, () =>
checkpointCallback(
makePlan(retained!.state, context, retained!.preferences, stages),
planningTraceState(retained!.state, horizon),
planningTraceState(retained!.state, horizon, context.declaredDays),
),
);
}
@@ -5964,7 +6016,11 @@ function* calculatePlanningSteps(
}
try {
if (explanationState)
finalState = planningTraceState(explanationState, context.horizon);
finalState = planningTraceState(
explanationState,
context.horizon,
context.declaredDays,
);
} catch (error) {
explanationError ??= reason(
'explanation-error',
@@ -6012,6 +6068,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -6079,7 +6138,11 @@ function* calculatePlanningSteps(
let checkpointSnapshot: PlanningTraceState;
const captureStarted = now();
try {
checkpointSnapshot = planningTraceState(checkpointState, context.horizon);
checkpointSnapshot = planningTraceState(
checkpointState,
context.horizon,
context.declaredDays,
);
} finally {
const captureTime = now() - captureStarted;
context.traceTime += captureTime;
@@ -6375,6 +6438,8 @@ export function explainIncompleteCheckpoint(
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,
@@ -6433,7 +6498,7 @@ export function explainIncompleteCheckpoint(
uncompletedStages,
},
},
finalState: planningTraceState(state, horizon),
finalState: planningTraceState(state, horizon, context.declaredDays),
};
} catch (error) {
return {
@@ -6477,6 +6542,8 @@ function counterfactualContext(
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,
@@ -6541,6 +6608,11 @@ export function historicalCounterfactualReasons(
'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',
@@ -6801,6 +6873,11 @@ export function analyzeCounterfactualInternal(
'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',