This commit is contained in:
1 parent
03841c6e3d
commit
036a8c0e1e
32 files changed
+1805
-89
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user