dabke 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +6 -3
- package/dist/cpsat/rules/index.d.ts +3 -0
- package/dist/cpsat/rules/index.d.ts.map +1 -1
- package/dist/cpsat/rules/index.js +3 -0
- package/dist/cpsat/rules/index.js.map +1 -1
- package/dist/cpsat/rules/max-days-week.d.ts +44 -0
- package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-days-week.js +95 -0
- package/dist/cpsat/rules/max-days-week.js.map +1 -0
- package/dist/cpsat/rules/min-days-week.d.ts +34 -0
- package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-days-week.js +84 -0
- package/dist/cpsat/rules/min-days-week.js.map +1 -0
- package/dist/cpsat/rules/must-assign.d.ts +49 -0
- package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
- package/dist/cpsat/rules/must-assign.js +86 -0
- package/dist/cpsat/rules/must-assign.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +4 -1
- package/dist/cpsat/rules/registry.d.ts.map +1 -1
- package/dist/cpsat/rules/registry.js +4 -1
- package/dist/cpsat/rules/registry.js.map +1 -1
- package/dist/cpsat/rules/rules.types.d.ts +3 -0
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
- package/dist/cpsat/rules/scope.types.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/schedule/cost.d.ts +204 -0
- package/dist/schedule/cost.d.ts.map +1 -0
- package/dist/schedule/cost.js +187 -0
- package/dist/schedule/cost.js.map +1 -0
- package/dist/schedule/coverage.d.ts +85 -0
- package/dist/schedule/coverage.d.ts.map +1 -0
- package/dist/schedule/coverage.js +33 -0
- package/dist/schedule/coverage.js.map +1 -0
- package/dist/schedule/definition.d.ts +227 -0
- package/dist/schedule/definition.d.ts.map +1 -0
- package/dist/{schedule.js → schedule/definition.js} +9 -673
- package/dist/schedule/definition.js.map +1 -0
- package/dist/schedule/index.d.ts +67 -0
- package/dist/schedule/index.d.ts.map +1 -0
- package/dist/schedule/index.js +69 -0
- package/dist/schedule/index.js.map +1 -0
- package/dist/schedule/rules.d.ts +353 -0
- package/dist/schedule/rules.d.ts.map +1 -0
- package/dist/schedule/rules.js +352 -0
- package/dist/schedule/rules.js.map +1 -0
- package/dist/schedule/shift-patterns.d.ts +34 -0
- package/dist/schedule/shift-patterns.d.ts.map +1 -0
- package/dist/schedule/shift-patterns.js +41 -0
- package/dist/schedule/shift-patterns.js.map +1 -0
- package/dist/schedule/time-periods.d.ts +69 -0
- package/dist/schedule/time-periods.d.ts.map +1 -0
- package/dist/schedule/time-periods.js +91 -0
- package/dist/schedule/time-periods.js.map +1 -0
- package/package.json +4 -9
- package/src/cpsat/rules/index.ts +3 -0
- package/src/cpsat/rules/max-days-week.ts +143 -0
- package/src/cpsat/rules/min-days-week.ts +120 -0
- package/src/cpsat/rules/must-assign.ts +108 -0
- package/src/cpsat/rules/registry.ts +6 -0
- package/src/cpsat/rules/rules.types.ts +3 -0
- package/src/cpsat/rules/scope.types.ts +1 -1
- package/src/index.ts +8 -3
- package/src/schedule/cost.ts +242 -0
- package/src/schedule/coverage.ts +135 -0
- package/src/schedule/definition.ts +958 -0
- package/src/schedule/index.ts +112 -0
- package/src/schedule/rules.ts +529 -0
- package/src/schedule/shift-patterns.ts +46 -0
- package/src/schedule/time-periods.ts +110 -0
- package/dist/llms.d.ts +0 -2
- package/dist/llms.d.ts.map +0 -1
- package/dist/llms.js +0 -3
- package/dist/llms.js.map +0 -1
- package/dist/schedule.d.ts +0 -917
- package/dist/schedule.d.ts.map +0 -1
- package/dist/schedule.js.map +0 -1
- package/llms.txt +0 -758
- package/src/llms.ts +0 -3
- package/src/schedule.ts +0 -1960
package/src/schedule.ts
DELETED
|
@@ -1,1960 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* High-level schedule definition API.
|
|
3
|
-
*
|
|
4
|
-
* Small, composable factory functions that produce a complete scheduling
|
|
5
|
-
* configuration. Designed for LLM code generation: each concept is a single
|
|
6
|
-
* function call with per-call type safety.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```typescript
|
|
10
|
-
* import {
|
|
11
|
-
* schedule, t, time, cover, shift,
|
|
12
|
-
* maxHoursPerDay, maxHoursPerWeek, minRestBetweenShifts,
|
|
13
|
-
* weekdays, weekend,
|
|
14
|
-
* } from "dabke";
|
|
15
|
-
*
|
|
16
|
-
* const venue = schedule({
|
|
17
|
-
* roleIds: ["cashier", "floor_lead", "stocker"],
|
|
18
|
-
* skillIds: ["keyholder"],
|
|
19
|
-
*
|
|
20
|
-
* times: {
|
|
21
|
-
* opening: time({ startTime: t(8), endTime: t(10) }),
|
|
22
|
-
* peak_hours: time(
|
|
23
|
-
* { startTime: t(11), endTime: t(14) },
|
|
24
|
-
* { startTime: t(10), endTime: t(15), dayOfWeek: weekend },
|
|
25
|
-
* ),
|
|
26
|
-
* closing: time({ startTime: t(20), endTime: t(22) }),
|
|
27
|
-
* },
|
|
28
|
-
*
|
|
29
|
-
* coverage: [
|
|
30
|
-
* cover("opening", "keyholder", 1),
|
|
31
|
-
* cover("peak_hours", "cashier", 3, { dayOfWeek: weekdays }),
|
|
32
|
-
* cover("peak_hours", "cashier", 5, { dayOfWeek: weekend }),
|
|
33
|
-
* cover("closing", "floor_lead", 1),
|
|
34
|
-
* ],
|
|
35
|
-
*
|
|
36
|
-
* shiftPatterns: [
|
|
37
|
-
* shift("morning", t(8), t(14)),
|
|
38
|
-
* shift("afternoon", t(14), t(22)),
|
|
39
|
-
* ],
|
|
40
|
-
*
|
|
41
|
-
* rules: [
|
|
42
|
-
* maxHoursPerDay(10),
|
|
43
|
-
* maxHoursPerWeek(48),
|
|
44
|
-
* minRestBetweenShifts(10),
|
|
45
|
-
* ],
|
|
46
|
-
* });
|
|
47
|
-
*
|
|
48
|
-
* const result = await venue
|
|
49
|
-
* .with([
|
|
50
|
-
* { id: "alice", roleIds: ["cashier"], skillIds: ["keyholder"] },
|
|
51
|
-
* ])
|
|
52
|
-
* .solve(client, { dateRange: { start: "2025-03-03", end: "2025-03-09" } });
|
|
53
|
-
* ```
|
|
54
|
-
*
|
|
55
|
-
* @module
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
import type { DayOfWeek, SchedulingPeriod, TimeOfDay } from "./types.js";
|
|
59
|
-
import type {
|
|
60
|
-
SemanticTimeDef,
|
|
61
|
-
SemanticTimeVariant,
|
|
62
|
-
SemanticTimeEntry,
|
|
63
|
-
MixedCoverageRequirement,
|
|
64
|
-
CoverageVariant,
|
|
65
|
-
} from "./cpsat/semantic-time.js";
|
|
66
|
-
|
|
67
|
-
export type { CoverageVariant } from "./cpsat/semantic-time.js";
|
|
68
|
-
import { defineSemanticTimes } from "./cpsat/semantic-time.js";
|
|
69
|
-
import { resolveDaysFromPeriod } from "./datetime.utils.js";
|
|
70
|
-
import type { ModelBuilderConfig, CompilationResult } from "./cpsat/model-builder.js";
|
|
71
|
-
import { ModelBuilder } from "./cpsat/model-builder.js";
|
|
72
|
-
import type { SchedulingMember, ShiftPattern, Priority } from "./cpsat/types.js";
|
|
73
|
-
import type {
|
|
74
|
-
CpsatRuleName,
|
|
75
|
-
CpsatRuleConfigEntry,
|
|
76
|
-
CreateCpsatRuleFunction,
|
|
77
|
-
} from "./cpsat/rules/rules.types.js";
|
|
78
|
-
import { builtInCpsatRuleFactories } from "./cpsat/rules/registry.js";
|
|
79
|
-
import type { RecurringPeriod } from "./cpsat/rules/scope.types.js";
|
|
80
|
-
import type { OvertimeTier } from "./cpsat/rules/overtime-tiered-multiplier.js";
|
|
81
|
-
import type { SolverClient, SolverResponse } from "./client.types.js";
|
|
82
|
-
import { parseSolverResponse, resolveAssignments } from "./cpsat/response.js";
|
|
83
|
-
import type { ShiftAssignment } from "./cpsat/response.js";
|
|
84
|
-
import type { ScheduleValidation } from "./cpsat/validation.types.js";
|
|
85
|
-
import { calculateScheduleCost } from "./cpsat/cost.js";
|
|
86
|
-
import type { CostBreakdown } from "./cpsat/cost.js";
|
|
87
|
-
|
|
88
|
-
// ============================================================================
|
|
89
|
-
// Primitives
|
|
90
|
-
// ============================================================================
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Creates a {@link TimeOfDay} value.
|
|
94
|
-
*
|
|
95
|
-
* @param hours - Hour component (0-23)
|
|
96
|
-
* @param minutes - Minute component (0-59)
|
|
97
|
-
*
|
|
98
|
-
* @example Hours only
|
|
99
|
-
* ```ts
|
|
100
|
-
* t(9) // { hours: 9, minutes: 0 }
|
|
101
|
-
* ```
|
|
102
|
-
*
|
|
103
|
-
* @example Hours and minutes
|
|
104
|
-
* ```ts
|
|
105
|
-
* t(17, 30) // { hours: 17, minutes: 30 }
|
|
106
|
-
* ```
|
|
107
|
-
*
|
|
108
|
-
* @category Time Periods
|
|
109
|
-
*/
|
|
110
|
-
export function t(hours: number, minutes = 0): TimeOfDay {
|
|
111
|
-
return { hours, minutes };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Monday through Friday.
|
|
116
|
-
*
|
|
117
|
-
* @category Time Periods
|
|
118
|
-
*/
|
|
119
|
-
export const weekdays = [
|
|
120
|
-
"monday",
|
|
121
|
-
"tuesday",
|
|
122
|
-
"wednesday",
|
|
123
|
-
"thursday",
|
|
124
|
-
"friday",
|
|
125
|
-
] as const satisfies readonly [DayOfWeek, ...DayOfWeek[]];
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Saturday and Sunday.
|
|
129
|
-
*
|
|
130
|
-
* @category Time Periods
|
|
131
|
-
*/
|
|
132
|
-
export const weekend = ["saturday", "sunday"] as const satisfies readonly [
|
|
133
|
-
DayOfWeek,
|
|
134
|
-
...DayOfWeek[],
|
|
135
|
-
];
|
|
136
|
-
|
|
137
|
-
// ============================================================================
|
|
138
|
-
// Semantic Times
|
|
139
|
-
// ============================================================================
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Define a named semantic time period.
|
|
143
|
-
*
|
|
144
|
-
* @remarks
|
|
145
|
-
* Each entry has `startTime`/`endTime` and optional `dayOfWeek` or `dates`
|
|
146
|
-
* scoping. Entries without scoping are the default.
|
|
147
|
-
*
|
|
148
|
-
* @example
|
|
149
|
-
* ```typescript
|
|
150
|
-
* times: {
|
|
151
|
-
* // Simple: same times every day
|
|
152
|
-
* lunch: time({ startTime: t(12), endTime: t(15) }),
|
|
153
|
-
*
|
|
154
|
-
* // Variants: different times on weekends
|
|
155
|
-
* dinner: time(
|
|
156
|
-
* { startTime: t(17), endTime: t(21) },
|
|
157
|
-
* { startTime: t(18), endTime: t(22), dayOfWeek: weekend },
|
|
158
|
-
* ),
|
|
159
|
-
*
|
|
160
|
-
* // Point-in-time window (keyholder at opening)
|
|
161
|
-
* opening: time({ startTime: t(8, 30), endTime: t(9) }),
|
|
162
|
-
* }
|
|
163
|
-
* ```
|
|
164
|
-
*
|
|
165
|
-
* @privateRemarks
|
|
166
|
-
* Resolution precedence: `dates` > `dayOfWeek` > default.
|
|
167
|
-
*
|
|
168
|
-
* @category Time Periods
|
|
169
|
-
*/
|
|
170
|
-
export function time(
|
|
171
|
-
...entries: [SemanticTimeVariant, ...SemanticTimeVariant[]]
|
|
172
|
-
): SemanticTimeEntry {
|
|
173
|
-
// Validate: at most one default (no dayOfWeek and no dates)
|
|
174
|
-
const defaults = entries.filter((e) => !e.dayOfWeek && !e.dates);
|
|
175
|
-
if (defaults.length > 1) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
"time() accepts at most one default entry (without dayOfWeek or dates). " +
|
|
178
|
-
`Found ${defaults.length} default entries.`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Single entry without scoping: simple SemanticTimeDef
|
|
183
|
-
if (entries.length === 1 && !entries[0].dayOfWeek && !entries[0].dates) {
|
|
184
|
-
return {
|
|
185
|
-
startTime: entries[0].startTime,
|
|
186
|
-
endTime: entries[0].endTime,
|
|
187
|
-
} satisfies SemanticTimeDef;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Multiple entries or scoped entries: shallow-copy to decouple from caller
|
|
191
|
-
return entries.map((entry) => Object.assign({}, entry));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ============================================================================
|
|
195
|
-
// Coverage
|
|
196
|
-
// ============================================================================
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Options for a {@link cover} call.
|
|
200
|
-
*
|
|
201
|
-
* @remarks
|
|
202
|
-
* Day/date scoping controls which days this coverage entry applies to.
|
|
203
|
-
* An entry without `dayOfWeek` or `dates` applies every day in the
|
|
204
|
-
* scheduling period.
|
|
205
|
-
*
|
|
206
|
-
* @category Coverage
|
|
207
|
-
*/
|
|
208
|
-
export interface CoverageOptions {
|
|
209
|
-
/** Additional skill ID filter (AND logic with the target role). */
|
|
210
|
-
skillIds?: [string, ...string[]];
|
|
211
|
-
/** Restrict to specific days of the week. */
|
|
212
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
213
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
214
|
-
dates?: string[];
|
|
215
|
-
/** Defaults to `"MANDATORY"`. */
|
|
216
|
-
priority?: Priority;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* A coverage entry returned by {@link cover}.
|
|
221
|
-
*
|
|
222
|
-
* @remarks
|
|
223
|
-
* Carries the semantic time name and target type information for
|
|
224
|
-
* compile-time validation by {@link schedule}. This is an opaque
|
|
225
|
-
* token; pass it directly into the `coverage` array.
|
|
226
|
-
*/
|
|
227
|
-
export interface CoverageEntry<T extends string = string, R extends string = string> {
|
|
228
|
-
/** @internal */ readonly _type: "coverage";
|
|
229
|
-
/** @internal */ readonly timeName: T;
|
|
230
|
-
/** @internal */ readonly target: R | R[];
|
|
231
|
-
/** @internal */ readonly count: number;
|
|
232
|
-
/** @internal */ readonly options: CoverageOptions;
|
|
233
|
-
/** @internal When present, this entry uses variant-based resolution. */
|
|
234
|
-
readonly variants?: readonly CoverageVariant[];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Defines a staffing requirement for a semantic time period.
|
|
239
|
-
*
|
|
240
|
-
* @remarks
|
|
241
|
-
* Entries for the same time and role **stack additively**.
|
|
242
|
-
* For weekday vs weekend staffing, use mutually exclusive `dayOfWeek`
|
|
243
|
-
* on both entries.
|
|
244
|
-
*
|
|
245
|
-
* @param timeName - Name of a declared semantic time
|
|
246
|
-
* @param target - Role name (string), array of role names (OR logic), or skill name
|
|
247
|
-
* @param count - Number of people needed
|
|
248
|
-
* @param opts - Options: `skillIds` (AND filter), `dayOfWeek`, `dates`, `priority`
|
|
249
|
-
*
|
|
250
|
-
* @example
|
|
251
|
-
* ```typescript
|
|
252
|
-
* coverage: [
|
|
253
|
-
* // 2 waiters during lunch
|
|
254
|
-
* cover("lunch", "waiter", 2),
|
|
255
|
-
*
|
|
256
|
-
* // 1 manager OR supervisor during dinner
|
|
257
|
-
* cover("dinner", ["manager", "supervisor"], 1),
|
|
258
|
-
*
|
|
259
|
-
* // 1 person with keyholder skill at opening
|
|
260
|
-
* cover("opening", "keyholder", 1),
|
|
261
|
-
*
|
|
262
|
-
* // 1 senior waiter (role + skill AND)
|
|
263
|
-
* cover("lunch", "waiter", 1, { skillIds: ["senior"] }),
|
|
264
|
-
*
|
|
265
|
-
* // Different counts by day (mutually exclusive dayOfWeek!)
|
|
266
|
-
* cover("lunch", "waiter", 2, { dayOfWeek: weekdays }),
|
|
267
|
-
* cover("lunch", "waiter", 3, { dayOfWeek: weekend }),
|
|
268
|
-
* ]
|
|
269
|
-
* ```
|
|
270
|
-
*
|
|
271
|
-
* @category Coverage
|
|
272
|
-
*/
|
|
273
|
-
export function cover<T extends string, R extends string>(
|
|
274
|
-
timeName: T,
|
|
275
|
-
target: R | [R, ...R[]],
|
|
276
|
-
count: number,
|
|
277
|
-
opts?: CoverageOptions,
|
|
278
|
-
): CoverageEntry<T, R>;
|
|
279
|
-
export function cover<T extends string, R extends string>(
|
|
280
|
-
timeName: T,
|
|
281
|
-
target: R | [R, ...R[]],
|
|
282
|
-
...variants: [CoverageVariant, ...CoverageVariant[]]
|
|
283
|
-
): CoverageEntry<T, R>;
|
|
284
|
-
export function cover<T extends string, R extends string>(
|
|
285
|
-
timeName: T,
|
|
286
|
-
target: R | [R, ...R[]],
|
|
287
|
-
countOrFirstVariant: number | CoverageVariant,
|
|
288
|
-
...rest: unknown[]
|
|
289
|
-
): CoverageEntry<T, R> {
|
|
290
|
-
if (typeof countOrFirstVariant === "number") {
|
|
291
|
-
// Simple form: cover(time, target, count, opts?)
|
|
292
|
-
return {
|
|
293
|
-
_type: "coverage",
|
|
294
|
-
timeName,
|
|
295
|
-
target,
|
|
296
|
-
count: countOrFirstVariant,
|
|
297
|
-
options: (rest[0] as CoverageOptions | undefined) ?? {},
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Variant form: cover(time, target, ...variants)
|
|
302
|
-
const variants = [countOrFirstVariant, ...(rest as CoverageVariant[])];
|
|
303
|
-
|
|
304
|
-
const defaults = variants.filter((v) => !v.dayOfWeek && !v.dates);
|
|
305
|
-
if (defaults.length > 1) {
|
|
306
|
-
throw new Error(
|
|
307
|
-
"cover() accepts at most one default variant (without dayOfWeek or dates). " +
|
|
308
|
-
`Found ${defaults.length} default variants.`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return {
|
|
313
|
-
_type: "coverage",
|
|
314
|
-
timeName,
|
|
315
|
-
target,
|
|
316
|
-
count: 0,
|
|
317
|
-
options: {},
|
|
318
|
-
variants,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ============================================================================
|
|
323
|
-
// Shift Patterns
|
|
324
|
-
// ============================================================================
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Define a shift pattern: a time slot available for employee assignment.
|
|
328
|
-
*
|
|
329
|
-
* @remarks
|
|
330
|
-
* Each pattern repeats daily unless filtered by `dayOfWeek`.
|
|
331
|
-
*
|
|
332
|
-
* @example
|
|
333
|
-
* ```typescript
|
|
334
|
-
* shiftPatterns: [
|
|
335
|
-
* shift("morning", t(11, 30), t(15)),
|
|
336
|
-
* shift("evening", t(17), t(22)),
|
|
337
|
-
*
|
|
338
|
-
* // Role-restricted shift
|
|
339
|
-
* shift("kitchen", t(6), t(14), { roleIds: ["chef", "prep_cook"] }),
|
|
340
|
-
*
|
|
341
|
-
* // Day-restricted shift
|
|
342
|
-
* shift("saturday_short", t(9), t(14), { dayOfWeek: ["saturday"] }),
|
|
343
|
-
*
|
|
344
|
-
* // Location-specific shift
|
|
345
|
-
* shift("terrace_lunch", t(12), t(16), { locationId: "terrace" }),
|
|
346
|
-
* ]
|
|
347
|
-
* ```
|
|
348
|
-
*
|
|
349
|
-
* @category Shift Patterns
|
|
350
|
-
*/
|
|
351
|
-
export function shift(
|
|
352
|
-
id: string,
|
|
353
|
-
startTime: TimeOfDay,
|
|
354
|
-
endTime: TimeOfDay,
|
|
355
|
-
opts?: Pick<ShiftPattern, "roleIds" | "dayOfWeek" | "locationId">,
|
|
356
|
-
): ShiftPattern {
|
|
357
|
-
const pattern: ShiftPattern = { id, startTime, endTime };
|
|
358
|
-
if (opts?.roleIds) pattern.roleIds = opts.roleIds;
|
|
359
|
-
if (opts?.dayOfWeek) pattern.dayOfWeek = opts.dayOfWeek;
|
|
360
|
-
if (opts?.locationId) pattern.locationId = opts.locationId;
|
|
361
|
-
return pattern;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// ============================================================================
|
|
365
|
-
// Rules
|
|
366
|
-
// ============================================================================
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Scoping options shared by most rule functions.
|
|
370
|
-
*
|
|
371
|
-
* @remarks
|
|
372
|
-
* Default priority is `MANDATORY`. Use `appliesTo` to scope to a
|
|
373
|
-
* role, skill, or member ID. Use time scoping options (`dayOfWeek`,
|
|
374
|
-
* `dateRange`, `dates`) to limit when the rule applies.
|
|
375
|
-
* Not all rules support all scoping options. Entity-only rules
|
|
376
|
-
* (e.g., {@link maxConsecutiveDays}) ignore time scoping.
|
|
377
|
-
*
|
|
378
|
-
* @category Rules
|
|
379
|
-
*/
|
|
380
|
-
export interface RuleOptions {
|
|
381
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
382
|
-
appliesTo?: string | string[];
|
|
383
|
-
/** Restrict to specific days of the week. */
|
|
384
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
385
|
-
/** Restrict to a date range. */
|
|
386
|
-
dateRange?: { start: string; end: string };
|
|
387
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
388
|
-
dates?: string[];
|
|
389
|
-
/** Restrict to recurring calendar periods. */
|
|
390
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
391
|
-
/** Defaults to `"MANDATORY"`. */
|
|
392
|
-
priority?: Priority;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Options for rules that support entity scoping only (no time scoping).
|
|
397
|
-
*
|
|
398
|
-
* @remarks
|
|
399
|
-
* Used by rules whose semantics are inherently per-day or per-week
|
|
400
|
-
* (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
|
|
401
|
-
* be meaningfully restricted to a date range or day of week.
|
|
402
|
-
*
|
|
403
|
-
* @category Rules
|
|
404
|
-
*/
|
|
405
|
-
export interface EntityOnlyRuleOptions {
|
|
406
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
407
|
-
appliesTo?: string | string[];
|
|
408
|
-
/** Defaults to `"MANDATORY"`. */
|
|
409
|
-
priority?: Priority;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Options for {@link timeOff}.
|
|
414
|
-
*
|
|
415
|
-
* @remarks
|
|
416
|
-
* At least one time scoping field is required (`dayOfWeek`, `dateRange`,
|
|
417
|
-
* `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
|
|
418
|
-
* of a day.
|
|
419
|
-
*
|
|
420
|
-
* @category Rules
|
|
421
|
-
*/
|
|
422
|
-
export interface TimeOffOptions {
|
|
423
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
424
|
-
appliesTo?: string | string[];
|
|
425
|
-
/** Off from this time until end of day. */
|
|
426
|
-
from?: TimeOfDay;
|
|
427
|
-
/** Off from start of day until this time. */
|
|
428
|
-
until?: TimeOfDay;
|
|
429
|
-
/** Restrict to specific days of the week. */
|
|
430
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
431
|
-
/** Restrict to a date range. */
|
|
432
|
-
dateRange?: { start: string; end: string };
|
|
433
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
434
|
-
dates?: string[];
|
|
435
|
-
/** Restrict to recurring calendar periods. */
|
|
436
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
437
|
-
/** Defaults to `"MANDATORY"`. */
|
|
438
|
-
priority?: Priority;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Options for {@link assignTogether}.
|
|
443
|
-
*
|
|
444
|
-
* @category Rules
|
|
445
|
-
*/
|
|
446
|
-
export interface AssignTogetherOptions {
|
|
447
|
-
/** Defaults to `"MANDATORY"`. */
|
|
448
|
-
priority?: Priority;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Options for cost rules.
|
|
453
|
-
*
|
|
454
|
-
* Cost rules are objective terms, not constraints. The `priority` field from
|
|
455
|
-
* {@link RuleOptions} does not apply.
|
|
456
|
-
*
|
|
457
|
-
* @category Cost Optimization
|
|
458
|
-
*/
|
|
459
|
-
export interface CostRuleOptions {
|
|
460
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
461
|
-
appliesTo?: string | string[];
|
|
462
|
-
/** Restrict to specific days of the week. */
|
|
463
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
464
|
-
/** Restrict to a date range. */
|
|
465
|
-
dateRange?: { start: string; end: string };
|
|
466
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
467
|
-
dates?: string[];
|
|
468
|
-
/** Restrict to recurring calendar periods. */
|
|
469
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Context passed to a rule's resolve function during compilation.
|
|
474
|
-
*
|
|
475
|
-
* Contains the declared roles, skills, and member IDs so the resolver
|
|
476
|
-
* can translate user-facing fields (like `appliesTo`) into internal
|
|
477
|
-
* scoping fields.
|
|
478
|
-
*
|
|
479
|
-
* @category Rules
|
|
480
|
-
*/
|
|
481
|
-
export interface RuleResolveContext {
|
|
482
|
-
readonly roles: ReadonlySet<string>;
|
|
483
|
-
readonly skills: ReadonlySet<string>;
|
|
484
|
-
readonly memberIds: ReadonlySet<string>;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Internal rule entry type
|
|
488
|
-
interface RuleEntryBase {
|
|
489
|
-
readonly _type: "rule";
|
|
490
|
-
readonly _rule: string;
|
|
491
|
-
/**
|
|
492
|
-
* Optional custom resolver. When present, `resolveRules()` calls this
|
|
493
|
-
* instead of the default translation path. Built-in rules that need
|
|
494
|
-
* special field mapping (e.g., `timeOff`, `assignTogether`) attach one;
|
|
495
|
-
* all other rules use the default resolver.
|
|
496
|
-
*/
|
|
497
|
-
readonly _resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* An opaque rule entry returned by rule functions.
|
|
502
|
-
*
|
|
503
|
-
* @remarks
|
|
504
|
-
* Pass these directly into the `rules` array of {@link ScheduleConfig}.
|
|
505
|
-
* The internal fields are resolved during compilation.
|
|
506
|
-
*/
|
|
507
|
-
export type RuleEntry = RuleEntryBase & Record<string, unknown>;
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Creates a rule entry for use in {@link ScheduleConfig.rules}.
|
|
511
|
-
*
|
|
512
|
-
* Built-in rules use the helpers (`maxHoursPerDay`, `timeOff`, etc.).
|
|
513
|
-
* Custom rules can use `defineRule` to create entries that plug into the
|
|
514
|
-
* same resolution and compilation pipeline.
|
|
515
|
-
*
|
|
516
|
-
* @param name - Rule name. Must match a key in the rule factory registry.
|
|
517
|
-
* @param fields - Rule-specific configuration fields.
|
|
518
|
-
* @param resolve - Optional custom resolver. When omitted, the default
|
|
519
|
-
* resolution applies: `appliesTo` is mapped to `roleIds`/`skillIds`/`memberIds`,
|
|
520
|
-
* `dates` is renamed to `specificDates`, and all other fields pass through.
|
|
521
|
-
*
|
|
522
|
-
* @category Rules
|
|
523
|
-
*/
|
|
524
|
-
export function defineRule(
|
|
525
|
-
name: string,
|
|
526
|
-
fields: Record<string, unknown>,
|
|
527
|
-
resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string },
|
|
528
|
-
): RuleEntry {
|
|
529
|
-
const { _type: _, _rule: __, ...safeFields } = fields;
|
|
530
|
-
const entry: RuleEntry = { _type: "rule", _rule: name, ...safeFields };
|
|
531
|
-
if (resolve) {
|
|
532
|
-
Object.defineProperty(entry, "_resolve", { value: resolve, enumerable: false });
|
|
533
|
-
}
|
|
534
|
-
return entry;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function makeRule(rule: CpsatRuleName, fields: Record<string, unknown>): RuleEntry {
|
|
538
|
-
return defineRule(rule, fields);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Limits hours per day.
|
|
543
|
-
*
|
|
544
|
-
* @example
|
|
545
|
-
* ```typescript
|
|
546
|
-
* maxHoursPerDay(10)
|
|
547
|
-
* maxHoursPerDay(4, { appliesTo: "student", dayOfWeek: weekdays })
|
|
548
|
-
* ```
|
|
549
|
-
*
|
|
550
|
-
* @category Rules
|
|
551
|
-
*/
|
|
552
|
-
export function maxHoursPerDay(hours: number, opts?: RuleOptions): RuleEntry {
|
|
553
|
-
return makeRule("max-hours-day", { hours, ...opts });
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Limits hours per scheduling week.
|
|
558
|
-
*
|
|
559
|
-
* @example
|
|
560
|
-
* ```typescript
|
|
561
|
-
* maxHoursPerWeek(48)
|
|
562
|
-
* maxHoursPerWeek(20, { appliesTo: "student" })
|
|
563
|
-
* ```
|
|
564
|
-
*
|
|
565
|
-
* @category Rules
|
|
566
|
-
*/
|
|
567
|
-
export function maxHoursPerWeek(hours: number, opts?: RuleOptions): RuleEntry {
|
|
568
|
-
return makeRule("max-hours-week", { hours, ...opts });
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Minimum hours when assigned on a day.
|
|
573
|
-
*
|
|
574
|
-
* @example
|
|
575
|
-
* ```typescript
|
|
576
|
-
* minHoursPerDay(4)
|
|
577
|
-
* ```
|
|
578
|
-
*
|
|
579
|
-
* @category Rules
|
|
580
|
-
*/
|
|
581
|
-
export function minHoursPerDay(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
582
|
-
return makeRule("min-hours-day", { hours, ...opts });
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Minimum hours per scheduling week.
|
|
587
|
-
*
|
|
588
|
-
* @example
|
|
589
|
-
* ```typescript
|
|
590
|
-
* minHoursPerWeek(20, { priority: "HIGH" })
|
|
591
|
-
* ```
|
|
592
|
-
*
|
|
593
|
-
* @category Rules
|
|
594
|
-
*/
|
|
595
|
-
export function minHoursPerWeek(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
596
|
-
return makeRule("min-hours-week", { hours, ...opts });
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Maximum distinct shifts per day.
|
|
601
|
-
*
|
|
602
|
-
* @example
|
|
603
|
-
* ```typescript
|
|
604
|
-
* maxShiftsPerDay(1)
|
|
605
|
-
* maxShiftsPerDay(2, { appliesTo: "student", dayOfWeek: weekend })
|
|
606
|
-
* ```
|
|
607
|
-
*
|
|
608
|
-
* @category Rules
|
|
609
|
-
*/
|
|
610
|
-
export function maxShiftsPerDay(shifts: number, opts?: RuleOptions): RuleEntry {
|
|
611
|
-
return makeRule("max-shifts-day", { shifts, ...opts });
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Maximum consecutive working days.
|
|
616
|
-
*
|
|
617
|
-
* @example
|
|
618
|
-
* ```typescript
|
|
619
|
-
* maxConsecutiveDays(5)
|
|
620
|
-
* ```
|
|
621
|
-
*
|
|
622
|
-
* @category Rules
|
|
623
|
-
*/
|
|
624
|
-
export function maxConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
625
|
-
return makeRule("max-consecutive-days", { days, ...opts });
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* Once working, continue for at least this many consecutive days.
|
|
630
|
-
*
|
|
631
|
-
* @example
|
|
632
|
-
* ```typescript
|
|
633
|
-
* minConsecutiveDays(2, { priority: "HIGH" })
|
|
634
|
-
* ```
|
|
635
|
-
*
|
|
636
|
-
* @category Rules
|
|
637
|
-
*/
|
|
638
|
-
export function minConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
639
|
-
return makeRule("min-consecutive-days", { days, ...opts });
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Minimum rest hours between shifts.
|
|
644
|
-
*
|
|
645
|
-
* @example
|
|
646
|
-
* ```typescript
|
|
647
|
-
* minRestBetweenShifts(10)
|
|
648
|
-
* ```
|
|
649
|
-
*
|
|
650
|
-
* @category Rules
|
|
651
|
-
*/
|
|
652
|
-
export function minRestBetweenShifts(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
653
|
-
return makeRule("min-rest-between-shifts", { hours, ...opts });
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Prefer (`"high"`) or avoid (`"low"`) assigning. Requires `appliesTo`.
|
|
658
|
-
*
|
|
659
|
-
* @example
|
|
660
|
-
* ```typescript
|
|
661
|
-
* preference("high", { appliesTo: "waiter" })
|
|
662
|
-
* preference("low", { appliesTo: "student", dayOfWeek: weekdays })
|
|
663
|
-
* ```
|
|
664
|
-
*
|
|
665
|
-
* @category Rules
|
|
666
|
-
*/
|
|
667
|
-
export function preference(level: "high" | "low", opts?: Omit<RuleOptions, "priority">): RuleEntry {
|
|
668
|
-
return makeRule("assignment-priority", { preference: level, ...opts });
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Prefer assigning to shifts at a specific location. Requires `appliesTo`.
|
|
673
|
-
*
|
|
674
|
-
* @example
|
|
675
|
-
* ```typescript
|
|
676
|
-
* preferLocation("terrace", { appliesTo: "alice" })
|
|
677
|
-
* ```
|
|
678
|
-
*
|
|
679
|
-
* @category Rules
|
|
680
|
-
*/
|
|
681
|
-
export function preferLocation(locationId: string, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
682
|
-
return makeRule("location-preference", { locationId, ...opts });
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Tells the solver to minimize total labor cost.
|
|
687
|
-
*
|
|
688
|
-
* @remarks
|
|
689
|
-
* Without this rule, cost modifiers only affect post-solve calculation.
|
|
690
|
-
* When present, the solver actively prefers cheaper assignments.
|
|
691
|
-
*
|
|
692
|
-
* For hourly members, penalizes each assignment proportionally to cost.
|
|
693
|
-
* For salaried members, adds a fixed weekly salary cost when they have
|
|
694
|
-
* any assignment that week (zero marginal cost up to contracted hours).
|
|
695
|
-
*
|
|
696
|
-
* Cost modifiers adjust the calculation:
|
|
697
|
-
* - `dayMultiplier(factor, opts?)` - multiply base rate on specific days
|
|
698
|
-
* - `daySurcharge(amount, opts?)` - flat extra per hour on specific days
|
|
699
|
-
* - `timeSurcharge(amount, window, opts?)` - flat extra per hour during a time window
|
|
700
|
-
* - `overtimeMultiplier({ after, factor }, opts?)` - weekly overtime multiplier
|
|
701
|
-
* - `overtimeSurcharge({ after, amount }, opts?)` - weekly overtime surcharge
|
|
702
|
-
* - `dailyOvertimeMultiplier({ after, factor }, opts?)` - daily overtime multiplier
|
|
703
|
-
* - `dailyOvertimeSurcharge({ after, amount }, opts?)` - daily overtime surcharge
|
|
704
|
-
* - `tieredOvertimeMultiplier(tiers, opts?)` - multiple overtime thresholds
|
|
705
|
-
*
|
|
706
|
-
* @example
|
|
707
|
-
* ```ts
|
|
708
|
-
* minimizeCost()
|
|
709
|
-
* ```
|
|
710
|
-
*
|
|
711
|
-
* @category Cost Optimization
|
|
712
|
-
*/
|
|
713
|
-
export function minimizeCost(opts?: CostRuleOptions): RuleEntry {
|
|
714
|
-
return makeRule("minimize-cost", { ...opts });
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Multiplies the base rate for assignments on specified days.
|
|
719
|
-
*
|
|
720
|
-
* @remarks
|
|
721
|
-
* The base cost (1x) is already counted by {@link minimizeCost};
|
|
722
|
-
* this rule adds only the extra portion above 1x.
|
|
723
|
-
*
|
|
724
|
-
* @category Cost Optimization
|
|
725
|
-
*
|
|
726
|
-
* @example Weekend multiplier
|
|
727
|
-
* ```typescript
|
|
728
|
-
* dayMultiplier(1.5, { dayOfWeek: weekend })
|
|
729
|
-
* ```
|
|
730
|
-
*/
|
|
731
|
-
export function dayMultiplier(factor: number, opts?: CostRuleOptions): RuleEntry {
|
|
732
|
-
return makeRule("day-cost-multiplier", { factor, ...opts });
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Adds a flat extra amount per hour for assignments on specified days.
|
|
737
|
-
*
|
|
738
|
-
* @remarks
|
|
739
|
-
* The surcharge is independent of the member's base rate.
|
|
740
|
-
*
|
|
741
|
-
* @category Cost Optimization
|
|
742
|
-
*
|
|
743
|
-
* @example Weekend surcharge
|
|
744
|
-
* ```typescript
|
|
745
|
-
* daySurcharge(500, { dayOfWeek: weekend })
|
|
746
|
-
* ```
|
|
747
|
-
*/
|
|
748
|
-
export function daySurcharge(amountPerHour: number, opts?: CostRuleOptions): RuleEntry {
|
|
749
|
-
return makeRule("day-cost-surcharge", { amountPerHour, ...opts });
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Adds a flat surcharge per hour for the portion of a shift that overlaps a time-of-day window.
|
|
754
|
-
*
|
|
755
|
-
* @remarks
|
|
756
|
-
* The window supports overnight spans (e.g., 22:00-06:00). The surcharge
|
|
757
|
-
* is independent of the member's base rate.
|
|
758
|
-
*
|
|
759
|
-
* @param amountPerHour - Flat surcharge per hour in smallest currency unit
|
|
760
|
-
* @param window - Time-of-day window
|
|
761
|
-
* @param opts - Entity and time scoping
|
|
762
|
-
*
|
|
763
|
-
* @category Cost Optimization
|
|
764
|
-
*
|
|
765
|
-
* @example Night differential
|
|
766
|
-
* ```typescript
|
|
767
|
-
* timeSurcharge(200, { from: t(22), until: t(6) })
|
|
768
|
-
* ```
|
|
769
|
-
*/
|
|
770
|
-
export function timeSurcharge(
|
|
771
|
-
amountPerHour: number,
|
|
772
|
-
window: { from: TimeOfDay; until: TimeOfDay },
|
|
773
|
-
opts?: CostRuleOptions,
|
|
774
|
-
): RuleEntry {
|
|
775
|
-
return makeRule("time-cost-surcharge", { amountPerHour, window, ...opts });
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Applies a multiplier to hours beyond a weekly threshold.
|
|
780
|
-
*
|
|
781
|
-
* @remarks
|
|
782
|
-
* Only the extra portion above 1x is added (the base cost is already
|
|
783
|
-
* counted by {@link minimizeCost}).
|
|
784
|
-
*
|
|
785
|
-
* @category Cost Optimization
|
|
786
|
-
*
|
|
787
|
-
* @example
|
|
788
|
-
* ```typescript
|
|
789
|
-
* overtimeMultiplier({ after: 40, factor: 1.5 })
|
|
790
|
-
* ```
|
|
791
|
-
*/
|
|
792
|
-
export function overtimeMultiplier(
|
|
793
|
-
opts: { after: number; factor: number } & CostRuleOptions,
|
|
794
|
-
): RuleEntry {
|
|
795
|
-
return makeRule("overtime-weekly-multiplier", { ...opts });
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* Adds a flat surcharge per hour beyond a weekly threshold.
|
|
800
|
-
*
|
|
801
|
-
* @remarks
|
|
802
|
-
* The surcharge is independent of the member's base rate.
|
|
803
|
-
*
|
|
804
|
-
* @category Cost Optimization
|
|
805
|
-
*
|
|
806
|
-
* @example
|
|
807
|
-
* ```typescript
|
|
808
|
-
* overtimeSurcharge({ after: 40, amount: 1000 })
|
|
809
|
-
* ```
|
|
810
|
-
*/
|
|
811
|
-
export function overtimeSurcharge(
|
|
812
|
-
opts: { after: number; amount: number } & CostRuleOptions,
|
|
813
|
-
): RuleEntry {
|
|
814
|
-
return makeRule("overtime-weekly-surcharge", { ...opts });
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Applies a multiplier to hours beyond a daily threshold.
|
|
819
|
-
*
|
|
820
|
-
* @remarks
|
|
821
|
-
* Only the extra portion above 1x is added (the base cost is already
|
|
822
|
-
* counted by {@link minimizeCost}).
|
|
823
|
-
*
|
|
824
|
-
* @category Cost Optimization
|
|
825
|
-
*
|
|
826
|
-
* @example
|
|
827
|
-
* ```typescript
|
|
828
|
-
* dailyOvertimeMultiplier({ after: 8, factor: 1.5 })
|
|
829
|
-
* ```
|
|
830
|
-
*/
|
|
831
|
-
export function dailyOvertimeMultiplier(
|
|
832
|
-
opts: { after: number; factor: number } & CostRuleOptions,
|
|
833
|
-
): RuleEntry {
|
|
834
|
-
return makeRule("overtime-daily-multiplier", { ...opts });
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* Adds a flat surcharge per hour beyond a daily threshold.
|
|
839
|
-
*
|
|
840
|
-
* @remarks
|
|
841
|
-
* The surcharge is independent of the member's base rate.
|
|
842
|
-
*
|
|
843
|
-
* @category Cost Optimization
|
|
844
|
-
*
|
|
845
|
-
* @example
|
|
846
|
-
* ```typescript
|
|
847
|
-
* dailyOvertimeSurcharge({ after: 8, amount: 500 })
|
|
848
|
-
* ```
|
|
849
|
-
*/
|
|
850
|
-
export function dailyOvertimeSurcharge(
|
|
851
|
-
opts: { after: number; amount: number } & CostRuleOptions,
|
|
852
|
-
): RuleEntry {
|
|
853
|
-
return makeRule("overtime-daily-surcharge", { ...opts });
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Applies multiple overtime thresholds with increasing multipliers.
|
|
858
|
-
*
|
|
859
|
-
* @remarks
|
|
860
|
-
* Each tier applies only to the hours between its threshold and the next.
|
|
861
|
-
* Tiers must be sorted by threshold ascending.
|
|
862
|
-
*
|
|
863
|
-
* @category Cost Optimization
|
|
864
|
-
*
|
|
865
|
-
* @example
|
|
866
|
-
* ```typescript
|
|
867
|
-
* // Hours 0-40: base rate
|
|
868
|
-
* // Hours 40-48: 1.5x
|
|
869
|
-
* // Hours 48+: 2.0x
|
|
870
|
-
* tieredOvertimeMultiplier([
|
|
871
|
-
* { after: 40, factor: 1.5 },
|
|
872
|
-
* { after: 48, factor: 2.0 },
|
|
873
|
-
* ])
|
|
874
|
-
* ```
|
|
875
|
-
*/
|
|
876
|
-
export function tieredOvertimeMultiplier(
|
|
877
|
-
tiers: [OvertimeTier, ...OvertimeTier[]],
|
|
878
|
-
opts?: CostRuleOptions,
|
|
879
|
-
): RuleEntry {
|
|
880
|
-
return makeRule("overtime-tiered-multiplier", { tiers, ...opts });
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/**
|
|
884
|
-
* Block assignments during specified periods.
|
|
885
|
-
* Requires at least one time scope (`dayOfWeek`, `dateRange`, `dates`, or `from`/`until`).
|
|
886
|
-
*
|
|
887
|
-
* @example
|
|
888
|
-
* ```typescript
|
|
889
|
-
* // Full days off
|
|
890
|
-
* timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } })
|
|
891
|
-
*
|
|
892
|
-
* // Every weekend off
|
|
893
|
-
* timeOff({ appliesTo: "mauro", dayOfWeek: weekend })
|
|
894
|
-
*
|
|
895
|
-
* // Wednesday afternoons off
|
|
896
|
-
* timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) })
|
|
897
|
-
* ```
|
|
898
|
-
*
|
|
899
|
-
* @category Rules
|
|
900
|
-
*/
|
|
901
|
-
export function timeOff(opts: TimeOffOptions): RuleEntry {
|
|
902
|
-
const { from, until, ...rest } = opts;
|
|
903
|
-
return defineRule("time-off", { from, until, ...rest }, (ctx) => {
|
|
904
|
-
if (!rest.dayOfWeek && !rest.dateRange && !rest.dates && !rest.recurringPeriods) {
|
|
905
|
-
throw new Error(
|
|
906
|
-
"timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const { appliesTo, dates, ...passthrough } = rest;
|
|
911
|
-
const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
|
|
912
|
-
const resolvedDates = dates ? { specificDates: dates } : {};
|
|
913
|
-
|
|
914
|
-
const partialDay: Record<string, unknown> = {};
|
|
915
|
-
if (from && until) {
|
|
916
|
-
partialDay.startTime = from;
|
|
917
|
-
partialDay.endTime = until;
|
|
918
|
-
} else if (from) {
|
|
919
|
-
partialDay.startTime = from;
|
|
920
|
-
partialDay.endTime = { hours: 23, minutes: 59 };
|
|
921
|
-
} else if (until) {
|
|
922
|
-
partialDay.startTime = { hours: 0, minutes: 0 };
|
|
923
|
-
partialDay.endTime = until;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
return {
|
|
927
|
-
name: "time-off",
|
|
928
|
-
...passthrough,
|
|
929
|
-
...entityScope,
|
|
930
|
-
...resolvedDates,
|
|
931
|
-
...partialDay,
|
|
932
|
-
} as CpsatRuleConfigEntry;
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* Members work the same shifts on days they are both assigned.
|
|
938
|
-
*
|
|
939
|
-
* @example
|
|
940
|
-
* ```typescript
|
|
941
|
-
* assignTogether(["alice", "bob"])
|
|
942
|
-
* assignTogether(["alice", "bob", "charlie"], { priority: "HIGH" })
|
|
943
|
-
* ```
|
|
944
|
-
*
|
|
945
|
-
* @category Rules
|
|
946
|
-
*/
|
|
947
|
-
export function assignTogether(
|
|
948
|
-
memberIds: [string, string, ...string[]],
|
|
949
|
-
opts?: AssignTogetherOptions,
|
|
950
|
-
): RuleEntry {
|
|
951
|
-
return defineRule("assign-together", { members: memberIds, ...opts }, (ctx) => {
|
|
952
|
-
for (const member of memberIds) {
|
|
953
|
-
if (!ctx.memberIds.has(member)) {
|
|
954
|
-
throw new Error(
|
|
955
|
-
`assignTogether references unknown member "${member}". ` +
|
|
956
|
-
`Known member IDs: ${[...ctx.memberIds].join(", ")}`,
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
return {
|
|
961
|
-
name: "assign-together",
|
|
962
|
-
groupMemberIds: memberIds,
|
|
963
|
-
...opts,
|
|
964
|
-
} as CpsatRuleConfigEntry;
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/** A value that can be passed to {@link Schedule.with}. */
|
|
969
|
-
type WithArg = Schedule | SchedulingMember[];
|
|
970
|
-
|
|
971
|
-
// ============================================================================
|
|
972
|
-
// SolveResult
|
|
973
|
-
// ============================================================================
|
|
974
|
-
|
|
975
|
-
/** Status of a solve attempt, using idiomatic lowercase TypeScript literals. */
|
|
976
|
-
export type SolveStatus = "optimal" | "feasible" | "infeasible" | "no_solution";
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* Result of {@link Schedule.solve}.
|
|
980
|
-
*
|
|
981
|
-
* @category Schedule Definition
|
|
982
|
-
*/
|
|
983
|
-
export interface SolveResult {
|
|
984
|
-
/** Outcome of the solve attempt. */
|
|
985
|
-
status: SolveStatus;
|
|
986
|
-
/** Shift assignments (empty when infeasible or no solution). */
|
|
987
|
-
assignments: ShiftAssignment[];
|
|
988
|
-
/** Validation diagnostics from compilation. */
|
|
989
|
-
validation: ScheduleValidation;
|
|
990
|
-
/** Cost breakdown (present when cost rules are used and a solution is found). */
|
|
991
|
-
cost?: CostBreakdown;
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Options for {@link Schedule.solve} and {@link Schedule.compile}.
|
|
996
|
-
*
|
|
997
|
-
* @category Schedule Definition
|
|
998
|
-
*/
|
|
999
|
-
export interface SolveOptions {
|
|
1000
|
-
/** The date range to schedule. */
|
|
1001
|
-
dateRange: { start: string; end: string };
|
|
1002
|
-
/**
|
|
1003
|
-
* Fixed assignments from a prior solve (e.g., rolling schedule).
|
|
1004
|
-
* These are injected as fixed variables in the solver.
|
|
1005
|
-
*
|
|
1006
|
-
* Not yet implemented. Providing pinned assignments throws an error.
|
|
1007
|
-
*/
|
|
1008
|
-
pinned?: ShiftAssignment[];
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// ============================================================================
|
|
1012
|
-
// Schedule Configuration
|
|
1013
|
-
// ============================================================================
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Configuration for {@link schedule}.
|
|
1017
|
-
*
|
|
1018
|
-
* @remarks
|
|
1019
|
-
* Coverage entries for the same semantic time and target stack additively.
|
|
1020
|
-
* An unscoped entry applies every day; adding a weekend-only entry on top
|
|
1021
|
-
* doubles the count on those days. Use mutually exclusive `dayOfWeek` on
|
|
1022
|
-
* both entries to avoid stacking. See {@link cover} for details.
|
|
1023
|
-
*
|
|
1024
|
-
* `roleIds`, `times`, `coverage`, and `shiftPatterns` are required.
|
|
1025
|
-
* These four fields form the minimum solvable schedule.
|
|
1026
|
-
*
|
|
1027
|
-
* @category Schedule Definition
|
|
1028
|
-
*/
|
|
1029
|
-
export interface ScheduleConfig<
|
|
1030
|
-
R extends readonly string[] = readonly string[],
|
|
1031
|
-
S extends readonly string[] = readonly string[],
|
|
1032
|
-
T extends Record<string, SemanticTimeEntry> = Record<string, SemanticTimeEntry>,
|
|
1033
|
-
> {
|
|
1034
|
-
/** Declared role IDs. */
|
|
1035
|
-
roleIds: R;
|
|
1036
|
-
/** Declared skill IDs. */
|
|
1037
|
-
skillIds?: S;
|
|
1038
|
-
/** Named semantic time periods. */
|
|
1039
|
-
times: T;
|
|
1040
|
-
/** Staffing requirements per time period (entries stack additively). */
|
|
1041
|
-
coverage: CoverageEntry<keyof T & string, R[number] | NonNullable<S>[number]>[];
|
|
1042
|
-
/** Available shift patterns. */
|
|
1043
|
-
shiftPatterns: ShiftPattern[];
|
|
1044
|
-
/** Scheduling rules and constraints. */
|
|
1045
|
-
rules?: RuleEntry[];
|
|
1046
|
-
/**
|
|
1047
|
-
* Custom rule factories. Keys are rule names, values are functions
|
|
1048
|
-
* that take a config object and return a {@link CompilationRule}.
|
|
1049
|
-
* Built-in rule names cannot be overridden.
|
|
1050
|
-
*/
|
|
1051
|
-
ruleFactories?: Record<string, CreateCpsatRuleFunction>;
|
|
1052
|
-
/** Team members (typically added via `.with()` at runtime). */
|
|
1053
|
-
members?: SchedulingMember[];
|
|
1054
|
-
/** Days of the week the business operates (inclusion filter). */
|
|
1055
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
1056
|
-
/** Which day starts the week for weekly rules. Defaults to `"monday"`. */
|
|
1057
|
-
weekStartsOn?: DayOfWeek;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// ============================================================================
|
|
1061
|
-
// Internal merged config
|
|
1062
|
-
// ============================================================================
|
|
1063
|
-
|
|
1064
|
-
/** Internal representation of a fully merged schedule. */
|
|
1065
|
-
interface MergedScheduleConfig {
|
|
1066
|
-
roleIds: string[];
|
|
1067
|
-
skillIds: string[];
|
|
1068
|
-
times: Record<string, SemanticTimeEntry>;
|
|
1069
|
-
coverage: CoverageEntry[];
|
|
1070
|
-
shiftPatterns: ShiftPattern[];
|
|
1071
|
-
rules: RuleEntry[];
|
|
1072
|
-
ruleFactories: Record<string, CreateCpsatRuleFunction>;
|
|
1073
|
-
members: SchedulingMember[];
|
|
1074
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
1075
|
-
weekStartsOn?: DayOfWeek;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// ============================================================================
|
|
1079
|
-
// Schedule class
|
|
1080
|
-
// ============================================================================
|
|
1081
|
-
|
|
1082
|
-
/**
|
|
1083
|
-
* An immutable schedule definition.
|
|
1084
|
-
*
|
|
1085
|
-
* Created by {@link schedule}, composed via {@link Schedule.with},
|
|
1086
|
-
* and solved via {@link Schedule.solve}.
|
|
1087
|
-
*
|
|
1088
|
-
* @category Schedule Definition
|
|
1089
|
-
*/
|
|
1090
|
-
export class Schedule {
|
|
1091
|
-
readonly #config: Readonly<MergedScheduleConfig>;
|
|
1092
|
-
|
|
1093
|
-
/** @internal */
|
|
1094
|
-
constructor(config: MergedScheduleConfig) {
|
|
1095
|
-
this.#config = config;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/** @internal Returns a defensive copy of the config for merging. */
|
|
1099
|
-
_getConfig(): MergedScheduleConfig {
|
|
1100
|
-
return {
|
|
1101
|
-
...this.#config,
|
|
1102
|
-
roleIds: [...this.#config.roleIds],
|
|
1103
|
-
skillIds: [...this.#config.skillIds],
|
|
1104
|
-
times: { ...this.#config.times },
|
|
1105
|
-
coverage: [...this.#config.coverage],
|
|
1106
|
-
shiftPatterns: [...this.#config.shiftPatterns],
|
|
1107
|
-
rules: [...this.#config.rules],
|
|
1108
|
-
ruleFactories: { ...this.#config.ruleFactories },
|
|
1109
|
-
members: [...this.#config.members],
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// --------------------------------------------------------------------------
|
|
1114
|
-
// Inspection
|
|
1115
|
-
// --------------------------------------------------------------------------
|
|
1116
|
-
|
|
1117
|
-
/** Declared role IDs. */
|
|
1118
|
-
get roleIds(): readonly string[] {
|
|
1119
|
-
return this.#config.roleIds;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/** Declared skill IDs. */
|
|
1123
|
-
get skillIds(): readonly string[] {
|
|
1124
|
-
return this.#config.skillIds;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/** Names of declared semantic times. */
|
|
1128
|
-
get timeNames(): readonly string[] {
|
|
1129
|
-
return Object.keys(this.#config.times);
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/** Shift pattern IDs. */
|
|
1133
|
-
get shiftPatternIds(): readonly string[] {
|
|
1134
|
-
return this.#config.shiftPatterns.map((sp) => sp.id);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/** Internal rule identifiers in kebab-case. */
|
|
1138
|
-
get ruleNames(): readonly string[] {
|
|
1139
|
-
return this.#config.rules.map((r) => r._rule);
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// --------------------------------------------------------------------------
|
|
1143
|
-
// Composition
|
|
1144
|
-
// --------------------------------------------------------------------------
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Merges schedules or members onto this schedule, returning a new
|
|
1148
|
-
* immutable `Schedule`. The original is untouched.
|
|
1149
|
-
*
|
|
1150
|
-
* Accepts any mix of `Schedule` instances and `SchedulingMember[]` arrays.
|
|
1151
|
-
*
|
|
1152
|
-
* Merge semantics (when merging schedules):
|
|
1153
|
-
* - Roles: union (additive)
|
|
1154
|
-
* - Skills: union (additive)
|
|
1155
|
-
* - Times: additive; error on name collision
|
|
1156
|
-
* - Coverage: additive
|
|
1157
|
-
* - Shift patterns: additive; error on ID collision
|
|
1158
|
-
* - Rules: additive
|
|
1159
|
-
* - Members: additive; error on duplicate ID
|
|
1160
|
-
*
|
|
1161
|
-
* Validation runs eagerly: role/skill disjointness, coverage targets
|
|
1162
|
-
* referencing declared roles/skills, member role references, etc.
|
|
1163
|
-
*/
|
|
1164
|
-
with(...args: WithArg[]): Schedule {
|
|
1165
|
-
const merged = mergeConfig(this.#config, args);
|
|
1166
|
-
return new Schedule(merged);
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// --------------------------------------------------------------------------
|
|
1170
|
-
// Solve / compile
|
|
1171
|
-
// --------------------------------------------------------------------------
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Compiles, validates, solves, and parses in one call.
|
|
1175
|
-
*
|
|
1176
|
-
* @param client - Solver client (e.g., `new HttpSolverClient(fetch, url)`)
|
|
1177
|
-
* @param options - Date range and optional pinned assignments
|
|
1178
|
-
*/
|
|
1179
|
-
async solve(client: SolverClient, options: SolveOptions): Promise<SolveResult> {
|
|
1180
|
-
const compiled = this.compile(options);
|
|
1181
|
-
if (!compiled.canSolve) {
|
|
1182
|
-
return {
|
|
1183
|
-
status: "infeasible",
|
|
1184
|
-
assignments: [],
|
|
1185
|
-
validation: compiled.validation,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const response = await client.solve(compiled.request);
|
|
1190
|
-
return buildSolveResult(response, compiled, this.#config);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
* Diagnostic escape hatch. Compiles the schedule without solving.
|
|
1195
|
-
*
|
|
1196
|
-
* @param options - Date range and optional pinned assignments
|
|
1197
|
-
*/
|
|
1198
|
-
compile(options: SolveOptions): CompilationResult & { builder: ModelBuilder } {
|
|
1199
|
-
if (options.pinned && options.pinned.length > 0) {
|
|
1200
|
-
throw new Error("Pinned assignments are not yet supported.");
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
const modelConfig = resolveToModelConfig(this.#config, options);
|
|
1204
|
-
const builder = new ModelBuilder(modelConfig);
|
|
1205
|
-
const result = builder.compile();
|
|
1206
|
-
return { ...result, builder };
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
// ============================================================================
|
|
1211
|
-
// schedule() factory
|
|
1212
|
-
// ============================================================================
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Create a schedule definition.
|
|
1216
|
-
*
|
|
1217
|
-
* Returns an immutable {@link Schedule} that can be composed via `.with()`
|
|
1218
|
-
* and solved via `.solve()`.
|
|
1219
|
-
*
|
|
1220
|
-
* @example
|
|
1221
|
-
* ```typescript
|
|
1222
|
-
* const venue = schedule({
|
|
1223
|
-
* roleIds: ["waiter", "runner", "manager"],
|
|
1224
|
-
* skillIds: ["senior"],
|
|
1225
|
-
* times: {
|
|
1226
|
-
* lunch: time({ startTime: t(12), endTime: t(15) }),
|
|
1227
|
-
* dinner: time(
|
|
1228
|
-
* { startTime: t(17), endTime: t(21) },
|
|
1229
|
-
* { startTime: t(18), endTime: t(22), dayOfWeek: weekend },
|
|
1230
|
-
* ),
|
|
1231
|
-
* },
|
|
1232
|
-
* coverage: [
|
|
1233
|
-
* cover("lunch", "waiter", 2),
|
|
1234
|
-
* cover("dinner", "waiter", 4, { dayOfWeek: weekdays }),
|
|
1235
|
-
* cover("dinner", "waiter", 5, { dayOfWeek: weekend }),
|
|
1236
|
-
* cover("dinner", "manager", 1),
|
|
1237
|
-
* ],
|
|
1238
|
-
* shiftPatterns: [
|
|
1239
|
-
* shift("lunch_shift", t(11, 30), t(15)),
|
|
1240
|
-
* shift("evening", t(17), t(22)),
|
|
1241
|
-
* ],
|
|
1242
|
-
* rules: [
|
|
1243
|
-
* maxHoursPerDay(10),
|
|
1244
|
-
* maxHoursPerWeek(48),
|
|
1245
|
-
* minRestBetweenShifts(11),
|
|
1246
|
-
* ],
|
|
1247
|
-
* });
|
|
1248
|
-
* ```
|
|
1249
|
-
*
|
|
1250
|
-
* @category Schedule Definition
|
|
1251
|
-
*/
|
|
1252
|
-
export function schedule<
|
|
1253
|
-
const R extends readonly string[],
|
|
1254
|
-
const S extends readonly string[],
|
|
1255
|
-
const T extends Record<string, SemanticTimeEntry>,
|
|
1256
|
-
>(config: ScheduleConfig<R, S, T>): Schedule {
|
|
1257
|
-
const merged = buildMergedConfig(config as unknown as ScheduleConfig);
|
|
1258
|
-
validateConfig(merged);
|
|
1259
|
-
return new Schedule(merged);
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Create a partial schedule for composition via `.with()`.
|
|
1264
|
-
*
|
|
1265
|
-
* Unlike {@link schedule}, all fields are optional. Use this for
|
|
1266
|
-
* schedules that layer rules, coverage, or other config onto a
|
|
1267
|
-
* complete base schedule.
|
|
1268
|
-
*
|
|
1269
|
-
* @example
|
|
1270
|
-
* ```typescript
|
|
1271
|
-
* const companyPolicy = partialSchedule({
|
|
1272
|
-
* rules: [maxHoursPerWeek(40), minRestBetweenShifts(11)],
|
|
1273
|
-
* });
|
|
1274
|
-
*
|
|
1275
|
-
* const ready = venue.with(companyPolicy, teamMembers);
|
|
1276
|
-
* ```
|
|
1277
|
-
*
|
|
1278
|
-
* @category Schedule Definition
|
|
1279
|
-
*/
|
|
1280
|
-
export function partialSchedule(config: Partial<ScheduleConfig>): Schedule {
|
|
1281
|
-
const merged = buildMergedConfig({
|
|
1282
|
-
roleIds: [],
|
|
1283
|
-
times: {},
|
|
1284
|
-
coverage: [],
|
|
1285
|
-
shiftPatterns: [],
|
|
1286
|
-
...config,
|
|
1287
|
-
} as ScheduleConfig);
|
|
1288
|
-
validateConfig(merged);
|
|
1289
|
-
return new Schedule(merged);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// ============================================================================
|
|
1293
|
-
// Internal: Build merged config from user input
|
|
1294
|
-
// ============================================================================
|
|
1295
|
-
|
|
1296
|
-
function buildMergedConfig(config: ScheduleConfig): MergedScheduleConfig {
|
|
1297
|
-
return {
|
|
1298
|
-
roleIds: [...config.roleIds],
|
|
1299
|
-
skillIds: [...(config.skillIds ?? [])],
|
|
1300
|
-
times: { ...config.times },
|
|
1301
|
-
coverage: [...config.coverage],
|
|
1302
|
-
shiftPatterns: [...config.shiftPatterns],
|
|
1303
|
-
rules: [...(config.rules ?? [])],
|
|
1304
|
-
ruleFactories: config.ruleFactories ? { ...config.ruleFactories } : {},
|
|
1305
|
-
members: [...(config.members ?? [])],
|
|
1306
|
-
dayOfWeek: config.dayOfWeek,
|
|
1307
|
-
weekStartsOn: config.weekStartsOn,
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// ============================================================================
|
|
1312
|
-
// Internal: Validate merged config
|
|
1313
|
-
// ============================================================================
|
|
1314
|
-
|
|
1315
|
-
function validateConfig(config: MergedScheduleConfig): void {
|
|
1316
|
-
const roles = new Set<string>(config.roleIds);
|
|
1317
|
-
const skills = new Set<string>(config.skillIds);
|
|
1318
|
-
|
|
1319
|
-
// Validate custom rule factories don't override built-in names
|
|
1320
|
-
for (const name of Object.keys(config.ruleFactories)) {
|
|
1321
|
-
if (name in builtInCpsatRuleFactories) {
|
|
1322
|
-
throw new Error(
|
|
1323
|
-
`Custom rule factory "${name}" conflicts with a built-in rule. Choose a different name.`,
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Validate role/skill disjointness
|
|
1329
|
-
for (const skill of skills) {
|
|
1330
|
-
if (roles.has(skill)) {
|
|
1331
|
-
throw new Error(
|
|
1332
|
-
`"${skill}" is declared as both a role and a skill. Roles and skills must be disjoint.`,
|
|
1333
|
-
);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// Validate shift pattern role references
|
|
1338
|
-
for (const sp of config.shiftPatterns) {
|
|
1339
|
-
if (sp.roleIds) {
|
|
1340
|
-
for (const role of sp.roleIds) {
|
|
1341
|
-
if (!roles.has(role)) {
|
|
1342
|
-
throw new Error(
|
|
1343
|
-
`Shift pattern "${sp.id}" references unknown role "${role}". ` +
|
|
1344
|
-
`Declared roles: ${[...roles].join(", ")}`,
|
|
1345
|
-
);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
// Validate coverage entries
|
|
1352
|
-
for (const entry of config.coverage) {
|
|
1353
|
-
validateCoverageEntry(entry, roles, skills);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// Validate member references
|
|
1357
|
-
const memberIds = new Set<string>();
|
|
1358
|
-
for (const member of config.members) {
|
|
1359
|
-
if (memberIds.has(member.id)) {
|
|
1360
|
-
throw new Error(`Duplicate member ID "${member.id}".`);
|
|
1361
|
-
}
|
|
1362
|
-
memberIds.add(member.id);
|
|
1363
|
-
|
|
1364
|
-
if (roles.has(member.id)) {
|
|
1365
|
-
throw new Error(`Member ID "${member.id}" collides with a declared role name.`);
|
|
1366
|
-
}
|
|
1367
|
-
if (skills.has(member.id)) {
|
|
1368
|
-
throw new Error(`Member ID "${member.id}" collides with a declared skill name.`);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
for (const role of member.roleIds) {
|
|
1372
|
-
if (!roles.has(role)) {
|
|
1373
|
-
throw new Error(
|
|
1374
|
-
`Member "${member.id}" references unknown role "${role}". ` +
|
|
1375
|
-
`Declared roles: ${[...roles].join(", ")}`,
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
if (member.skillIds) {
|
|
1380
|
-
for (const skill of member.skillIds) {
|
|
1381
|
-
if (!skills.has(skill)) {
|
|
1382
|
-
throw new Error(
|
|
1383
|
-
`Member "${member.id}" references unknown skill "${skill}". ` +
|
|
1384
|
-
`Declared skills: ${[...skills].join(", ")}`,
|
|
1385
|
-
);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function validateCoverageEntry(
|
|
1393
|
-
entry: CoverageEntry,
|
|
1394
|
-
roles: Set<string>,
|
|
1395
|
-
skills: Set<string>,
|
|
1396
|
-
): void {
|
|
1397
|
-
const targets = Array.isArray(entry.target) ? entry.target : [entry.target];
|
|
1398
|
-
if (Array.isArray(entry.target)) {
|
|
1399
|
-
for (const target of targets) {
|
|
1400
|
-
if (!roles.has(target)) {
|
|
1401
|
-
throw new Error(
|
|
1402
|
-
`Coverage for "${entry.timeName}" references "${target}" in a role OR group, ` +
|
|
1403
|
-
`but it is not a declared role. Declared roles: ${[...roles].join(", ")}`,
|
|
1404
|
-
);
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
} else {
|
|
1408
|
-
if (!roles.has(entry.target) && !skills.has(entry.target)) {
|
|
1409
|
-
throw new Error(
|
|
1410
|
-
`Coverage for "${entry.timeName}" references unknown target "${entry.target}". ` +
|
|
1411
|
-
`Declared roles: ${[...roles].join(", ")}. ` +
|
|
1412
|
-
`Declared skills: ${[...skills].join(", ")}`,
|
|
1413
|
-
);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
if (entry.options.skillIds) {
|
|
1417
|
-
for (const s of entry.options.skillIds) {
|
|
1418
|
-
if (!skills.has(s)) {
|
|
1419
|
-
throw new Error(
|
|
1420
|
-
`Coverage for "${entry.timeName}" uses skill filter "${s}" ` +
|
|
1421
|
-
`which is not a declared skill. Declared skills: ${[...skills].join(", ")}`,
|
|
1422
|
-
);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// ============================================================================
|
|
1429
|
-
// Internal: Merge logic
|
|
1430
|
-
// ============================================================================
|
|
1431
|
-
|
|
1432
|
-
function mergeConfig(base: Readonly<MergedScheduleConfig>, args: WithArg[]): MergedScheduleConfig {
|
|
1433
|
-
const result: MergedScheduleConfig = {
|
|
1434
|
-
roleIds: [...base.roleIds],
|
|
1435
|
-
skillIds: [...base.skillIds],
|
|
1436
|
-
times: { ...base.times },
|
|
1437
|
-
coverage: [...base.coverage],
|
|
1438
|
-
shiftPatterns: [...base.shiftPatterns],
|
|
1439
|
-
rules: [...base.rules],
|
|
1440
|
-
ruleFactories: { ...base.ruleFactories },
|
|
1441
|
-
members: [...base.members],
|
|
1442
|
-
dayOfWeek: base.dayOfWeek,
|
|
1443
|
-
weekStartsOn: base.weekStartsOn,
|
|
1444
|
-
};
|
|
1445
|
-
|
|
1446
|
-
for (const arg of args) {
|
|
1447
|
-
if (arg instanceof Schedule) {
|
|
1448
|
-
mergeScheduleFragment(result, arg);
|
|
1449
|
-
} else if (Array.isArray(arg)) {
|
|
1450
|
-
mergeMembers(result, arg);
|
|
1451
|
-
} else {
|
|
1452
|
-
throw new Error(
|
|
1453
|
-
`Unexpected argument passed to .with(): expected Schedule or SchedulingMember[], got ${typeof arg}`,
|
|
1454
|
-
);
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
// Validate the merged result
|
|
1459
|
-
validateConfig(result);
|
|
1460
|
-
return result;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
function mergeScheduleFragment(result: MergedScheduleConfig, s: Schedule): void {
|
|
1464
|
-
const other = s._getConfig();
|
|
1465
|
-
|
|
1466
|
-
// dayOfWeek: error on conflict (semantics of union vs intersection are ambiguous)
|
|
1467
|
-
if (other.dayOfWeek !== undefined) {
|
|
1468
|
-
if (result.dayOfWeek !== undefined) {
|
|
1469
|
-
const baseSet = new Set(result.dayOfWeek);
|
|
1470
|
-
const same =
|
|
1471
|
-
result.dayOfWeek.length === other.dayOfWeek.length &&
|
|
1472
|
-
other.dayOfWeek.every((d) => baseSet.has(d));
|
|
1473
|
-
if (!same) {
|
|
1474
|
-
throw new Error(
|
|
1475
|
-
"Cannot merge schedules with different dayOfWeek filters. " +
|
|
1476
|
-
`Base has [${result.dayOfWeek.join(", ")}], ` +
|
|
1477
|
-
`incoming has [${other.dayOfWeek.join(", ")}].`,
|
|
1478
|
-
);
|
|
1479
|
-
}
|
|
1480
|
-
} else {
|
|
1481
|
-
result.dayOfWeek = other.dayOfWeek;
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// weekStartsOn: error on conflict (only one week boundary for weekly rules)
|
|
1486
|
-
if (other.weekStartsOn !== undefined) {
|
|
1487
|
-
if (result.weekStartsOn !== undefined && result.weekStartsOn !== other.weekStartsOn) {
|
|
1488
|
-
throw new Error(
|
|
1489
|
-
"Cannot merge schedules with different weekStartsOn values. " +
|
|
1490
|
-
`Base has "${result.weekStartsOn}", incoming has "${other.weekStartsOn}".`,
|
|
1491
|
-
);
|
|
1492
|
-
}
|
|
1493
|
-
result.weekStartsOn = other.weekStartsOn;
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// Roles: union
|
|
1497
|
-
for (const role of other.roleIds) {
|
|
1498
|
-
if (!result.roleIds.includes(role)) {
|
|
1499
|
-
result.roleIds.push(role);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
// Skills: union
|
|
1504
|
-
for (const skill of other.skillIds) {
|
|
1505
|
-
if (!result.skillIds.includes(skill)) {
|
|
1506
|
-
result.skillIds.push(skill);
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// Times: additive, error on collision
|
|
1511
|
-
for (const [name, entry] of Object.entries(other.times)) {
|
|
1512
|
-
if (name in result.times) {
|
|
1513
|
-
throw new Error(
|
|
1514
|
-
`Time name "${name}" already exists. Cannot merge schedules with colliding time names.`,
|
|
1515
|
-
);
|
|
1516
|
-
}
|
|
1517
|
-
result.times[name] = entry;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// Coverage: additive
|
|
1521
|
-
result.coverage.push(...other.coverage);
|
|
1522
|
-
|
|
1523
|
-
// Shift patterns: additive, error on ID collision
|
|
1524
|
-
const existingIds = new Set(result.shiftPatterns.map((sp) => sp.id));
|
|
1525
|
-
for (const sp of other.shiftPatterns) {
|
|
1526
|
-
if (existingIds.has(sp.id)) {
|
|
1527
|
-
throw new Error(
|
|
1528
|
-
`Shift pattern ID "${sp.id}" already exists. Cannot merge schedules with colliding shift pattern IDs.`,
|
|
1529
|
-
);
|
|
1530
|
-
}
|
|
1531
|
-
result.shiftPatterns.push(sp);
|
|
1532
|
-
existingIds.add(sp.id);
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
// Rules: additive
|
|
1536
|
-
result.rules.push(...other.rules);
|
|
1537
|
-
|
|
1538
|
-
// Rule factories: merge, error on collision
|
|
1539
|
-
for (const [name, factory] of Object.entries(other.ruleFactories)) {
|
|
1540
|
-
if (name in result.ruleFactories && result.ruleFactories[name] !== factory) {
|
|
1541
|
-
throw new Error(
|
|
1542
|
-
`Rule factory "${name}" already registered. Cannot merge schedules with colliding rule factories.`,
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
1545
|
-
result.ruleFactories[name] = factory;
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
// Members: additive, error on duplicate ID
|
|
1549
|
-
const existingMemberIds = new Set(result.members.map((m) => m.id));
|
|
1550
|
-
for (const member of other.members) {
|
|
1551
|
-
if (existingMemberIds.has(member.id)) {
|
|
1552
|
-
throw new Error(
|
|
1553
|
-
`Duplicate member ID "${member.id}". Cannot merge schedules with colliding member IDs.`,
|
|
1554
|
-
);
|
|
1555
|
-
}
|
|
1556
|
-
result.members.push(member);
|
|
1557
|
-
existingMemberIds.add(member.id);
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
function mergeMembers(result: MergedScheduleConfig, incoming: SchedulingMember[]): void {
|
|
1562
|
-
const existingIds = new Set(result.members.map((m) => m.id));
|
|
1563
|
-
for (const member of incoming) {
|
|
1564
|
-
if (existingIds.has(member.id)) {
|
|
1565
|
-
throw new Error(
|
|
1566
|
-
`Duplicate member ID "${member.id}". Cannot merge members with colliding IDs.`,
|
|
1567
|
-
);
|
|
1568
|
-
}
|
|
1569
|
-
result.members.push(member);
|
|
1570
|
-
existingIds.add(member.id);
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// ============================================================================
|
|
1575
|
-
// Internal: Resolve to ModelBuilderConfig
|
|
1576
|
-
// ============================================================================
|
|
1577
|
-
|
|
1578
|
-
function resolveToModelConfig(
|
|
1579
|
-
config: Readonly<MergedScheduleConfig>,
|
|
1580
|
-
options: SolveOptions,
|
|
1581
|
-
): ModelBuilderConfig {
|
|
1582
|
-
const roles = new Set<string>(config.roleIds);
|
|
1583
|
-
const skills = new Set<string>(config.skillIds);
|
|
1584
|
-
const memberIds = new Set<string>(config.members.map((m) => m.id));
|
|
1585
|
-
|
|
1586
|
-
// Build semantic time context
|
|
1587
|
-
const semanticTimes = defineSemanticTimes(config.times);
|
|
1588
|
-
|
|
1589
|
-
// Convert coverage entries to semantic coverage requirements
|
|
1590
|
-
const coverageReqs = buildCoverageRequirements(config.coverage, roles, skills);
|
|
1591
|
-
|
|
1592
|
-
// Resolve scheduling period with dayOfWeek filter
|
|
1593
|
-
const schedulingPeriod: SchedulingPeriod = {
|
|
1594
|
-
dateRange: options.dateRange,
|
|
1595
|
-
};
|
|
1596
|
-
const resolvedPeriod = applyDaysFilter(schedulingPeriod, config.dayOfWeek);
|
|
1597
|
-
const days = resolveDaysFromPeriod(resolvedPeriod);
|
|
1598
|
-
|
|
1599
|
-
// Resolve coverage
|
|
1600
|
-
const resolvedCoverage = semanticTimes.resolve(coverageReqs, days);
|
|
1601
|
-
|
|
1602
|
-
// Resolve rules
|
|
1603
|
-
const allRules = [...config.rules];
|
|
1604
|
-
|
|
1605
|
-
// Validate pay data when cost rules are present
|
|
1606
|
-
const costRuleNames = new Set([
|
|
1607
|
-
"minimize-cost",
|
|
1608
|
-
"day-cost-multiplier",
|
|
1609
|
-
"day-cost-surcharge",
|
|
1610
|
-
"time-cost-surcharge",
|
|
1611
|
-
"overtime-weekly-multiplier",
|
|
1612
|
-
"overtime-weekly-surcharge",
|
|
1613
|
-
"overtime-daily-multiplier",
|
|
1614
|
-
"overtime-daily-surcharge",
|
|
1615
|
-
"overtime-tiered-multiplier",
|
|
1616
|
-
]);
|
|
1617
|
-
const hasCostRules = allRules.some((r) => costRuleNames.has(r._rule));
|
|
1618
|
-
if (hasCostRules) {
|
|
1619
|
-
const missingPay = config.members.filter((m) => !m.pay).map((m) => m.id);
|
|
1620
|
-
if (missingPay.length > 0) {
|
|
1621
|
-
throw new Error(
|
|
1622
|
-
`Cost rules require pay data on all members. Missing pay: ${missingPay.join(", ")}`,
|
|
1623
|
-
);
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
// Sort rules so minimize-cost compiles before modifier rules
|
|
1628
|
-
const sortedRules = sortCostRulesFirst(allRules);
|
|
1629
|
-
const ruleConfigs = resolveRules(sortedRules, roles, skills, memberIds);
|
|
1630
|
-
|
|
1631
|
-
return {
|
|
1632
|
-
members: config.members,
|
|
1633
|
-
shiftPatterns: config.shiftPatterns,
|
|
1634
|
-
schedulingPeriod: resolvedPeriod,
|
|
1635
|
-
coverage: resolvedCoverage,
|
|
1636
|
-
ruleConfigs,
|
|
1637
|
-
ruleFactories:
|
|
1638
|
-
Object.keys(config.ruleFactories).length > 0
|
|
1639
|
-
? { ...builtInCpsatRuleFactories, ...config.ruleFactories }
|
|
1640
|
-
: undefined,
|
|
1641
|
-
weekStartsOn: config.weekStartsOn,
|
|
1642
|
-
};
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
// ============================================================================
|
|
1646
|
-
// Internal: Build SolveResult from solver response
|
|
1647
|
-
// ============================================================================
|
|
1648
|
-
|
|
1649
|
-
function mapSolverStatus(solverStatus: SolverResponse["status"]): SolveStatus {
|
|
1650
|
-
switch (solverStatus) {
|
|
1651
|
-
case "OPTIMAL":
|
|
1652
|
-
return "optimal";
|
|
1653
|
-
case "FEASIBLE":
|
|
1654
|
-
return "feasible";
|
|
1655
|
-
case "INFEASIBLE":
|
|
1656
|
-
return "infeasible";
|
|
1657
|
-
case "TIMEOUT":
|
|
1658
|
-
case "ERROR":
|
|
1659
|
-
return "no_solution";
|
|
1660
|
-
default:
|
|
1661
|
-
return "no_solution";
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
function buildSolveResult(
|
|
1666
|
-
response: SolverResponse,
|
|
1667
|
-
compiled: CompilationResult & { builder: ModelBuilder },
|
|
1668
|
-
config: Readonly<MergedScheduleConfig>,
|
|
1669
|
-
): SolveResult {
|
|
1670
|
-
const status = mapSolverStatus(response.status);
|
|
1671
|
-
const parsed = parseSolverResponse(response);
|
|
1672
|
-
|
|
1673
|
-
// Run post-solve validation when a solution exists
|
|
1674
|
-
if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
|
|
1675
|
-
const resolved = resolveAssignments(parsed.assignments, compiled.builder.shiftPatterns);
|
|
1676
|
-
compiled.builder.reporter.analyzeSolution(response);
|
|
1677
|
-
compiled.builder.validateSolution(resolved);
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
const validation = compiled.builder.reporter.getValidation();
|
|
1681
|
-
|
|
1682
|
-
const result: SolveResult = {
|
|
1683
|
-
status,
|
|
1684
|
-
assignments: parsed.assignments,
|
|
1685
|
-
validation,
|
|
1686
|
-
};
|
|
1687
|
-
|
|
1688
|
-
// Compute cost breakdown when cost rules are present and a solution was found
|
|
1689
|
-
if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
|
|
1690
|
-
const hasCostRules = config.rules.some((r) => r._rule === "minimize-cost");
|
|
1691
|
-
if (hasCostRules) {
|
|
1692
|
-
result.cost = calculateScheduleCost(parsed.assignments, {
|
|
1693
|
-
members: config.members,
|
|
1694
|
-
shiftPatterns: config.shiftPatterns,
|
|
1695
|
-
rules: compiled.builder.rules,
|
|
1696
|
-
});
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
return result;
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
// ============================================================================
|
|
1704
|
-
// Internal: Coverage Translation
|
|
1705
|
-
// ============================================================================
|
|
1706
|
-
|
|
1707
|
-
function buildCoverageRequirements<T extends string>(
|
|
1708
|
-
entries: CoverageEntry<T, string>[],
|
|
1709
|
-
roles: Set<string>,
|
|
1710
|
-
skills: Set<string>,
|
|
1711
|
-
): MixedCoverageRequirement<T>[] {
|
|
1712
|
-
return entries.map((entry) => {
|
|
1713
|
-
// Variant form: produce a VariantCoverageRequirement
|
|
1714
|
-
if (entry.variants) {
|
|
1715
|
-
return buildVariantCoverageRequirement(entry, roles, skills);
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
// Simple form: produce a SemanticCoverageRequirement
|
|
1719
|
-
const base: {
|
|
1720
|
-
semanticTime: T;
|
|
1721
|
-
targetCount: number;
|
|
1722
|
-
priority?: Priority;
|
|
1723
|
-
dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
|
|
1724
|
-
dates?: string[];
|
|
1725
|
-
} = {
|
|
1726
|
-
semanticTime: entry.timeName,
|
|
1727
|
-
targetCount: entry.count,
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
|
-
if (entry.options.priority) base.priority = entry.options.priority;
|
|
1731
|
-
if (entry.options.dayOfWeek && entry.options.dayOfWeek.length > 0) {
|
|
1732
|
-
base.dayOfWeek = entry.options.dayOfWeek as [DayOfWeek, ...DayOfWeek[]];
|
|
1733
|
-
}
|
|
1734
|
-
if (entry.options.dates) base.dates = entry.options.dates;
|
|
1735
|
-
|
|
1736
|
-
return buildSimpleCoverageTarget(entry, base, roles, skills);
|
|
1737
|
-
}) as MixedCoverageRequirement<T>[];
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/**
|
|
1741
|
-
* Resolve the target (role/skill) for a simple coverage entry.
|
|
1742
|
-
*/
|
|
1743
|
-
function buildSimpleCoverageTarget<T extends string>(
|
|
1744
|
-
entry: CoverageEntry<T, string>,
|
|
1745
|
-
base: {
|
|
1746
|
-
semanticTime: T;
|
|
1747
|
-
targetCount: number;
|
|
1748
|
-
priority?: Priority;
|
|
1749
|
-
dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
|
|
1750
|
-
dates?: string[];
|
|
1751
|
-
},
|
|
1752
|
-
roles: Set<string>,
|
|
1753
|
-
skills: Set<string>,
|
|
1754
|
-
): MixedCoverageRequirement<T> {
|
|
1755
|
-
if (Array.isArray(entry.target)) {
|
|
1756
|
-
return {
|
|
1757
|
-
...base,
|
|
1758
|
-
roleIds: entry.target as [string, ...string[]],
|
|
1759
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
const singleTarget = entry.target as string;
|
|
1763
|
-
if (roles.has(singleTarget)) {
|
|
1764
|
-
if (entry.options.skillIds) {
|
|
1765
|
-
return {
|
|
1766
|
-
...base,
|
|
1767
|
-
roleIds: [singleTarget] as [string, ...string[]],
|
|
1768
|
-
skillIds: entry.options.skillIds,
|
|
1769
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1770
|
-
}
|
|
1771
|
-
return {
|
|
1772
|
-
...base,
|
|
1773
|
-
roleIds: [singleTarget] as [string, ...string[]],
|
|
1774
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
if (skills.has(singleTarget)) {
|
|
1778
|
-
return {
|
|
1779
|
-
...base,
|
|
1780
|
-
skillIds: [singleTarget] as [string, ...string[]],
|
|
1781
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
/**
|
|
1788
|
-
* Build a VariantCoverageRequirement from a variant-form CoverageEntry.
|
|
1789
|
-
*/
|
|
1790
|
-
function buildVariantCoverageRequirement<T extends string>(
|
|
1791
|
-
entry: CoverageEntry<T, string>,
|
|
1792
|
-
roles: Set<string>,
|
|
1793
|
-
skills: Set<string>,
|
|
1794
|
-
): MixedCoverageRequirement<T> {
|
|
1795
|
-
const variants = entry.variants! as unknown as [CoverageVariant, ...CoverageVariant[]];
|
|
1796
|
-
|
|
1797
|
-
const resolveTarget = (): {
|
|
1798
|
-
roleIds?: [string, ...string[]];
|
|
1799
|
-
skillIds?: [string, ...string[]];
|
|
1800
|
-
} => {
|
|
1801
|
-
if (Array.isArray(entry.target)) {
|
|
1802
|
-
return { roleIds: entry.target as [string, ...string[]] };
|
|
1803
|
-
}
|
|
1804
|
-
const singleTarget = entry.target as string;
|
|
1805
|
-
if (roles.has(singleTarget)) {
|
|
1806
|
-
return { roleIds: [singleTarget] as [string, ...string[]] };
|
|
1807
|
-
}
|
|
1808
|
-
if (skills.has(singleTarget)) {
|
|
1809
|
-
return { skillIds: [singleTarget] as [string, ...string[]] };
|
|
1810
|
-
}
|
|
1811
|
-
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
1812
|
-
};
|
|
1813
|
-
|
|
1814
|
-
return {
|
|
1815
|
-
semanticTime: entry.timeName,
|
|
1816
|
-
variants,
|
|
1817
|
-
...resolveTarget(),
|
|
1818
|
-
} as MixedCoverageRequirement<T>;
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
// ============================================================================
|
|
1822
|
-
// Internal: Rule Translation
|
|
1823
|
-
// ============================================================================
|
|
1824
|
-
|
|
1825
|
-
/**
|
|
1826
|
-
* Resolves an `appliesTo` value into entity scope fields.
|
|
1827
|
-
*
|
|
1828
|
-
* Each target string is checked against roles, skills, then member IDs.
|
|
1829
|
-
* If all targets resolve to the same namespace, they are combined into one
|
|
1830
|
-
* scope field. If they span namespaces, an error is thrown; the caller
|
|
1831
|
-
* should use separate rule entries instead.
|
|
1832
|
-
*/
|
|
1833
|
-
function resolveAppliesTo(
|
|
1834
|
-
appliesTo: string | string[] | undefined,
|
|
1835
|
-
roles: ReadonlySet<string>,
|
|
1836
|
-
skills: ReadonlySet<string>,
|
|
1837
|
-
memberIds: ReadonlySet<string>,
|
|
1838
|
-
): {
|
|
1839
|
-
memberIds?: [string, ...string[]];
|
|
1840
|
-
roleIds?: [string, ...string[]];
|
|
1841
|
-
skillIds?: [string, ...string[]];
|
|
1842
|
-
} {
|
|
1843
|
-
if (!appliesTo) return {};
|
|
1844
|
-
|
|
1845
|
-
const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
|
|
1846
|
-
if (targets.length === 0) return {};
|
|
1847
|
-
|
|
1848
|
-
const resolvedRoles: string[] = [];
|
|
1849
|
-
const resolvedSkills: string[] = [];
|
|
1850
|
-
const resolvedMembers: string[] = [];
|
|
1851
|
-
|
|
1852
|
-
for (const target of targets) {
|
|
1853
|
-
if (roles.has(target)) {
|
|
1854
|
-
resolvedRoles.push(target);
|
|
1855
|
-
} else if (skills.has(target)) {
|
|
1856
|
-
resolvedSkills.push(target);
|
|
1857
|
-
} else if (memberIds.has(target)) {
|
|
1858
|
-
resolvedMembers.push(target);
|
|
1859
|
-
} else {
|
|
1860
|
-
throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
// Count how many namespaces were used
|
|
1865
|
-
const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
|
|
1866
|
-
(arr) => arr.length > 0,
|
|
1867
|
-
).length;
|
|
1868
|
-
|
|
1869
|
-
if (namespacesUsed > 1) {
|
|
1870
|
-
throw new Error(
|
|
1871
|
-
`appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
|
|
1872
|
-
`skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
|
|
1873
|
-
`Use separate rule entries for each namespace.`,
|
|
1874
|
-
);
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
if (resolvedRoles.length > 0) {
|
|
1878
|
-
return { roleIds: resolvedRoles as [string, ...string[]] };
|
|
1879
|
-
}
|
|
1880
|
-
if (resolvedSkills.length > 0) {
|
|
1881
|
-
return { skillIds: resolvedSkills as [string, ...string[]] };
|
|
1882
|
-
}
|
|
1883
|
-
if (resolvedMembers.length > 0) {
|
|
1884
|
-
return { memberIds: resolvedMembers as [string, ...string[]] };
|
|
1885
|
-
}
|
|
1886
|
-
return {};
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
function resolveRules(
|
|
1890
|
-
rules: RuleEntry[],
|
|
1891
|
-
roles: Set<string>,
|
|
1892
|
-
skills: Set<string>,
|
|
1893
|
-
memberIds: Set<string>,
|
|
1894
|
-
): CpsatRuleConfigEntry[] {
|
|
1895
|
-
const ctx: RuleResolveContext = { roles, skills, memberIds };
|
|
1896
|
-
|
|
1897
|
-
return rules.map((rule) => {
|
|
1898
|
-
// Rules with custom resolvers handle their own translation
|
|
1899
|
-
if (rule._resolve) {
|
|
1900
|
-
return rule._resolve(ctx) as CpsatRuleConfigEntry;
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
// Default resolution: appliesTo → entity scope, dates → specificDates
|
|
1904
|
-
const { _type, _rule, _resolve, appliesTo, dates, ...passthrough } = rule as RuleEntry & {
|
|
1905
|
-
appliesTo?: string | string[];
|
|
1906
|
-
dates?: string[];
|
|
1907
|
-
};
|
|
1908
|
-
|
|
1909
|
-
const entityScope = resolveAppliesTo(appliesTo, roles, skills, memberIds);
|
|
1910
|
-
const resolvedDates = dates ? { specificDates: dates } : {};
|
|
1911
|
-
|
|
1912
|
-
return {
|
|
1913
|
-
name: _rule,
|
|
1914
|
-
...passthrough,
|
|
1915
|
-
...entityScope,
|
|
1916
|
-
...resolvedDates,
|
|
1917
|
-
} as CpsatRuleConfigEntry;
|
|
1918
|
-
}) as CpsatRuleConfigEntry[];
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
// ============================================================================
|
|
1922
|
-
// Internal: Cost Rule Ordering
|
|
1923
|
-
// ============================================================================
|
|
1924
|
-
|
|
1925
|
-
/**
|
|
1926
|
-
* Sorts rules so that `minimize-cost` compiles before cost modifier rules.
|
|
1927
|
-
*
|
|
1928
|
-
* The `minimize-cost` rule must be compiled first because modifier rules
|
|
1929
|
-
* (multipliers, surcharges) reference cost variables it creates.
|
|
1930
|
-
* Non-cost rules retain their original relative order.
|
|
1931
|
-
*/
|
|
1932
|
-
function sortCostRulesFirst(rules: RuleEntry[]): RuleEntry[] {
|
|
1933
|
-
return rules.toSorted((a, b) => {
|
|
1934
|
-
const aIsCostBase = a._rule === "minimize-cost" ? 0 : 1;
|
|
1935
|
-
const bIsCostBase = b._rule === "minimize-cost" ? 0 : 1;
|
|
1936
|
-
return aIsCostBase - bIsCostBase;
|
|
1937
|
-
});
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
// ============================================================================
|
|
1941
|
-
// Internal: Scheduling Period Helpers
|
|
1942
|
-
// ============================================================================
|
|
1943
|
-
|
|
1944
|
-
function applyDaysFilter(
|
|
1945
|
-
schedulingPeriod: SchedulingPeriod,
|
|
1946
|
-
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]],
|
|
1947
|
-
): SchedulingPeriod {
|
|
1948
|
-
if (!dayOfWeek || dayOfWeek.length === 0) {
|
|
1949
|
-
return schedulingPeriod;
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
const existingDays = schedulingPeriod.dayOfWeek;
|
|
1953
|
-
if (!existingDays || existingDays.length === 0) {
|
|
1954
|
-
return { ...schedulingPeriod, dayOfWeek: [...dayOfWeek] };
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
const existingSet = new Set(existingDays);
|
|
1958
|
-
const intersected = dayOfWeek.filter((day) => existingSet.has(day));
|
|
1959
|
-
return { ...schedulingPeriod, dayOfWeek: intersected };
|
|
1960
|
-
}
|