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
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule definition, compilation, and solving.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DayOfWeek, SchedulingPeriod } from "../types.js";
|
|
8
|
+
import type { SemanticTimeEntry } from "../cpsat/semantic-time.js";
|
|
9
|
+
import type { MixedCoverageRequirement, CoverageVariant } from "../cpsat/semantic-time.js";
|
|
10
|
+
import { defineSemanticTimes } from "../cpsat/semantic-time.js";
|
|
11
|
+
import { resolveDaysFromPeriod } from "../datetime.utils.js";
|
|
12
|
+
import type { ModelBuilderConfig, CompilationResult } from "../cpsat/model-builder.js";
|
|
13
|
+
import { ModelBuilder } from "../cpsat/model-builder.js";
|
|
14
|
+
import type { SchedulingMember, ShiftPattern, Priority } from "../cpsat/types.js";
|
|
15
|
+
import type { CpsatRuleConfigEntry, CreateCpsatRuleFunction } from "../cpsat/rules/rules.types.js";
|
|
16
|
+
import { builtInCpsatRuleFactories } from "../cpsat/rules/registry.js";
|
|
17
|
+
import type { SolverClient, SolverResponse } from "../client.types.js";
|
|
18
|
+
import { parseSolverResponse, resolveAssignments } from "../cpsat/response.js";
|
|
19
|
+
import type { ShiftAssignment } from "../cpsat/response.js";
|
|
20
|
+
import type { ScheduleValidation } from "../cpsat/validation.types.js";
|
|
21
|
+
import { calculateScheduleCost } from "../cpsat/cost.js";
|
|
22
|
+
import type { CostBreakdown } from "../cpsat/cost.js";
|
|
23
|
+
|
|
24
|
+
import type { CoverageEntry } from "./coverage.js";
|
|
25
|
+
import type { RuleEntry, RuleResolveContext } from "./rules.js";
|
|
26
|
+
import { resolveAppliesTo } from "./rules.js";
|
|
27
|
+
|
|
28
|
+
/** A value that can be passed to {@link Schedule.with}. */
|
|
29
|
+
type WithArg = Schedule | SchedulingMember[];
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// SolveResult
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/** Status of a solve attempt, using idiomatic lowercase TypeScript literals. */
|
|
36
|
+
export type SolveStatus = "optimal" | "feasible" | "infeasible" | "no_solution";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of {@link Schedule.solve}.
|
|
40
|
+
*
|
|
41
|
+
* @category Schedule Definition
|
|
42
|
+
*/
|
|
43
|
+
export interface SolveResult {
|
|
44
|
+
/** Outcome of the solve attempt. */
|
|
45
|
+
status: SolveStatus;
|
|
46
|
+
/** Shift assignments (empty when infeasible or no solution). */
|
|
47
|
+
assignments: ShiftAssignment[];
|
|
48
|
+
/** Validation diagnostics from compilation. */
|
|
49
|
+
validation: ScheduleValidation;
|
|
50
|
+
/** Cost breakdown (present when cost rules are used and a solution is found). */
|
|
51
|
+
cost?: CostBreakdown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Options for {@link Schedule.solve} and {@link Schedule.compile}.
|
|
56
|
+
*
|
|
57
|
+
* @category Schedule Definition
|
|
58
|
+
*/
|
|
59
|
+
export interface SolveOptions {
|
|
60
|
+
/** The date range to schedule. */
|
|
61
|
+
dateRange: { start: string; end: string };
|
|
62
|
+
/**
|
|
63
|
+
* Fixed assignments from a prior solve (e.g., rolling schedule).
|
|
64
|
+
* These are injected as fixed variables in the solver.
|
|
65
|
+
*
|
|
66
|
+
* Not yet implemented. Providing pinned assignments throws an error.
|
|
67
|
+
*/
|
|
68
|
+
pinned?: ShiftAssignment[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Schedule Configuration
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Configuration for {@link schedule}.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* Coverage entries for the same semantic time and target stack additively.
|
|
80
|
+
* An unscoped entry applies every day; adding a weekend-only entry on top
|
|
81
|
+
* doubles the count on those days. Use mutually exclusive `dayOfWeek` on
|
|
82
|
+
* both entries to avoid stacking. See {@link cover} for details.
|
|
83
|
+
*
|
|
84
|
+
* `roleIds`, `times`, `coverage`, and `shiftPatterns` are required.
|
|
85
|
+
* These four fields form the minimum solvable schedule.
|
|
86
|
+
*
|
|
87
|
+
* @category Schedule Definition
|
|
88
|
+
*/
|
|
89
|
+
export interface ScheduleConfig<
|
|
90
|
+
R extends readonly string[] = readonly string[],
|
|
91
|
+
S extends readonly string[] = readonly [],
|
|
92
|
+
T extends Record<string, SemanticTimeEntry> = Record<string, SemanticTimeEntry>,
|
|
93
|
+
> {
|
|
94
|
+
/** Declared role IDs. */
|
|
95
|
+
roleIds: R;
|
|
96
|
+
/** Declared skill IDs. When omitted, coverage targets can only be roles. */
|
|
97
|
+
skillIds?: S;
|
|
98
|
+
/** Named semantic time periods. */
|
|
99
|
+
times: T;
|
|
100
|
+
/** Staffing requirements per time period (entries stack additively). */
|
|
101
|
+
coverage: NoInfer<CoverageEntry<keyof T & string, R[number] | S[number]>>[];
|
|
102
|
+
/** Available shift patterns. */
|
|
103
|
+
shiftPatterns: ShiftPattern[];
|
|
104
|
+
/** Scheduling rules and constraints. */
|
|
105
|
+
rules?: RuleEntry[];
|
|
106
|
+
/**
|
|
107
|
+
* Custom rule factories. Keys are rule names, values are functions
|
|
108
|
+
* that take a config object and return a {@link CompilationRule}.
|
|
109
|
+
* Built-in rule names cannot be overridden.
|
|
110
|
+
*/
|
|
111
|
+
ruleFactories?: Record<string, CreateCpsatRuleFunction>;
|
|
112
|
+
/** Team members (typically added via `.with()` at runtime). */
|
|
113
|
+
members?: SchedulingMember[];
|
|
114
|
+
/** Days of the week the business operates (inclusion filter). */
|
|
115
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
116
|
+
/** Which day starts the week for weekly rules. Defaults to `"monday"`. */
|
|
117
|
+
weekStartsOn?: DayOfWeek;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Internal merged config
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/** Internal representation of a fully merged schedule. */
|
|
125
|
+
interface MergedScheduleConfig {
|
|
126
|
+
roleIds: string[];
|
|
127
|
+
skillIds: string[];
|
|
128
|
+
times: Record<string, SemanticTimeEntry>;
|
|
129
|
+
coverage: CoverageEntry[];
|
|
130
|
+
shiftPatterns: ShiftPattern[];
|
|
131
|
+
rules: RuleEntry[];
|
|
132
|
+
ruleFactories: Record<string, CreateCpsatRuleFunction>;
|
|
133
|
+
members: SchedulingMember[];
|
|
134
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
135
|
+
weekStartsOn?: DayOfWeek;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Schedule class
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An immutable schedule definition.
|
|
144
|
+
*
|
|
145
|
+
* Created by {@link schedule}, composed via {@link Schedule.with},
|
|
146
|
+
* and solved via {@link Schedule.solve}.
|
|
147
|
+
*
|
|
148
|
+
* @category Schedule Definition
|
|
149
|
+
*/
|
|
150
|
+
export class Schedule {
|
|
151
|
+
readonly #config: Readonly<MergedScheduleConfig>;
|
|
152
|
+
|
|
153
|
+
/** @internal */
|
|
154
|
+
constructor(config: MergedScheduleConfig) {
|
|
155
|
+
this.#config = config;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @internal Returns a defensive copy of the config for merging. */
|
|
159
|
+
_getConfig(): MergedScheduleConfig {
|
|
160
|
+
return {
|
|
161
|
+
...this.#config,
|
|
162
|
+
roleIds: [...this.#config.roleIds],
|
|
163
|
+
skillIds: [...this.#config.skillIds],
|
|
164
|
+
times: { ...this.#config.times },
|
|
165
|
+
coverage: [...this.#config.coverage],
|
|
166
|
+
shiftPatterns: [...this.#config.shiftPatterns],
|
|
167
|
+
rules: [...this.#config.rules],
|
|
168
|
+
ruleFactories: { ...this.#config.ruleFactories },
|
|
169
|
+
members: [...this.#config.members],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --------------------------------------------------------------------------
|
|
174
|
+
// Inspection
|
|
175
|
+
// --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** Declared role IDs. */
|
|
178
|
+
get roleIds(): readonly string[] {
|
|
179
|
+
return this.#config.roleIds;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Declared skill IDs. */
|
|
183
|
+
get skillIds(): readonly string[] {
|
|
184
|
+
return this.#config.skillIds;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Names of declared semantic times. */
|
|
188
|
+
get timeNames(): readonly string[] {
|
|
189
|
+
return Object.keys(this.#config.times);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Shift pattern IDs. */
|
|
193
|
+
get shiftPatternIds(): readonly string[] {
|
|
194
|
+
return this.#config.shiftPatterns.map((sp) => sp.id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Internal rule identifiers in kebab-case. */
|
|
198
|
+
get ruleNames(): readonly string[] {
|
|
199
|
+
return this.#config.rules.map((r) => r._rule);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --------------------------------------------------------------------------
|
|
203
|
+
// Composition
|
|
204
|
+
// --------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merges schedules or members onto this schedule, returning a new
|
|
208
|
+
* immutable `Schedule`. The original is untouched.
|
|
209
|
+
*
|
|
210
|
+
* Accepts any mix of `Schedule` instances and `SchedulingMember[]` arrays.
|
|
211
|
+
*
|
|
212
|
+
* Merge semantics (when merging schedules):
|
|
213
|
+
* - Roles: union (additive)
|
|
214
|
+
* - Skills: union (additive)
|
|
215
|
+
* - Times: additive; error on name collision
|
|
216
|
+
* - Coverage: additive
|
|
217
|
+
* - Shift patterns: additive; error on ID collision
|
|
218
|
+
* - Rules: additive
|
|
219
|
+
* - Members: additive; error on duplicate ID
|
|
220
|
+
*
|
|
221
|
+
* Validation runs eagerly: role/skill disjointness, coverage targets
|
|
222
|
+
* referencing declared roles/skills, member role references, etc.
|
|
223
|
+
*/
|
|
224
|
+
with(...args: WithArg[]): Schedule {
|
|
225
|
+
const merged = mergeConfig(this.#config, args);
|
|
226
|
+
return new Schedule(merged);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --------------------------------------------------------------------------
|
|
230
|
+
// Solve / compile
|
|
231
|
+
// --------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compiles, validates, solves, and parses in one call.
|
|
235
|
+
*
|
|
236
|
+
* @param client - Solver client (e.g., `new HttpSolverClient(fetch, url)`)
|
|
237
|
+
* @param options - Date range and optional pinned assignments
|
|
238
|
+
*/
|
|
239
|
+
async solve(client: SolverClient, options: SolveOptions): Promise<SolveResult> {
|
|
240
|
+
const compiled = this.compile(options);
|
|
241
|
+
if (!compiled.canSolve) {
|
|
242
|
+
return {
|
|
243
|
+
status: "infeasible",
|
|
244
|
+
assignments: [],
|
|
245
|
+
validation: compiled.validation,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const response = await client.solve(compiled.request);
|
|
250
|
+
return buildSolveResult(response, compiled, this.#config);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Diagnostic escape hatch. Compiles the schedule without solving.
|
|
255
|
+
*
|
|
256
|
+
* @param options - Date range and optional pinned assignments
|
|
257
|
+
*/
|
|
258
|
+
compile(options: SolveOptions): CompilationResult & { builder: ModelBuilder } {
|
|
259
|
+
if (options.pinned && options.pinned.length > 0) {
|
|
260
|
+
throw new Error("Pinned assignments are not yet supported.");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const modelConfig = resolveToModelConfig(this.#config, options);
|
|
264
|
+
const builder = new ModelBuilder(modelConfig);
|
|
265
|
+
const result = builder.compile();
|
|
266
|
+
return { ...result, builder };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// schedule() factory
|
|
272
|
+
// ============================================================================
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create a schedule definition.
|
|
276
|
+
*
|
|
277
|
+
* Returns an immutable {@link Schedule} that can be composed via `.with()`
|
|
278
|
+
* and solved via `.solve()`.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```typescript
|
|
282
|
+
* const venue = schedule({
|
|
283
|
+
* roleIds: ["waiter", "runner", "manager"],
|
|
284
|
+
* skillIds: ["senior"],
|
|
285
|
+
* times: {
|
|
286
|
+
* lunch: time({ startTime: t(12), endTime: t(15) }),
|
|
287
|
+
* dinner: time(
|
|
288
|
+
* { startTime: t(17), endTime: t(21) },
|
|
289
|
+
* { startTime: t(18), endTime: t(22), dayOfWeek: weekend },
|
|
290
|
+
* ),
|
|
291
|
+
* },
|
|
292
|
+
* coverage: [
|
|
293
|
+
* cover("lunch", "waiter", 2),
|
|
294
|
+
* cover("dinner", "waiter", 4, { dayOfWeek: weekdays }),
|
|
295
|
+
* cover("dinner", "waiter", 5, { dayOfWeek: weekend }),
|
|
296
|
+
* cover("dinner", "manager", 1),
|
|
297
|
+
* ],
|
|
298
|
+
* shiftPatterns: [
|
|
299
|
+
* shift("lunch_shift", t(11, 30), t(15)),
|
|
300
|
+
* shift("evening", t(17), t(22)),
|
|
301
|
+
* ],
|
|
302
|
+
* rules: [
|
|
303
|
+
* maxHoursPerDay(10),
|
|
304
|
+
* maxHoursPerWeek(48),
|
|
305
|
+
* minRestBetweenShifts(11),
|
|
306
|
+
* ],
|
|
307
|
+
* });
|
|
308
|
+
* ```
|
|
309
|
+
*
|
|
310
|
+
* @category Schedule Definition
|
|
311
|
+
*/
|
|
312
|
+
export function schedule<
|
|
313
|
+
const R extends readonly string[],
|
|
314
|
+
const S extends readonly string[] = readonly [],
|
|
315
|
+
const T extends Record<string, SemanticTimeEntry> = Record<string, SemanticTimeEntry>,
|
|
316
|
+
>(config: ScheduleConfig<R, S, T>): Schedule {
|
|
317
|
+
const merged = buildMergedConfig(config as unknown as ScheduleConfig);
|
|
318
|
+
validateConfig(merged);
|
|
319
|
+
return new Schedule(merged);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Create a partial schedule for composition via `.with()`.
|
|
324
|
+
*
|
|
325
|
+
* Unlike {@link schedule}, all fields are optional. Use this for
|
|
326
|
+
* schedules that layer rules, coverage, or other config onto a
|
|
327
|
+
* complete base schedule.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```typescript
|
|
331
|
+
* const companyPolicy = partialSchedule({
|
|
332
|
+
* rules: [maxHoursPerWeek(40), minRestBetweenShifts(11)],
|
|
333
|
+
* });
|
|
334
|
+
*
|
|
335
|
+
* const ready = venue.with(companyPolicy, teamMembers);
|
|
336
|
+
* ```
|
|
337
|
+
*
|
|
338
|
+
* @category Schedule Definition
|
|
339
|
+
*/
|
|
340
|
+
export function partialSchedule(
|
|
341
|
+
config: Partial<ScheduleConfig<readonly string[], readonly string[]>>,
|
|
342
|
+
): Schedule {
|
|
343
|
+
const merged = buildMergedConfig({
|
|
344
|
+
roleIds: [],
|
|
345
|
+
times: {},
|
|
346
|
+
coverage: [],
|
|
347
|
+
shiftPatterns: [],
|
|
348
|
+
...config,
|
|
349
|
+
} as ScheduleConfig);
|
|
350
|
+
validateConfig(merged);
|
|
351
|
+
return new Schedule(merged);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ============================================================================
|
|
355
|
+
// Internal: Build merged config from user input
|
|
356
|
+
// ============================================================================
|
|
357
|
+
|
|
358
|
+
function buildMergedConfig(config: ScheduleConfig): MergedScheduleConfig {
|
|
359
|
+
return {
|
|
360
|
+
roleIds: [...config.roleIds],
|
|
361
|
+
skillIds: [...(config.skillIds ?? [])],
|
|
362
|
+
times: { ...config.times },
|
|
363
|
+
coverage: [...config.coverage],
|
|
364
|
+
shiftPatterns: [...config.shiftPatterns],
|
|
365
|
+
rules: [...(config.rules ?? [])],
|
|
366
|
+
ruleFactories: config.ruleFactories ? { ...config.ruleFactories } : {},
|
|
367
|
+
members: [...(config.members ?? [])],
|
|
368
|
+
dayOfWeek: config.dayOfWeek,
|
|
369
|
+
weekStartsOn: config.weekStartsOn,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Internal: Validate merged config
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
function validateConfig(config: MergedScheduleConfig): void {
|
|
378
|
+
const roles = new Set<string>(config.roleIds);
|
|
379
|
+
const skills = new Set<string>(config.skillIds);
|
|
380
|
+
|
|
381
|
+
// Validate custom rule factories don't override built-in names
|
|
382
|
+
for (const name of Object.keys(config.ruleFactories)) {
|
|
383
|
+
if (name in builtInCpsatRuleFactories) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Custom rule factory "${name}" conflicts with a built-in rule. Choose a different name.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Validate role/skill disjointness
|
|
391
|
+
for (const skill of skills) {
|
|
392
|
+
if (roles.has(skill)) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`"${skill}" is declared as both a role and a skill. Roles and skills must be disjoint.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Validate shift pattern role references
|
|
400
|
+
for (const sp of config.shiftPatterns) {
|
|
401
|
+
if (sp.roleIds) {
|
|
402
|
+
for (const role of sp.roleIds) {
|
|
403
|
+
if (!roles.has(role)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Shift pattern "${sp.id}" references unknown role "${role}". ` +
|
|
406
|
+
`Declared roles: ${[...roles].join(", ")}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Validate coverage entries
|
|
414
|
+
for (const entry of config.coverage) {
|
|
415
|
+
validateCoverageEntry(entry, roles, skills);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Validate member references
|
|
419
|
+
const memberIds = new Set<string>();
|
|
420
|
+
for (const member of config.members) {
|
|
421
|
+
if (memberIds.has(member.id)) {
|
|
422
|
+
throw new Error(`Duplicate member ID "${member.id}".`);
|
|
423
|
+
}
|
|
424
|
+
memberIds.add(member.id);
|
|
425
|
+
|
|
426
|
+
if (roles.has(member.id)) {
|
|
427
|
+
throw new Error(`Member ID "${member.id}" collides with a declared role name.`);
|
|
428
|
+
}
|
|
429
|
+
if (skills.has(member.id)) {
|
|
430
|
+
throw new Error(`Member ID "${member.id}" collides with a declared skill name.`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const role of member.roleIds) {
|
|
434
|
+
if (!roles.has(role)) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Member "${member.id}" references unknown role "${role}". ` +
|
|
437
|
+
`Declared roles: ${[...roles].join(", ")}`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (member.skillIds) {
|
|
442
|
+
for (const skill of member.skillIds) {
|
|
443
|
+
if (!skills.has(skill)) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`Member "${member.id}" references unknown skill "${skill}". ` +
|
|
446
|
+
`Declared skills: ${[...skills].join(", ")}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function validateCoverageEntry(
|
|
455
|
+
entry: CoverageEntry,
|
|
456
|
+
roles: Set<string>,
|
|
457
|
+
skills: Set<string>,
|
|
458
|
+
): void {
|
|
459
|
+
const targets = Array.isArray(entry.target) ? entry.target : [entry.target];
|
|
460
|
+
if (Array.isArray(entry.target)) {
|
|
461
|
+
for (const target of targets) {
|
|
462
|
+
if (!roles.has(target)) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Coverage for "${entry.timeName}" references "${target}" in a role OR group, ` +
|
|
465
|
+
`but it is not a declared role. Declared roles: ${[...roles].join(", ")}`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
if (!roles.has(entry.target) && !skills.has(entry.target)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Coverage for "${entry.timeName}" references unknown target "${entry.target}". ` +
|
|
473
|
+
`Declared roles: ${[...roles].join(", ")}. ` +
|
|
474
|
+
`Declared skills: ${[...skills].join(", ")}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (entry.options.skillIds) {
|
|
479
|
+
for (const s of entry.options.skillIds) {
|
|
480
|
+
if (!skills.has(s)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`Coverage for "${entry.timeName}" uses skill filter "${s}" ` +
|
|
483
|
+
`which is not a declared skill. Declared skills: ${[...skills].join(", ")}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// Internal: Merge logic
|
|
492
|
+
// ============================================================================
|
|
493
|
+
|
|
494
|
+
function mergeConfig(base: Readonly<MergedScheduleConfig>, args: WithArg[]): MergedScheduleConfig {
|
|
495
|
+
const result: MergedScheduleConfig = {
|
|
496
|
+
roleIds: [...base.roleIds],
|
|
497
|
+
skillIds: [...base.skillIds],
|
|
498
|
+
times: { ...base.times },
|
|
499
|
+
coverage: [...base.coverage],
|
|
500
|
+
shiftPatterns: [...base.shiftPatterns],
|
|
501
|
+
rules: [...base.rules],
|
|
502
|
+
ruleFactories: { ...base.ruleFactories },
|
|
503
|
+
members: [...base.members],
|
|
504
|
+
dayOfWeek: base.dayOfWeek,
|
|
505
|
+
weekStartsOn: base.weekStartsOn,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
for (const arg of args) {
|
|
509
|
+
if (arg instanceof Schedule) {
|
|
510
|
+
mergeScheduleFragment(result, arg);
|
|
511
|
+
} else if (Array.isArray(arg)) {
|
|
512
|
+
mergeMembers(result, arg);
|
|
513
|
+
} else {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`Unexpected argument passed to .with(): expected Schedule or SchedulingMember[], got ${typeof arg}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Validate the merged result
|
|
521
|
+
validateConfig(result);
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function mergeScheduleFragment(result: MergedScheduleConfig, s: Schedule): void {
|
|
526
|
+
const other = s._getConfig();
|
|
527
|
+
|
|
528
|
+
// dayOfWeek: error on conflict (semantics of union vs intersection are ambiguous)
|
|
529
|
+
if (other.dayOfWeek !== undefined) {
|
|
530
|
+
if (result.dayOfWeek !== undefined) {
|
|
531
|
+
const baseSet = new Set(result.dayOfWeek);
|
|
532
|
+
const same =
|
|
533
|
+
result.dayOfWeek.length === other.dayOfWeek.length &&
|
|
534
|
+
other.dayOfWeek.every((d) => baseSet.has(d));
|
|
535
|
+
if (!same) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
"Cannot merge schedules with different dayOfWeek filters. " +
|
|
538
|
+
`Base has [${result.dayOfWeek.join(", ")}], ` +
|
|
539
|
+
`incoming has [${other.dayOfWeek.join(", ")}].`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
result.dayOfWeek = other.dayOfWeek;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// weekStartsOn: error on conflict (only one week boundary for weekly rules)
|
|
548
|
+
if (other.weekStartsOn !== undefined) {
|
|
549
|
+
if (result.weekStartsOn !== undefined && result.weekStartsOn !== other.weekStartsOn) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
"Cannot merge schedules with different weekStartsOn values. " +
|
|
552
|
+
`Base has "${result.weekStartsOn}", incoming has "${other.weekStartsOn}".`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
result.weekStartsOn = other.weekStartsOn;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Roles: union
|
|
559
|
+
for (const role of other.roleIds) {
|
|
560
|
+
if (!result.roleIds.includes(role)) {
|
|
561
|
+
result.roleIds.push(role);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Skills: union
|
|
566
|
+
for (const skill of other.skillIds) {
|
|
567
|
+
if (!result.skillIds.includes(skill)) {
|
|
568
|
+
result.skillIds.push(skill);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Times: additive, error on collision
|
|
573
|
+
for (const [name, entry] of Object.entries(other.times)) {
|
|
574
|
+
if (name in result.times) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Time name "${name}" already exists. Cannot merge schedules with colliding time names.`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
result.times[name] = entry;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Coverage: additive
|
|
583
|
+
result.coverage.push(...other.coverage);
|
|
584
|
+
|
|
585
|
+
// Shift patterns: additive, error on ID collision
|
|
586
|
+
const existingIds = new Set(result.shiftPatterns.map((sp) => sp.id));
|
|
587
|
+
for (const sp of other.shiftPatterns) {
|
|
588
|
+
if (existingIds.has(sp.id)) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`Shift pattern ID "${sp.id}" already exists. Cannot merge schedules with colliding shift pattern IDs.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
result.shiftPatterns.push(sp);
|
|
594
|
+
existingIds.add(sp.id);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Rules: additive
|
|
598
|
+
result.rules.push(...other.rules);
|
|
599
|
+
|
|
600
|
+
// Rule factories: merge, error on collision
|
|
601
|
+
for (const [name, factory] of Object.entries(other.ruleFactories)) {
|
|
602
|
+
if (name in result.ruleFactories && result.ruleFactories[name] !== factory) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`Rule factory "${name}" already registered. Cannot merge schedules with colliding rule factories.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
result.ruleFactories[name] = factory;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Members: additive, error on duplicate ID
|
|
611
|
+
const existingMemberIds = new Set(result.members.map((m) => m.id));
|
|
612
|
+
for (const member of other.members) {
|
|
613
|
+
if (existingMemberIds.has(member.id)) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Duplicate member ID "${member.id}". Cannot merge schedules with colliding member IDs.`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
result.members.push(member);
|
|
619
|
+
existingMemberIds.add(member.id);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function mergeMembers(result: MergedScheduleConfig, incoming: SchedulingMember[]): void {
|
|
624
|
+
const existingIds = new Set(result.members.map((m) => m.id));
|
|
625
|
+
for (const member of incoming) {
|
|
626
|
+
if (existingIds.has(member.id)) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Duplicate member ID "${member.id}". Cannot merge members with colliding IDs.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
result.members.push(member);
|
|
632
|
+
existingIds.add(member.id);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ============================================================================
|
|
637
|
+
// Internal: Resolve to ModelBuilderConfig
|
|
638
|
+
// ============================================================================
|
|
639
|
+
|
|
640
|
+
function resolveToModelConfig(
|
|
641
|
+
config: Readonly<MergedScheduleConfig>,
|
|
642
|
+
options: SolveOptions,
|
|
643
|
+
): ModelBuilderConfig {
|
|
644
|
+
const roles = new Set<string>(config.roleIds);
|
|
645
|
+
const skills = new Set<string>(config.skillIds);
|
|
646
|
+
const memberIds = new Set<string>(config.members.map((m) => m.id));
|
|
647
|
+
|
|
648
|
+
// Build semantic time context
|
|
649
|
+
const semanticTimes = defineSemanticTimes(config.times);
|
|
650
|
+
|
|
651
|
+
// Convert coverage entries to semantic coverage requirements
|
|
652
|
+
const coverageReqs = buildCoverageRequirements(config.coverage, roles, skills);
|
|
653
|
+
|
|
654
|
+
// Resolve scheduling period with dayOfWeek filter
|
|
655
|
+
const schedulingPeriod: SchedulingPeriod = {
|
|
656
|
+
dateRange: options.dateRange,
|
|
657
|
+
};
|
|
658
|
+
const resolvedPeriod = applyDaysFilter(schedulingPeriod, config.dayOfWeek);
|
|
659
|
+
const days = resolveDaysFromPeriod(resolvedPeriod);
|
|
660
|
+
|
|
661
|
+
// Resolve coverage
|
|
662
|
+
const resolvedCoverage = semanticTimes.resolve(coverageReqs, days);
|
|
663
|
+
|
|
664
|
+
// Resolve rules
|
|
665
|
+
const allRules = [...config.rules];
|
|
666
|
+
|
|
667
|
+
// Validate pay data when cost rules are present
|
|
668
|
+
const costRuleNames = new Set([
|
|
669
|
+
"minimize-cost",
|
|
670
|
+
"day-cost-multiplier",
|
|
671
|
+
"day-cost-surcharge",
|
|
672
|
+
"time-cost-surcharge",
|
|
673
|
+
"overtime-weekly-multiplier",
|
|
674
|
+
"overtime-weekly-surcharge",
|
|
675
|
+
"overtime-daily-multiplier",
|
|
676
|
+
"overtime-daily-surcharge",
|
|
677
|
+
"overtime-tiered-multiplier",
|
|
678
|
+
]);
|
|
679
|
+
const hasCostRules = allRules.some((r) => costRuleNames.has(r._rule));
|
|
680
|
+
if (hasCostRules) {
|
|
681
|
+
const missingPay = config.members.filter((m) => !m.pay).map((m) => m.id);
|
|
682
|
+
if (missingPay.length > 0) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Cost rules require pay data on all members. Missing pay: ${missingPay.join(", ")}`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Sort rules so minimize-cost compiles before modifier rules
|
|
690
|
+
const sortedRules = sortCostRulesFirst(allRules);
|
|
691
|
+
const ruleConfigs = resolveRules(sortedRules, roles, skills, memberIds);
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
members: config.members,
|
|
695
|
+
shiftPatterns: config.shiftPatterns,
|
|
696
|
+
schedulingPeriod: resolvedPeriod,
|
|
697
|
+
coverage: resolvedCoverage,
|
|
698
|
+
ruleConfigs,
|
|
699
|
+
ruleFactories:
|
|
700
|
+
Object.keys(config.ruleFactories).length > 0
|
|
701
|
+
? { ...builtInCpsatRuleFactories, ...config.ruleFactories }
|
|
702
|
+
: undefined,
|
|
703
|
+
weekStartsOn: config.weekStartsOn,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ============================================================================
|
|
708
|
+
// Internal: Build SolveResult from solver response
|
|
709
|
+
// ============================================================================
|
|
710
|
+
|
|
711
|
+
function mapSolverStatus(solverStatus: SolverResponse["status"]): SolveStatus {
|
|
712
|
+
switch (solverStatus) {
|
|
713
|
+
case "OPTIMAL":
|
|
714
|
+
return "optimal";
|
|
715
|
+
case "FEASIBLE":
|
|
716
|
+
return "feasible";
|
|
717
|
+
case "INFEASIBLE":
|
|
718
|
+
return "infeasible";
|
|
719
|
+
case "TIMEOUT":
|
|
720
|
+
case "ERROR":
|
|
721
|
+
return "no_solution";
|
|
722
|
+
default:
|
|
723
|
+
return "no_solution";
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function buildSolveResult(
|
|
728
|
+
response: SolverResponse,
|
|
729
|
+
compiled: CompilationResult & { builder: ModelBuilder },
|
|
730
|
+
config: Readonly<MergedScheduleConfig>,
|
|
731
|
+
): SolveResult {
|
|
732
|
+
const status = mapSolverStatus(response.status);
|
|
733
|
+
const parsed = parseSolverResponse(response);
|
|
734
|
+
|
|
735
|
+
// Run post-solve validation when a solution exists
|
|
736
|
+
if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
|
|
737
|
+
const resolved = resolveAssignments(parsed.assignments, compiled.builder.shiftPatterns);
|
|
738
|
+
compiled.builder.reporter.analyzeSolution(response);
|
|
739
|
+
compiled.builder.validateSolution(resolved);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const validation = compiled.builder.reporter.getValidation();
|
|
743
|
+
|
|
744
|
+
const result: SolveResult = {
|
|
745
|
+
status,
|
|
746
|
+
assignments: parsed.assignments,
|
|
747
|
+
validation,
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// Compute cost breakdown when cost rules are present and a solution was found
|
|
751
|
+
if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
|
|
752
|
+
const hasCostRules = config.rules.some((r) => r._rule === "minimize-cost");
|
|
753
|
+
if (hasCostRules) {
|
|
754
|
+
result.cost = calculateScheduleCost(parsed.assignments, {
|
|
755
|
+
members: config.members,
|
|
756
|
+
shiftPatterns: config.shiftPatterns,
|
|
757
|
+
rules: compiled.builder.rules,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return result;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// Internal: Coverage Translation
|
|
767
|
+
// ============================================================================
|
|
768
|
+
|
|
769
|
+
function buildCoverageRequirements<T extends string>(
|
|
770
|
+
entries: CoverageEntry<T, string>[],
|
|
771
|
+
roles: Set<string>,
|
|
772
|
+
skills: Set<string>,
|
|
773
|
+
): MixedCoverageRequirement<T>[] {
|
|
774
|
+
return entries.map((entry) => {
|
|
775
|
+
// Variant form: produce a VariantCoverageRequirement
|
|
776
|
+
if (entry.variants) {
|
|
777
|
+
return buildVariantCoverageRequirement(entry, roles, skills);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Simple form: produce a SemanticCoverageRequirement
|
|
781
|
+
const base: {
|
|
782
|
+
semanticTime: T;
|
|
783
|
+
targetCount: number;
|
|
784
|
+
priority?: Priority;
|
|
785
|
+
dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
|
|
786
|
+
dates?: string[];
|
|
787
|
+
} = {
|
|
788
|
+
semanticTime: entry.timeName,
|
|
789
|
+
targetCount: entry.count,
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
if (entry.options.priority) base.priority = entry.options.priority;
|
|
793
|
+
if (entry.options.dayOfWeek && entry.options.dayOfWeek.length > 0) {
|
|
794
|
+
base.dayOfWeek = entry.options.dayOfWeek as [DayOfWeek, ...DayOfWeek[]];
|
|
795
|
+
}
|
|
796
|
+
if (entry.options.dates) base.dates = entry.options.dates;
|
|
797
|
+
|
|
798
|
+
return buildSimpleCoverageTarget(entry, base, roles, skills);
|
|
799
|
+
}) as MixedCoverageRequirement<T>[];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Resolve the target (role/skill) for a simple coverage entry.
|
|
804
|
+
*/
|
|
805
|
+
function buildSimpleCoverageTarget<T extends string>(
|
|
806
|
+
entry: CoverageEntry<T, string>,
|
|
807
|
+
base: {
|
|
808
|
+
semanticTime: T;
|
|
809
|
+
targetCount: number;
|
|
810
|
+
priority?: Priority;
|
|
811
|
+
dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
|
|
812
|
+
dates?: string[];
|
|
813
|
+
},
|
|
814
|
+
roles: Set<string>,
|
|
815
|
+
skills: Set<string>,
|
|
816
|
+
): MixedCoverageRequirement<T> {
|
|
817
|
+
if (Array.isArray(entry.target)) {
|
|
818
|
+
return {
|
|
819
|
+
...base,
|
|
820
|
+
roleIds: entry.target as [string, ...string[]],
|
|
821
|
+
} satisfies MixedCoverageRequirement<T>;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const singleTarget = entry.target as string;
|
|
825
|
+
if (roles.has(singleTarget)) {
|
|
826
|
+
if (entry.options.skillIds) {
|
|
827
|
+
return {
|
|
828
|
+
...base,
|
|
829
|
+
roleIds: [singleTarget] as [string, ...string[]],
|
|
830
|
+
skillIds: entry.options.skillIds,
|
|
831
|
+
} satisfies MixedCoverageRequirement<T>;
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
...base,
|
|
835
|
+
roleIds: [singleTarget] as [string, ...string[]],
|
|
836
|
+
} satisfies MixedCoverageRequirement<T>;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (skills.has(singleTarget)) {
|
|
840
|
+
return {
|
|
841
|
+
...base,
|
|
842
|
+
skillIds: [singleTarget] as [string, ...string[]],
|
|
843
|
+
} satisfies MixedCoverageRequirement<T>;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Build a VariantCoverageRequirement from a variant-form CoverageEntry.
|
|
851
|
+
*/
|
|
852
|
+
function buildVariantCoverageRequirement<T extends string>(
|
|
853
|
+
entry: CoverageEntry<T, string>,
|
|
854
|
+
roles: Set<string>,
|
|
855
|
+
skills: Set<string>,
|
|
856
|
+
): MixedCoverageRequirement<T> {
|
|
857
|
+
const variants = entry.variants! as unknown as [CoverageVariant, ...CoverageVariant[]];
|
|
858
|
+
|
|
859
|
+
const resolveTarget = (): {
|
|
860
|
+
roleIds?: [string, ...string[]];
|
|
861
|
+
skillIds?: [string, ...string[]];
|
|
862
|
+
} => {
|
|
863
|
+
if (Array.isArray(entry.target)) {
|
|
864
|
+
return { roleIds: entry.target as [string, ...string[]] };
|
|
865
|
+
}
|
|
866
|
+
const singleTarget = entry.target as string;
|
|
867
|
+
if (roles.has(singleTarget)) {
|
|
868
|
+
return { roleIds: [singleTarget] as [string, ...string[]] };
|
|
869
|
+
}
|
|
870
|
+
if (skills.has(singleTarget)) {
|
|
871
|
+
return { skillIds: [singleTarget] as [string, ...string[]] };
|
|
872
|
+
}
|
|
873
|
+
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
return {
|
|
877
|
+
semanticTime: entry.timeName,
|
|
878
|
+
variants,
|
|
879
|
+
...resolveTarget(),
|
|
880
|
+
} as MixedCoverageRequirement<T>;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ============================================================================
|
|
884
|
+
// Internal: Rule Translation
|
|
885
|
+
// ============================================================================
|
|
886
|
+
|
|
887
|
+
function resolveRules(
|
|
888
|
+
rules: RuleEntry[],
|
|
889
|
+
roles: Set<string>,
|
|
890
|
+
skills: Set<string>,
|
|
891
|
+
memberIds: Set<string>,
|
|
892
|
+
): CpsatRuleConfigEntry[] {
|
|
893
|
+
const ctx: RuleResolveContext = { roles, skills, memberIds };
|
|
894
|
+
|
|
895
|
+
return rules.map((rule) => {
|
|
896
|
+
// Rules with custom resolvers handle their own translation
|
|
897
|
+
if (rule._resolve) {
|
|
898
|
+
return rule._resolve(ctx) as CpsatRuleConfigEntry;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Default resolution: appliesTo → entity scope, dates → specificDates
|
|
902
|
+
const { _type, _rule, _resolve, appliesTo, dates, ...passthrough } = rule as RuleEntry & {
|
|
903
|
+
appliesTo?: string | string[];
|
|
904
|
+
dates?: string[];
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const entityScope = resolveAppliesTo(appliesTo, roles, skills, memberIds);
|
|
908
|
+
const resolvedDates = dates ? { specificDates: dates } : {};
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
name: _rule,
|
|
912
|
+
...passthrough,
|
|
913
|
+
...entityScope,
|
|
914
|
+
...resolvedDates,
|
|
915
|
+
} as CpsatRuleConfigEntry;
|
|
916
|
+
}) as CpsatRuleConfigEntry[];
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ============================================================================
|
|
920
|
+
// Internal: Cost Rule Ordering
|
|
921
|
+
// ============================================================================
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Sorts rules so that `minimize-cost` compiles before cost modifier rules.
|
|
925
|
+
*
|
|
926
|
+
* The `minimize-cost` rule must be compiled first because modifier rules
|
|
927
|
+
* (multipliers, surcharges) reference cost variables it creates.
|
|
928
|
+
* Non-cost rules retain their original relative order.
|
|
929
|
+
*/
|
|
930
|
+
function sortCostRulesFirst(rules: RuleEntry[]): RuleEntry[] {
|
|
931
|
+
return rules.toSorted((a, b) => {
|
|
932
|
+
const aIsCostBase = a._rule === "minimize-cost" ? 0 : 1;
|
|
933
|
+
const bIsCostBase = b._rule === "minimize-cost" ? 0 : 1;
|
|
934
|
+
return aIsCostBase - bIsCostBase;
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ============================================================================
|
|
939
|
+
// Internal: Scheduling Period Helpers
|
|
940
|
+
// ============================================================================
|
|
941
|
+
|
|
942
|
+
function applyDaysFilter(
|
|
943
|
+
schedulingPeriod: SchedulingPeriod,
|
|
944
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]],
|
|
945
|
+
): SchedulingPeriod {
|
|
946
|
+
if (!dayOfWeek || dayOfWeek.length === 0) {
|
|
947
|
+
return schedulingPeriod;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const existingDays = schedulingPeriod.dayOfWeek;
|
|
951
|
+
if (!existingDays || existingDays.length === 0) {
|
|
952
|
+
return { ...schedulingPeriod, dayOfWeek: [...dayOfWeek] };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const existingSet = new Set(existingDays);
|
|
956
|
+
const intersected = dayOfWeek.filter((day) => existingSet.has(day));
|
|
957
|
+
return { ...schedulingPeriod, dayOfWeek: intersected };
|
|
958
|
+
}
|