309 lines
9.9 KiB
TypeScript
309 lines
9.9 KiB
TypeScript
export type OptimizationElements =
|
|
| 'leave-balances'
|
|
| 'public-holiday-weeks'
|
|
| 'time-off-periods'
|
|
| 'work-sequences'
|
|
| 'leave-placements';
|
|
export type OptimizationCondition =
|
|
| 'at-risk'
|
|
| 'complete'
|
|
| 'internal'
|
|
| 'non-empty'
|
|
| 'supporting';
|
|
export type OptimizationMeasure =
|
|
| 'unplaced-quantity'
|
|
| 'public-holiday-week-value'
|
|
| 'non-working-day-gain'
|
|
| 'work-sequence-length'
|
|
| 'work-sequence-deficit'
|
|
| 'placement-count';
|
|
export type OptimizationAggregation = 'sum' | 'count' | 'maximum';
|
|
export type OptimizationDirection = 'minimize' | 'maximize';
|
|
export type OptimizationUnit = 'working-days' | 'calendar-days' | 'count';
|
|
|
|
interface OptimizationConfigurationBase {
|
|
elements: OptimizationElements;
|
|
conditions: readonly OptimizationCondition[];
|
|
aggregation: OptimizationAggregation;
|
|
direction: OptimizationDirection;
|
|
}
|
|
|
|
export type OptimizationConfiguration = OptimizationConfigurationBase &
|
|
(
|
|
| { measure: 'work-sequence-deficit'; thresholdDays: number }
|
|
| { measure: Exclude<OptimizationMeasure, 'work-sequence-deficit'>; thresholdDays?: never }
|
|
);
|
|
|
|
/** Each row accepts any subset of its conditions and both comparison directions. */
|
|
export const OPTIMIZATION_CATALOG = [
|
|
{
|
|
elements: 'leave-balances',
|
|
measure: 'unplaced-quantity',
|
|
conditions: ['at-risk'],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'working-days',
|
|
},
|
|
{
|
|
elements: 'public-holiday-weeks',
|
|
measure: 'public-holiday-week-value',
|
|
conditions: ['complete'],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'calendar-days',
|
|
},
|
|
{
|
|
elements: 'time-off-periods',
|
|
measure: 'non-working-day-gain',
|
|
conditions: [],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'calendar-days',
|
|
},
|
|
{
|
|
elements: 'work-sequences',
|
|
measure: 'work-sequence-length',
|
|
conditions: ['internal', 'non-empty'],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'working-days',
|
|
},
|
|
{
|
|
elements: 'work-sequences',
|
|
measure: 'work-sequence-deficit',
|
|
conditions: ['internal', 'non-empty'],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'working-days',
|
|
},
|
|
{
|
|
elements: 'leave-placements',
|
|
measure: 'placement-count',
|
|
conditions: ['supporting'],
|
|
aggregations: ['sum', 'count', 'maximum'],
|
|
unit: 'count',
|
|
},
|
|
] as const;
|
|
|
|
export type OptimizationValidation =
|
|
| { valid: true; configuration: OptimizationConfiguration }
|
|
| {
|
|
valid: false;
|
|
code: 'invalid-optimization-configuration' | 'unsupported-optimization-configuration';
|
|
message: string;
|
|
};
|
|
|
|
export function validateOptimizationConfiguration(value: unknown): OptimizationValidation {
|
|
const invalid = (message: string): OptimizationValidation => ({
|
|
valid: false,
|
|
code: 'invalid-optimization-configuration',
|
|
message,
|
|
});
|
|
const unsupported = (message: string): OptimizationValidation => ({
|
|
valid: false,
|
|
code: 'unsupported-optimization-configuration',
|
|
message,
|
|
});
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return invalid('An optimization configuration must be an object.');
|
|
const config = value as Record<string, unknown>;
|
|
const fields = [
|
|
'elements',
|
|
'conditions',
|
|
'measure',
|
|
'aggregation',
|
|
'direction',
|
|
'thresholdDays',
|
|
];
|
|
if (Object.keys(config).some((key) => !fields.includes(key)))
|
|
return unsupported('The optimization configuration contains an unsupported field.');
|
|
if (config['direction'] !== 'minimize' && config['direction'] !== 'maximize')
|
|
return invalid('The optimization direction must be minimize or maximize.');
|
|
if (
|
|
!Array.isArray(config['conditions']) ||
|
|
[...config['conditions']].some((condition) => typeof condition !== 'string') ||
|
|
new Set(config['conditions']).size !== config['conditions'].length
|
|
)
|
|
return invalid('Optimization conditions must be a list of distinct conditions.');
|
|
const row = OPTIMIZATION_CATALOG.find(
|
|
(item) => item.elements === config['elements'] && item.measure === config['measure'],
|
|
);
|
|
if (!row) return unsupported('The planning elements and measure are not compatible.');
|
|
const conditions: readonly string[] = row.conditions;
|
|
if (config['conditions'].some((condition) => !conditions.includes(condition)))
|
|
return unsupported('A condition is not supported for these planning elements.');
|
|
const aggregations: readonly string[] = row.aggregations;
|
|
if (typeof config['aggregation'] !== 'string' || !aggregations.includes(config['aggregation']))
|
|
return unsupported('The aggregation is not supported for this measure.');
|
|
if (config['measure'] === 'work-sequence-deficit') {
|
|
if (!Number.isSafeInteger(config['thresholdDays']) || Number(config['thresholdDays']) <= 0)
|
|
return invalid('The deficit threshold must be a positive integer in working days.');
|
|
} else if ('thresholdDays' in config)
|
|
return unsupported('Only the work-sequence deficit accepts a working-day threshold.');
|
|
return { valid: true, configuration: value as OptimizationConfiguration };
|
|
}
|
|
|
|
export function optimizationUnit(configuration: OptimizationConfiguration): OptimizationUnit {
|
|
return configuration.aggregation === 'count'
|
|
? 'count'
|
|
: OPTIMIZATION_CATALOG.find(
|
|
(row) =>
|
|
row.elements === configuration.elements &&
|
|
row.measure === configuration.measure,
|
|
)!.unit;
|
|
}
|
|
|
|
export const OPTIMIZATION_PRESETS = {
|
|
unplacedAtRisk: {
|
|
elements: 'leave-balances',
|
|
conditions: ['at-risk'],
|
|
measure: 'unplaced-quantity',
|
|
aggregation: 'sum',
|
|
direction: 'minimize',
|
|
},
|
|
holidayWeekValue: {
|
|
elements: 'public-holiday-weeks',
|
|
conditions: ['complete'],
|
|
measure: 'public-holiday-week-value',
|
|
aggregation: 'sum',
|
|
direction: 'maximize',
|
|
},
|
|
nonWorkingDayGain: {
|
|
elements: 'time-off-periods',
|
|
conditions: [],
|
|
measure: 'non-working-day-gain',
|
|
aggregation: 'sum',
|
|
direction: 'maximize',
|
|
},
|
|
longestWorkSequence: {
|
|
elements: 'work-sequences',
|
|
conditions: [],
|
|
measure: 'work-sequence-length',
|
|
aggregation: 'maximum',
|
|
direction: 'minimize',
|
|
},
|
|
shortSequenceDeficit: {
|
|
elements: 'work-sequences',
|
|
conditions: ['internal', 'non-empty'],
|
|
measure: 'work-sequence-deficit',
|
|
thresholdDays: 5,
|
|
aggregation: 'sum',
|
|
direction: 'minimize',
|
|
},
|
|
supportingPlacements: {
|
|
elements: 'leave-placements',
|
|
conditions: ['supporting'],
|
|
measure: 'placement-count',
|
|
aggregation: 'count',
|
|
direction: 'minimize',
|
|
},
|
|
} as const satisfies Record<string, OptimizationConfiguration>;
|
|
|
|
/** Concrete observations from one plan; they carry no rule identity or display name. */
|
|
export interface OptimizationObservations {
|
|
leaveBalances: readonly { unplacedQuantity: number; atRisk: boolean }[];
|
|
publicHolidayWeeks: readonly { value: number; complete: boolean }[];
|
|
timeOffPeriods: readonly { nonWorkingDates: readonly string[] }[];
|
|
workSequences: readonly { length: number; internal: boolean }[];
|
|
leavePlacements: readonly { supporting: boolean }[];
|
|
}
|
|
|
|
// Calendar geometry reuses frozen date arrays across candidate plans.
|
|
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
|
|
|
|
export function evaluateOptimization(
|
|
configuration: OptimizationConfiguration,
|
|
observations: OptimizationObservations,
|
|
): number {
|
|
const validation = validateOptimizationConfiguration(configuration);
|
|
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
|
|
const selected = (condition: OptimizationCondition) =>
|
|
configuration.conditions.includes(condition);
|
|
let values: number[];
|
|
switch (configuration.elements) {
|
|
case 'leave-balances':
|
|
values = observations.leaveBalances
|
|
.filter((balance) => !selected('at-risk') || balance.atRisk)
|
|
.map((balance) => balance.unplacedQuantity);
|
|
break;
|
|
case 'public-holiday-weeks':
|
|
values = observations.publicHolidayWeeks
|
|
.filter((week) => !selected('complete') || week.complete)
|
|
.map((week) => week.value);
|
|
break;
|
|
case 'time-off-periods':
|
|
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
|
|
const cached = nonWorkingDateCounts.get(nonWorkingDates);
|
|
if (cached !== undefined) return cached;
|
|
const count = new Set(nonWorkingDates).size;
|
|
if (Object.isFrozen(nonWorkingDates))
|
|
nonWorkingDateCounts.set(nonWorkingDates, count);
|
|
return count;
|
|
});
|
|
break;
|
|
case 'work-sequences':
|
|
values = observations.workSequences
|
|
.filter(
|
|
(sequence) =>
|
|
(!selected('internal') || sequence.internal) &&
|
|
(!selected('non-empty') || sequence.length > 0),
|
|
)
|
|
.map((sequence) =>
|
|
configuration.measure === 'work-sequence-deficit'
|
|
? sequence.length > 0
|
|
? Math.max(0, configuration.thresholdDays - sequence.length)
|
|
: 0
|
|
: sequence.length,
|
|
);
|
|
break;
|
|
case 'leave-placements':
|
|
values = observations.leavePlacements
|
|
.filter((placement) => !selected('supporting') || placement.supporting)
|
|
.map(() => 1);
|
|
}
|
|
switch (configuration.aggregation) {
|
|
case 'count':
|
|
return values.length;
|
|
case 'sum':
|
|
return values.reduce((total, value) => total + value, 0);
|
|
case 'maximum':
|
|
return values.reduce((maximum, value) => Math.max(maximum, value), 0);
|
|
}
|
|
}
|
|
|
|
export interface ComparableObjective {
|
|
id: string;
|
|
direction: OptimizationDirection;
|
|
value: number | string;
|
|
}
|
|
|
|
/** Negative means the left candidate is preferred. Strings retain lexical tie-break ordering. */
|
|
export function compareObjectiveValues(
|
|
direction: OptimizationDirection,
|
|
left: number | string,
|
|
right: number | string,
|
|
): number {
|
|
if (left === right) return 0;
|
|
const difference =
|
|
typeof left === 'number' && typeof right === 'number'
|
|
? left - right
|
|
: left < right
|
|
? -1
|
|
: left > right
|
|
? 1
|
|
: 0;
|
|
return direction === 'minimize' ? difference : -difference;
|
|
}
|
|
|
|
export function firstDifferentObjective(
|
|
left: readonly ComparableObjective[],
|
|
right: readonly ComparableObjective[],
|
|
): number {
|
|
return left.findIndex((objective, index) => objective.value !== right[index]?.value);
|
|
}
|
|
|
|
export function compareObjectiveLists(
|
|
left: readonly ComparableObjective[],
|
|
right: readonly ComparableObjective[],
|
|
): number {
|
|
const index = firstDifferentObjective(left, right);
|
|
return index < 0
|
|
? 0
|
|
: compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
|
|
}
|