dabke 0.81.1 → 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 +58 -0
- package/README.md +45 -27
- package/dist/client.d.ts +20 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -1
- package/dist/client.js.map +1 -1
- package/dist/client.types.d.ts +9 -0
- package/dist/client.types.d.ts.map +1 -1
- package/dist/client.types.js +1 -0
- package/dist/client.types.js.map +1 -1
- package/dist/cpsat/model-builder.d.ts +9 -0
- package/dist/cpsat/model-builder.d.ts.map +1 -1
- package/dist/cpsat/model-builder.js +36 -34
- package/dist/cpsat/model-builder.js.map +1 -1
- package/dist/cpsat/response.d.ts +13 -1
- package/dist/cpsat/response.d.ts.map +1 -1
- package/dist/cpsat/response.js +4 -0
- package/dist/cpsat/response.js.map +1 -1
- package/dist/cpsat/rules/cost-utils.d.ts +11 -0
- package/dist/cpsat/rules/cost-utils.d.ts.map +1 -0
- package/dist/cpsat/rules/cost-utils.js +24 -0
- package/dist/cpsat/rules/cost-utils.js.map +1 -0
- package/dist/cpsat/rules/day-cost-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/day-cost-multiplier.js +3 -14
- package/dist/cpsat/rules/day-cost-multiplier.js.map +1 -1
- package/dist/cpsat/rules/day-cost-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/day-cost-surcharge.js +3 -7
- package/dist/cpsat/rules/day-cost-surcharge.js.map +1 -1
- 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-consecutive-days.d.ts.map +1 -1
- package/dist/cpsat/rules/max-consecutive-days.js +16 -2
- package/dist/cpsat/rules/max-consecutive-days.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/max-hours-day.d.ts.map +1 -1
- package/dist/cpsat/rules/max-hours-day.js +15 -2
- package/dist/cpsat/rules/max-hours-day.js.map +1 -1
- package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
- package/dist/cpsat/rules/max-hours-week.js +16 -2
- package/dist/cpsat/rules/max-hours-week.js.map +1 -1
- package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
- package/dist/cpsat/rules/max-shifts-day.js +15 -2
- package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
- package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
- package/dist/cpsat/rules/min-consecutive-days.js +15 -2
- package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
- 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/min-hours-day.d.ts.map +1 -1
- package/dist/cpsat/rules/min-hours-day.js +15 -2
- package/dist/cpsat/rules/min-hours-day.js.map +1 -1
- package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
- package/dist/cpsat/rules/min-hours-week.js +16 -2
- package/dist/cpsat/rules/min-hours-week.js.map +1 -1
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
- package/dist/cpsat/rules/min-rest-between-shifts.js +72 -2
- package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
- package/dist/cpsat/rules/minimize-cost.d.ts.map +1 -1
- package/dist/cpsat/rules/minimize-cost.js +2 -23
- package/dist/cpsat/rules/minimize-cost.js.map +1 -1
- 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/overtime-daily-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-daily-multiplier.js +1 -12
- package/dist/cpsat/rules/overtime-daily-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-daily-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-daily-surcharge.js +1 -5
- package/dist/cpsat/rules/overtime-daily-surcharge.js.map +1 -1
- package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts +5 -1
- package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-tiered-multiplier.js +1 -12
- package/dist/cpsat/rules/overtime-tiered-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-multiplier.js +1 -12
- package/dist/cpsat/rules/overtime-weekly-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-surcharge.js +1 -5
- package/dist/cpsat/rules/overtime-weekly-surcharge.js.map +1 -1
- package/dist/cpsat/rules/registry.d.ts +28 -2
- 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/resolver.js +2 -2
- package/dist/cpsat/rules/resolver.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 +18 -1
- package/dist/cpsat/rules/scope.types.d.ts.map +1 -1
- package/dist/cpsat/rules/scope.types.js +59 -16
- package/dist/cpsat/rules/scope.types.js.map +1 -1
- package/dist/cpsat/rules/time-cost-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/time-cost-surcharge.js +2 -1
- package/dist/cpsat/rules/time-cost-surcharge.js.map +1 -1
- package/dist/cpsat/rules/time-off.d.ts.map +1 -1
- package/dist/cpsat/rules/time-off.js +6 -3
- package/dist/cpsat/rules/time-off.js.map +1 -1
- package/dist/cpsat/semantic-time.d.ts +44 -42
- package/dist/cpsat/semantic-time.d.ts.map +1 -1
- package/dist/cpsat/semantic-time.js +64 -46
- package/dist/cpsat/semantic-time.js.map +1 -1
- package/dist/cpsat/types.d.ts +37 -27
- package/dist/cpsat/types.d.ts.map +1 -1
- package/dist/cpsat/utils.d.ts.map +1 -1
- package/dist/cpsat/utils.js +7 -12
- package/dist/cpsat/utils.js.map +1 -1
- package/dist/cpsat/validation-reporter.d.ts +10 -7
- package/dist/cpsat/validation-reporter.d.ts.map +1 -1
- package/dist/cpsat/validation-reporter.js +44 -72
- package/dist/cpsat/validation-reporter.js.map +1 -1
- package/dist/cpsat/validation.types.d.ts +54 -44
- package/dist/cpsat/validation.types.d.ts.map +1 -1
- package/dist/cpsat/validation.types.js +15 -10
- package/dist/cpsat/validation.types.js.map +1 -1
- package/dist/datetime.utils.d.ts +3 -203
- package/dist/datetime.utils.d.ts.map +1 -1
- package/dist/datetime.utils.js +1 -288
- package/dist/datetime.utils.js.map +1 -1
- package/dist/index.d.ts +14 -83
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -83
- 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/definition.js +659 -0
- 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/dist/types.d.ts +14 -78
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -9
- package/solver/src/solver/app.py +1 -1
- package/solver/src/solver/solver.py +7 -4
- package/src/client.ts +6 -8
- package/src/client.types.ts +9 -0
- package/src/cpsat/model-builder.ts +44 -35
- package/src/cpsat/response.ts +13 -1
- package/src/cpsat/rules/cost-utils.ts +25 -0
- package/src/cpsat/rules/day-cost-multiplier.ts +3 -14
- package/src/cpsat/rules/day-cost-surcharge.ts +3 -8
- package/src/cpsat/rules/index.ts +3 -0
- package/src/cpsat/rules/max-consecutive-days.ts +17 -0
- package/src/cpsat/rules/max-days-week.ts +143 -0
- package/src/cpsat/rules/max-hours-day.ts +21 -1
- package/src/cpsat/rules/max-hours-week.ts +22 -1
- package/src/cpsat/rules/max-shifts-day.ts +21 -1
- package/src/cpsat/rules/min-consecutive-days.ts +16 -1
- package/src/cpsat/rules/min-days-week.ts +120 -0
- package/src/cpsat/rules/min-hours-day.ts +16 -1
- package/src/cpsat/rules/min-hours-week.ts +17 -1
- package/src/cpsat/rules/min-rest-between-shifts.ts +92 -2
- package/src/cpsat/rules/minimize-cost.ts +2 -29
- package/src/cpsat/rules/must-assign.ts +108 -0
- package/src/cpsat/rules/overtime-daily-multiplier.ts +1 -12
- package/src/cpsat/rules/overtime-daily-surcharge.ts +1 -6
- package/src/cpsat/rules/overtime-tiered-multiplier.ts +6 -13
- package/src/cpsat/rules/overtime-weekly-multiplier.ts +1 -12
- package/src/cpsat/rules/overtime-weekly-surcharge.ts +1 -6
- package/src/cpsat/rules/registry.ts +8 -2
- package/src/cpsat/rules/resolver.ts +2 -2
- package/src/cpsat/rules/rules.types.ts +3 -0
- package/src/cpsat/rules/scope.types.ts +73 -20
- package/src/cpsat/rules/time-cost-surcharge.ts +2 -1
- package/src/cpsat/rules/time-off.ts +6 -2
- package/src/cpsat/semantic-time.ts +115 -91
- package/src/cpsat/types.ts +37 -27
- package/src/cpsat/utils.ts +8 -12
- package/src/cpsat/validation-reporter.ts +51 -82
- package/src/cpsat/validation.types.ts +72 -47
- package/src/datetime.utils.ts +3 -334
- package/src/index.ts +35 -107
- 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/src/types.ts +14 -88
- package/dist/errors.d.ts +0 -12
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -17
- package/dist/errors.js.map +0 -1
- 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 -724
- package/dist/schedule.d.ts.map +0 -1
- package/dist/schedule.js +0 -899
- package/dist/schedule.js.map +0 -1
- package/dist/validation.d.ts +0 -105
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -130
- package/dist/validation.js.map +0 -1
- package/llms.txt +0 -925
- package/src/errors.ts +0 -17
- package/src/llms.ts +0 -3
- package/src/schedule.ts +0 -1419
- package/src/validation.ts +0 -188
package/src/schedule.ts
DELETED
|
@@ -1,1419 +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
|
-
* defineSchedule, t, time, cover, shift,
|
|
12
|
-
* maxHoursPerDay, maxHoursPerWeek, minRestBetweenShifts,
|
|
13
|
-
* weekdays, weekend,
|
|
14
|
-
* } from "dabke";
|
|
15
|
-
*
|
|
16
|
-
* export default defineSchedule({
|
|
17
|
-
* roles: ["cashier", "floor_lead", "stocker"],
|
|
18
|
-
* skills: ["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
|
-
*
|
|
49
|
-
* @module
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
import type { DayOfWeek, SchedulingPeriod, TimeOfDay } from "./types.js";
|
|
53
|
-
import type {
|
|
54
|
-
SemanticTimeDef,
|
|
55
|
-
SemanticTimeVariant,
|
|
56
|
-
SemanticTimeEntry,
|
|
57
|
-
MixedCoverageRequirement,
|
|
58
|
-
CoverageVariant,
|
|
59
|
-
} from "./cpsat/semantic-time.js";
|
|
60
|
-
|
|
61
|
-
export type { CoverageVariant } from "./cpsat/semantic-time.js";
|
|
62
|
-
import { defineSemanticTimes } from "./cpsat/semantic-time.js";
|
|
63
|
-
import { resolveDaysFromPeriod } from "./datetime.utils.js";
|
|
64
|
-
import type { ModelBuilderConfig } from "./cpsat/model-builder.js";
|
|
65
|
-
import type { SchedulingMember, ShiftPattern, Priority } from "./cpsat/types.js";
|
|
66
|
-
import type { CpsatRuleName, CpsatRuleConfigEntry } from "./cpsat/rules/rules.types.js";
|
|
67
|
-
import type { RecurringPeriod } from "./cpsat/rules/scope.types.js";
|
|
68
|
-
import type { OvertimeTier } from "./cpsat/rules/overtime-tiered-multiplier.js";
|
|
69
|
-
|
|
70
|
-
// ============================================================================
|
|
71
|
-
// Primitives
|
|
72
|
-
// ============================================================================
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Creates a {@link TimeOfDay} value.
|
|
76
|
-
*
|
|
77
|
-
* @param hours - Hour component (0-23)
|
|
78
|
-
* @param minutes - Minute component (0-59)
|
|
79
|
-
*
|
|
80
|
-
* @example Hours only
|
|
81
|
-
* ```ts
|
|
82
|
-
* t(9) // { hours: 9, minutes: 0 }
|
|
83
|
-
* ```
|
|
84
|
-
*
|
|
85
|
-
* @example Hours and minutes
|
|
86
|
-
* ```ts
|
|
87
|
-
* t(17, 30) // { hours: 17, minutes: 30 }
|
|
88
|
-
* ```
|
|
89
|
-
*/
|
|
90
|
-
export function t(hours: number, minutes = 0): TimeOfDay {
|
|
91
|
-
return { hours, minutes };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Monday through Friday. */
|
|
95
|
-
export const weekdays: readonly DayOfWeek[] = [
|
|
96
|
-
"monday",
|
|
97
|
-
"tuesday",
|
|
98
|
-
"wednesday",
|
|
99
|
-
"thursday",
|
|
100
|
-
"friday",
|
|
101
|
-
] as const;
|
|
102
|
-
|
|
103
|
-
/** Saturday and Sunday. */
|
|
104
|
-
export const weekend: readonly DayOfWeek[] = ["saturday", "sunday"] as const;
|
|
105
|
-
|
|
106
|
-
// ============================================================================
|
|
107
|
-
// Semantic Times
|
|
108
|
-
// ============================================================================
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Defines a named time window.
|
|
112
|
-
*
|
|
113
|
-
* @remarks
|
|
114
|
-
* A semantic time is any recurring period you need to reference:
|
|
115
|
-
* service hours, delivery windows, peak periods, weekly events. Times
|
|
116
|
-
* may overlap (e.g., "dinner" 18:00-22:00 and "happy_hour"
|
|
117
|
-
* 17:30-18:30, or "lunch" 12:00-14:00 with "peak_lunch"
|
|
118
|
-
* 13:00-13:30). Coverage and rules reference these names; each
|
|
119
|
-
* generates independent constraints.
|
|
120
|
-
*
|
|
121
|
-
* Every argument is a {@link SemanticTimeVariant} with `startTime`/`endTime`
|
|
122
|
-
* and optional `dayOfWeek`/`dates` scoping. An entry without scoping is the
|
|
123
|
-
* default (applies when no scoped entry matches). At most one default is
|
|
124
|
-
* allowed. If no default, the time only exists on the scoped days.
|
|
125
|
-
*
|
|
126
|
-
* Resolution precedence: `dates` > `dayOfWeek` > default.
|
|
127
|
-
*
|
|
128
|
-
* @example Every day
|
|
129
|
-
* ```typescript
|
|
130
|
-
* day_shift: time({ startTime: t(7), endTime: t(15) }),
|
|
131
|
-
* ```
|
|
132
|
-
*
|
|
133
|
-
* @example Default with weekend variant
|
|
134
|
-
* ```typescript
|
|
135
|
-
* peak_hours: time(
|
|
136
|
-
* { startTime: t(9), endTime: t(17) },
|
|
137
|
-
* { startTime: t(10), endTime: t(15), dayOfWeek: weekend },
|
|
138
|
-
* ),
|
|
139
|
-
* ```
|
|
140
|
-
*
|
|
141
|
-
* @example No default (specific days only)
|
|
142
|
-
* ```typescript
|
|
143
|
-
* happy_hour: time(
|
|
144
|
-
* { startTime: t(16), endTime: t(18), dayOfWeek: ["monday", "tuesday"] },
|
|
145
|
-
* { startTime: t(17), endTime: t(19), dayOfWeek: ["friday"] },
|
|
146
|
-
* ),
|
|
147
|
-
* ```
|
|
148
|
-
*/
|
|
149
|
-
export function time(
|
|
150
|
-
...entries: [SemanticTimeVariant, ...SemanticTimeVariant[]]
|
|
151
|
-
): SemanticTimeEntry {
|
|
152
|
-
// Validate: at most one default (no dayOfWeek and no dates)
|
|
153
|
-
const defaults = entries.filter((e) => !e.dayOfWeek && !e.dates);
|
|
154
|
-
if (defaults.length > 1) {
|
|
155
|
-
throw new Error(
|
|
156
|
-
"time() accepts at most one default entry (without dayOfWeek or dates). " +
|
|
157
|
-
`Found ${defaults.length} default entries.`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Single entry without scoping: simple SemanticTimeDef
|
|
162
|
-
if (entries.length === 1 && !entries[0].dayOfWeek && !entries[0].dates) {
|
|
163
|
-
return {
|
|
164
|
-
startTime: entries[0].startTime,
|
|
165
|
-
endTime: entries[0].endTime,
|
|
166
|
-
} satisfies SemanticTimeDef;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Multiple entries or scoped entries: pass through directly
|
|
170
|
-
return entries.map((entry): SemanticTimeVariant => {
|
|
171
|
-
const variant: SemanticTimeVariant = {
|
|
172
|
-
startTime: entry.startTime,
|
|
173
|
-
endTime: entry.endTime,
|
|
174
|
-
};
|
|
175
|
-
if (entry.dayOfWeek) variant.dayOfWeek = entry.dayOfWeek;
|
|
176
|
-
if (entry.dates) variant.dates = entry.dates;
|
|
177
|
-
return variant;
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ============================================================================
|
|
182
|
-
// Coverage
|
|
183
|
-
// ============================================================================
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Options for a {@link cover} call.
|
|
187
|
-
*
|
|
188
|
-
* @remarks
|
|
189
|
-
* Day/date scoping controls which days this coverage entry applies to.
|
|
190
|
-
* An entry without `dayOfWeek` or `dates` applies every day in the
|
|
191
|
-
* scheduling period.
|
|
192
|
-
*/
|
|
193
|
-
export interface CoverageOptions {
|
|
194
|
-
/** Additional skill filter (AND logic with the target role). */
|
|
195
|
-
skills?: [string, ...string[]];
|
|
196
|
-
/** Restrict to specific days of the week. */
|
|
197
|
-
dayOfWeek?: readonly DayOfWeek[];
|
|
198
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
199
|
-
dates?: string[];
|
|
200
|
-
/** Defaults to `"MANDATORY"`. */
|
|
201
|
-
priority?: Priority;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* A coverage entry returned by {@link cover}.
|
|
206
|
-
*
|
|
207
|
-
* @remarks
|
|
208
|
-
* Carries the semantic time name and target type information for
|
|
209
|
-
* compile-time validation by {@link defineSchedule}. This is an opaque
|
|
210
|
-
* token; pass it directly into the `coverage` array.
|
|
211
|
-
*/
|
|
212
|
-
export interface CoverageEntry<T extends string = string, R extends string = string> {
|
|
213
|
-
/** @internal */ readonly _type: "coverage";
|
|
214
|
-
/** @internal */ readonly timeName: T;
|
|
215
|
-
/** @internal */ readonly target: R | R[];
|
|
216
|
-
/** @internal */ readonly count: number;
|
|
217
|
-
/** @internal */ readonly options: CoverageOptions;
|
|
218
|
-
/** @internal When present, this entry uses variant-based resolution. */
|
|
219
|
-
readonly variants?: readonly CoverageVariant[];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Defines a staffing requirement for a semantic time period.
|
|
224
|
-
*
|
|
225
|
-
* @remarks
|
|
226
|
-
* Two call forms are supported:
|
|
227
|
-
*
|
|
228
|
-
* **Simple form** `cover(time, target, count, opts?)` creates a single
|
|
229
|
-
* constraint. Use `dayOfWeek`/`dates` in `opts` to restrict which days
|
|
230
|
-
* it applies to.
|
|
231
|
-
*
|
|
232
|
-
* **Variant form** `cover(time, target, ...variants)` accepts one or more
|
|
233
|
-
* {@link CoverageVariant} entries with day-specific counts. For each
|
|
234
|
-
* scheduling day, exactly one variant is selected using the same
|
|
235
|
-
* precedence as {@link time}: `dates` > `dayOfWeek` > default (unscoped).
|
|
236
|
-
* At most one variant may be unscoped (the default). Days with no matching
|
|
237
|
-
* variant produce no coverage. See {@link CoverageVariant} for the entry
|
|
238
|
-
* shape.
|
|
239
|
-
*
|
|
240
|
-
* **Target resolution.** The `target` parameter is resolved against declared
|
|
241
|
-
* `roles` and `skills`:
|
|
242
|
-
*
|
|
243
|
-
* - Single string: matched against roles first, then skills.
|
|
244
|
-
* - Array of strings: OR logic (any of the listed roles).
|
|
245
|
-
* - With `skills` option (simple form only): role AND skill(s) filter.
|
|
246
|
-
*
|
|
247
|
-
* @param timeName - Name of a declared semantic time
|
|
248
|
-
* @param target - Role name, skill name, or array of role names (OR)
|
|
249
|
-
* @param count - Number of people needed (simple form)
|
|
250
|
-
* @param opts - See {@link CoverageOptions} (simple form)
|
|
251
|
-
*
|
|
252
|
-
* @example Basic role coverage
|
|
253
|
-
* ```ts
|
|
254
|
-
* cover("day_shift", "nurse", 3)
|
|
255
|
-
* ```
|
|
256
|
-
*
|
|
257
|
-
* @example OR logic (any of the listed roles)
|
|
258
|
-
* ```ts
|
|
259
|
-
* cover("day_shift", ["manager", "team_lead"], 1)
|
|
260
|
-
* ```
|
|
261
|
-
*
|
|
262
|
-
* @example Skill-based coverage
|
|
263
|
-
* ```ts
|
|
264
|
-
* cover("night_shift", "keyholder", 1)
|
|
265
|
-
* ```
|
|
266
|
-
*
|
|
267
|
-
* @example Role with skill filter (role AND skill)
|
|
268
|
-
* ```ts
|
|
269
|
-
* cover("day_shift", "nurse", 1, { skills: ["charge_nurse"] })
|
|
270
|
-
* ```
|
|
271
|
-
*
|
|
272
|
-
* @example Day-of-week scoping (simple form)
|
|
273
|
-
* ```ts
|
|
274
|
-
* cover("peak_hours", "cashier", 3, { dayOfWeek: weekdays }),
|
|
275
|
-
* cover("peak_hours", "cashier", 5, { dayOfWeek: weekend }),
|
|
276
|
-
* ```
|
|
277
|
-
*
|
|
278
|
-
* @example Default with date override (variant form)
|
|
279
|
-
* ```ts
|
|
280
|
-
* cover("peak_hours", "agent",
|
|
281
|
-
* { count: 4 },
|
|
282
|
-
* { count: 2, dates: ["2025-12-24"] },
|
|
283
|
-
* )
|
|
284
|
-
* ```
|
|
285
|
-
*
|
|
286
|
-
* @example Weekday vs weekend with holiday override (variant form)
|
|
287
|
-
* ```ts
|
|
288
|
-
* cover("peak_hours", "agent",
|
|
289
|
-
* { count: 3, dayOfWeek: weekdays },
|
|
290
|
-
* { count: 5, dayOfWeek: weekend },
|
|
291
|
-
* { count: 8, dates: ["2025-12-31"] },
|
|
292
|
-
* )
|
|
293
|
-
* ```
|
|
294
|
-
*/
|
|
295
|
-
export function cover<T extends string, R extends string>(
|
|
296
|
-
timeName: T,
|
|
297
|
-
target: R | [R, ...R[]],
|
|
298
|
-
count: number,
|
|
299
|
-
opts?: CoverageOptions,
|
|
300
|
-
): CoverageEntry<T, R>;
|
|
301
|
-
export function cover<T extends string, R extends string>(
|
|
302
|
-
timeName: T,
|
|
303
|
-
target: R | [R, ...R[]],
|
|
304
|
-
...variants: [CoverageVariant, ...CoverageVariant[]]
|
|
305
|
-
): CoverageEntry<T, R>;
|
|
306
|
-
export function cover<T extends string, R extends string>(
|
|
307
|
-
timeName: T,
|
|
308
|
-
target: R | [R, ...R[]],
|
|
309
|
-
countOrFirstVariant: number | CoverageVariant,
|
|
310
|
-
...rest: unknown[]
|
|
311
|
-
): CoverageEntry<T, R> {
|
|
312
|
-
if (typeof countOrFirstVariant === "number") {
|
|
313
|
-
// Simple form: cover(time, target, count, opts?)
|
|
314
|
-
return {
|
|
315
|
-
_type: "coverage",
|
|
316
|
-
timeName,
|
|
317
|
-
target,
|
|
318
|
-
count: countOrFirstVariant,
|
|
319
|
-
options: (rest[0] as CoverageOptions | undefined) ?? {},
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Variant form: cover(time, target, ...variants)
|
|
324
|
-
const variants = [countOrFirstVariant, ...(rest as CoverageVariant[])];
|
|
325
|
-
|
|
326
|
-
const defaults = variants.filter((v) => !v.dayOfWeek && !v.dates);
|
|
327
|
-
if (defaults.length > 1) {
|
|
328
|
-
throw new Error(
|
|
329
|
-
"cover() accepts at most one default variant (without dayOfWeek or dates). " +
|
|
330
|
-
`Found ${defaults.length} default variants.`,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
return {
|
|
335
|
-
_type: "coverage",
|
|
336
|
-
timeName,
|
|
337
|
-
target,
|
|
338
|
-
count: 0,
|
|
339
|
-
options: {},
|
|
340
|
-
variants,
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// ============================================================================
|
|
345
|
-
// Shift Patterns
|
|
346
|
-
// ============================================================================
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Creates a {@link ShiftPattern} (time slot template).
|
|
350
|
-
*
|
|
351
|
-
* @remarks
|
|
352
|
-
* Shift patterns define when people can work: the concrete time slots
|
|
353
|
-
* the solver may assign members to. Each pattern repeats across all
|
|
354
|
-
* scheduling days unless filtered by `dayOfWeek` or `roles`.
|
|
355
|
-
*
|
|
356
|
-
* @example
|
|
357
|
-
* ```typescript
|
|
358
|
-
* shift("early", t(6), t(14)),
|
|
359
|
-
* shift("day", t(9), t(17)),
|
|
360
|
-
* shift("night", t(22), t(6), { roles: ["nurse", "doctor"] }),
|
|
361
|
-
* ```
|
|
362
|
-
*/
|
|
363
|
-
export function shift(
|
|
364
|
-
id: string,
|
|
365
|
-
startTime: TimeOfDay,
|
|
366
|
-
endTime: TimeOfDay,
|
|
367
|
-
opts?: Pick<ShiftPattern, "roles" | "dayOfWeek" | "locationId">,
|
|
368
|
-
): ShiftPattern {
|
|
369
|
-
const pattern: ShiftPattern = { id, startTime, endTime };
|
|
370
|
-
if (opts?.roles) pattern.roles = opts.roles;
|
|
371
|
-
if (opts?.dayOfWeek) pattern.dayOfWeek = opts.dayOfWeek as DayOfWeek[];
|
|
372
|
-
if (opts?.locationId) pattern.locationId = opts.locationId;
|
|
373
|
-
return pattern;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ============================================================================
|
|
377
|
-
// Rules
|
|
378
|
-
// ============================================================================
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Scoping options shared by most rule functions.
|
|
382
|
-
*
|
|
383
|
-
* @remarks
|
|
384
|
-
* Each rule function returns an opaque {@link RuleEntry} for the `rules`
|
|
385
|
-
* array. Most accept a `RuleOptions` parameter for scoping and priority.
|
|
386
|
-
*
|
|
387
|
-
* **Entity scoping.** `appliesTo` targets a role name, skill name, or
|
|
388
|
-
* member ID. It is resolved against declared roles first, then skills,
|
|
389
|
-
* then runtime member IDs. The namespaces are guaranteed disjoint by
|
|
390
|
-
* validation. Unscoped rules apply to all members.
|
|
391
|
-
*
|
|
392
|
-
* **Time scoping.** `dayOfWeek`, `dateRange`, `dates`, and
|
|
393
|
-
* `recurringPeriods` narrow when the rule is active. Unscoped rules
|
|
394
|
-
* apply to every day in the scheduling period.
|
|
395
|
-
*
|
|
396
|
-
* **Priority.** Defaults to `MANDATORY` (hard constraint the solver
|
|
397
|
-
* must satisfy). Use `LOW`, `MEDIUM`, or `HIGH` for soft preferences
|
|
398
|
-
* the solver may violate when necessary.
|
|
399
|
-
*/
|
|
400
|
-
export interface RuleOptions {
|
|
401
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
402
|
-
appliesTo?: string | string[];
|
|
403
|
-
/** Restrict to specific days of the week. */
|
|
404
|
-
dayOfWeek?: readonly DayOfWeek[];
|
|
405
|
-
/** Restrict to a date range. */
|
|
406
|
-
dateRange?: { start: string; end: string };
|
|
407
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
408
|
-
dates?: string[];
|
|
409
|
-
/** Restrict to recurring calendar periods. */
|
|
410
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
411
|
-
/** Defaults to `"MANDATORY"`. */
|
|
412
|
-
priority?: Priority;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Options for rules that support entity scoping only (no time scoping).
|
|
417
|
-
*
|
|
418
|
-
* @remarks
|
|
419
|
-
* Used by rules whose semantics are inherently per-day or per-week
|
|
420
|
-
* (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
|
|
421
|
-
* be meaningfully restricted to a date range or day of week.
|
|
422
|
-
*/
|
|
423
|
-
export interface EntityOnlyRuleOptions {
|
|
424
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
425
|
-
appliesTo?: string | string[];
|
|
426
|
-
/** Defaults to `"MANDATORY"`. */
|
|
427
|
-
priority?: Priority;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Options for {@link timeOff}.
|
|
432
|
-
*
|
|
433
|
-
* @remarks
|
|
434
|
-
* At least one time scoping field is required (`dayOfWeek`, `dateRange`,
|
|
435
|
-
* `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
|
|
436
|
-
* of a day.
|
|
437
|
-
*/
|
|
438
|
-
export interface TimeOffOptions {
|
|
439
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
440
|
-
appliesTo?: string | string[];
|
|
441
|
-
/** Off from this time until end of day. */
|
|
442
|
-
from?: TimeOfDay;
|
|
443
|
-
/** Off from start of day until this time. */
|
|
444
|
-
until?: TimeOfDay;
|
|
445
|
-
/** Restrict to specific days of the week. */
|
|
446
|
-
dayOfWeek?: readonly DayOfWeek[];
|
|
447
|
-
/** Restrict to a date range. */
|
|
448
|
-
dateRange?: { start: string; end: string };
|
|
449
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
450
|
-
dates?: string[];
|
|
451
|
-
/** Restrict to recurring calendar periods. */
|
|
452
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
453
|
-
/** Defaults to `"MANDATORY"`. */
|
|
454
|
-
priority?: Priority;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** Options for {@link assignTogether}. */
|
|
458
|
-
export interface AssignTogetherOptions {
|
|
459
|
-
/** Defaults to `"MANDATORY"`. */
|
|
460
|
-
priority?: Priority;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Options for cost rules.
|
|
465
|
-
*
|
|
466
|
-
* Cost rules are objective terms, not constraints. The `priority` field from
|
|
467
|
-
* {@link RuleOptions} does not apply.
|
|
468
|
-
*/
|
|
469
|
-
export interface CostRuleOptions {
|
|
470
|
-
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
471
|
-
appliesTo?: string | string[];
|
|
472
|
-
/** Restrict to specific days of the week. */
|
|
473
|
-
dayOfWeek?: DayOfWeek[];
|
|
474
|
-
/** Restrict to a date range. */
|
|
475
|
-
dateRange?: { start: string; end: string };
|
|
476
|
-
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
477
|
-
dates?: string[];
|
|
478
|
-
/** Restrict to recurring calendar periods. */
|
|
479
|
-
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Internal rule entry type
|
|
483
|
-
interface RuleEntryBase {
|
|
484
|
-
readonly _type: "rule";
|
|
485
|
-
readonly _rule: CpsatRuleName;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* An opaque rule entry returned by rule functions.
|
|
490
|
-
*
|
|
491
|
-
* @remarks
|
|
492
|
-
* Pass these directly into the `rules` array of {@link ScheduleConfig} or
|
|
493
|
-
* the `runtimeRules` array of {@link RuntimeArgs}. The internal fields are
|
|
494
|
-
* resolved during {@link ScheduleDefinition.createSchedulerConfig}.
|
|
495
|
-
*/
|
|
496
|
-
export type RuleEntry = RuleEntryBase & Record<string, unknown>;
|
|
497
|
-
|
|
498
|
-
function makeRule(rule: CpsatRuleName, fields: Record<string, unknown>): RuleEntry {
|
|
499
|
-
return { _type: "rule", _rule: rule, ...fields };
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Limits how many hours a person can work in a single day.
|
|
504
|
-
*
|
|
505
|
-
* @example Global limit
|
|
506
|
-
* ```ts
|
|
507
|
-
* maxHoursPerDay(10)
|
|
508
|
-
* ```
|
|
509
|
-
*
|
|
510
|
-
* @example Scoped to a role
|
|
511
|
-
* ```ts
|
|
512
|
-
* maxHoursPerDay(6, { appliesTo: "student" })
|
|
513
|
-
* ```
|
|
514
|
-
*/
|
|
515
|
-
export function maxHoursPerDay(hours: number, opts?: RuleOptions): RuleEntry {
|
|
516
|
-
return makeRule("max-hours-day", { hours, ...opts });
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Caps total hours a person can work within each scheduling week.
|
|
521
|
-
*
|
|
522
|
-
* @example Global cap
|
|
523
|
-
* ```ts
|
|
524
|
-
* maxHoursPerWeek(48)
|
|
525
|
-
* ```
|
|
526
|
-
*
|
|
527
|
-
* @example Part-time cap for a skill group
|
|
528
|
-
* ```ts
|
|
529
|
-
* maxHoursPerWeek(20, { appliesTo: "part_time" })
|
|
530
|
-
* ```
|
|
531
|
-
*/
|
|
532
|
-
export function maxHoursPerWeek(hours: number, opts?: RuleOptions): RuleEntry {
|
|
533
|
-
return makeRule("max-hours-week", { hours, ...opts });
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Ensures a person works at least a minimum number of hours per day when assigned.
|
|
538
|
-
*
|
|
539
|
-
* @example
|
|
540
|
-
* ```ts
|
|
541
|
-
* minHoursPerDay(4)
|
|
542
|
-
* ```
|
|
543
|
-
*/
|
|
544
|
-
export function minHoursPerDay(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
545
|
-
return makeRule("min-hours-day", { hours, ...opts });
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Enforces a minimum total number of hours per scheduling week.
|
|
550
|
-
*
|
|
551
|
-
* @example Guaranteed minimum for full-time members
|
|
552
|
-
* ```ts
|
|
553
|
-
* minHoursPerWeek(30, { appliesTo: "full_time" })
|
|
554
|
-
* ```
|
|
555
|
-
*/
|
|
556
|
-
export function minHoursPerWeek(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
557
|
-
return makeRule("min-hours-week", { hours, ...opts });
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Limits how many shifts a person can work in a single day.
|
|
562
|
-
*
|
|
563
|
-
* @example One shift per day
|
|
564
|
-
* ```ts
|
|
565
|
-
* maxShiftsPerDay(1)
|
|
566
|
-
* ```
|
|
567
|
-
*/
|
|
568
|
-
export function maxShiftsPerDay(shifts: number, opts?: RuleOptions): RuleEntry {
|
|
569
|
-
return makeRule("max-shifts-day", { shifts, ...opts });
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Limits how many consecutive days a person can be assigned.
|
|
574
|
-
*
|
|
575
|
-
* @example Five-day work week limit
|
|
576
|
-
* ```ts
|
|
577
|
-
* maxConsecutiveDays(5)
|
|
578
|
-
* ```
|
|
579
|
-
*/
|
|
580
|
-
export function maxConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
581
|
-
return makeRule("max-consecutive-days", { days, ...opts });
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Requires a minimum stretch of consecutive working days once assigned.
|
|
586
|
-
*
|
|
587
|
-
* @example
|
|
588
|
-
* ```ts
|
|
589
|
-
* minConsecutiveDays(2)
|
|
590
|
-
* ```
|
|
591
|
-
*/
|
|
592
|
-
export function minConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
593
|
-
return makeRule("min-consecutive-days", { days, ...opts });
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Enforces a minimum rest period between any two shifts a person works.
|
|
598
|
-
*
|
|
599
|
-
* @example EU Working Time Directive (11 hours)
|
|
600
|
-
* ```ts
|
|
601
|
-
* minRestBetweenShifts(11)
|
|
602
|
-
* ```
|
|
603
|
-
*/
|
|
604
|
-
export function minRestBetweenShifts(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
605
|
-
return makeRule("min-rest-between-shifts", { hours, ...opts });
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Adds objective weight to prefer or avoid assigning team members.
|
|
610
|
-
*
|
|
611
|
-
* @param level - `"high"` to prefer assigning, `"low"` to avoid
|
|
612
|
-
* @param opts - Entity and time scoping (no priority; preference is the priority mechanism)
|
|
613
|
-
*
|
|
614
|
-
* @example Prefer assigning full-time staff
|
|
615
|
-
* ```ts
|
|
616
|
-
* preference("high", { appliesTo: "full_time" })
|
|
617
|
-
* ```
|
|
618
|
-
*
|
|
619
|
-
* @example Avoid assigning a specific member on weekends
|
|
620
|
-
* ```ts
|
|
621
|
-
* preference("low", { appliesTo: "alice", dayOfWeek: weekend })
|
|
622
|
-
* ```
|
|
623
|
-
*/
|
|
624
|
-
export function preference(level: "high" | "low", opts?: Omit<RuleOptions, "priority">): RuleEntry {
|
|
625
|
-
return makeRule("assignment-priority", { preference: level, ...opts });
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* Prefers assigning a person to shift patterns at a specific location.
|
|
630
|
-
*
|
|
631
|
-
* @example
|
|
632
|
-
* ```ts
|
|
633
|
-
* preferLocation("north_wing", { appliesTo: "alice" })
|
|
634
|
-
* ```
|
|
635
|
-
*/
|
|
636
|
-
export function preferLocation(locationId: string, opts?: EntityOnlyRuleOptions): RuleEntry {
|
|
637
|
-
return makeRule("location-preference", { locationId, ...opts });
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Tells the solver to minimize total labor cost.
|
|
642
|
-
*
|
|
643
|
-
* @remarks
|
|
644
|
-
* Without this rule, cost modifiers only affect post-solve calculation.
|
|
645
|
-
* When present, the solver actively prefers cheaper assignments.
|
|
646
|
-
*
|
|
647
|
-
* For hourly members, penalizes each assignment proportionally to cost.
|
|
648
|
-
* For salaried members, adds a fixed weekly salary cost when they have
|
|
649
|
-
* any assignment that week (zero marginal cost up to contracted hours).
|
|
650
|
-
*
|
|
651
|
-
* @example
|
|
652
|
-
* ```ts
|
|
653
|
-
* minimizeCost()
|
|
654
|
-
* ```
|
|
655
|
-
*/
|
|
656
|
-
export function minimizeCost(opts?: CostRuleOptions): RuleEntry {
|
|
657
|
-
return makeRule("minimize-cost", { ...opts });
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Multiplies the base rate for assignments on specified days.
|
|
662
|
-
*
|
|
663
|
-
* @remarks
|
|
664
|
-
* The base cost (1x) is already counted by {@link minimizeCost};
|
|
665
|
-
* this rule adds only the extra portion above 1x.
|
|
666
|
-
*
|
|
667
|
-
* @example Weekend multiplier
|
|
668
|
-
* ```typescript
|
|
669
|
-
* dayMultiplier(1.5, { dayOfWeek: weekend })
|
|
670
|
-
* ```
|
|
671
|
-
*/
|
|
672
|
-
export function dayMultiplier(factor: number, opts?: CostRuleOptions): RuleEntry {
|
|
673
|
-
return makeRule("day-cost-multiplier", { factor, ...opts });
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Adds a flat extra amount per hour for assignments on specified days.
|
|
678
|
-
*
|
|
679
|
-
* @remarks
|
|
680
|
-
* The surcharge is independent of the member's base rate.
|
|
681
|
-
*
|
|
682
|
-
* @example Weekend surcharge
|
|
683
|
-
* ```typescript
|
|
684
|
-
* daySurcharge(500, { dayOfWeek: weekend })
|
|
685
|
-
* ```
|
|
686
|
-
*/
|
|
687
|
-
export function daySurcharge(amountPerHour: number, opts?: CostRuleOptions): RuleEntry {
|
|
688
|
-
return makeRule("day-cost-surcharge", { amountPerHour, ...opts });
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Adds a flat surcharge per hour for the portion of a shift that overlaps a time-of-day window.
|
|
693
|
-
*
|
|
694
|
-
* @remarks
|
|
695
|
-
* The window supports overnight spans (e.g., 22:00-06:00). The surcharge
|
|
696
|
-
* is independent of the member's base rate.
|
|
697
|
-
*
|
|
698
|
-
* @param amountPerHour - Flat surcharge per hour in smallest currency unit
|
|
699
|
-
* @param window - Time-of-day window
|
|
700
|
-
* @param opts - Entity and time scoping
|
|
701
|
-
*
|
|
702
|
-
* @example Night differential
|
|
703
|
-
* ```typescript
|
|
704
|
-
* timeSurcharge(200, { from: t(22), until: t(6) })
|
|
705
|
-
* ```
|
|
706
|
-
*/
|
|
707
|
-
export function timeSurcharge(
|
|
708
|
-
amountPerHour: number,
|
|
709
|
-
window: { from: TimeOfDay; until: TimeOfDay },
|
|
710
|
-
opts?: CostRuleOptions,
|
|
711
|
-
): RuleEntry {
|
|
712
|
-
return makeRule("time-cost-surcharge", { amountPerHour, window, ...opts });
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Applies a multiplier to hours beyond a weekly threshold.
|
|
717
|
-
*
|
|
718
|
-
* @remarks
|
|
719
|
-
* Only the extra portion above 1x is added (the base cost is already
|
|
720
|
-
* counted by {@link minimizeCost}).
|
|
721
|
-
*
|
|
722
|
-
* @example
|
|
723
|
-
* ```typescript
|
|
724
|
-
* overtimeMultiplier({ after: 40, factor: 1.5 })
|
|
725
|
-
* ```
|
|
726
|
-
*/
|
|
727
|
-
export function overtimeMultiplier(
|
|
728
|
-
opts: { after: number; factor: number } & CostRuleOptions,
|
|
729
|
-
): RuleEntry {
|
|
730
|
-
return makeRule("overtime-weekly-multiplier", { ...opts });
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Adds a flat surcharge per hour beyond a weekly threshold.
|
|
735
|
-
*
|
|
736
|
-
* @remarks
|
|
737
|
-
* The surcharge is independent of the member's base rate.
|
|
738
|
-
*
|
|
739
|
-
* @example
|
|
740
|
-
* ```typescript
|
|
741
|
-
* overtimeSurcharge({ after: 40, amount: 1000 })
|
|
742
|
-
* ```
|
|
743
|
-
*/
|
|
744
|
-
export function overtimeSurcharge(
|
|
745
|
-
opts: { after: number; amount: number } & CostRuleOptions,
|
|
746
|
-
): RuleEntry {
|
|
747
|
-
return makeRule("overtime-weekly-surcharge", { ...opts });
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* Applies a multiplier to hours beyond a daily threshold.
|
|
752
|
-
*
|
|
753
|
-
* @remarks
|
|
754
|
-
* Only the extra portion above 1x is added (the base cost is already
|
|
755
|
-
* counted by {@link minimizeCost}).
|
|
756
|
-
*
|
|
757
|
-
* @example
|
|
758
|
-
* ```typescript
|
|
759
|
-
* dailyOvertimeMultiplier({ after: 8, factor: 1.5 })
|
|
760
|
-
* ```
|
|
761
|
-
*/
|
|
762
|
-
export function dailyOvertimeMultiplier(
|
|
763
|
-
opts: { after: number; factor: number } & CostRuleOptions,
|
|
764
|
-
): RuleEntry {
|
|
765
|
-
return makeRule("overtime-daily-multiplier", { ...opts });
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* Adds a flat surcharge per hour beyond a daily threshold.
|
|
770
|
-
*
|
|
771
|
-
* @remarks
|
|
772
|
-
* The surcharge is independent of the member's base rate.
|
|
773
|
-
*
|
|
774
|
-
* @example
|
|
775
|
-
* ```typescript
|
|
776
|
-
* dailyOvertimeSurcharge({ after: 8, amount: 500 })
|
|
777
|
-
* ```
|
|
778
|
-
*/
|
|
779
|
-
export function dailyOvertimeSurcharge(
|
|
780
|
-
opts: { after: number; amount: number } & CostRuleOptions,
|
|
781
|
-
): RuleEntry {
|
|
782
|
-
return makeRule("overtime-daily-surcharge", { ...opts });
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* Applies multiple overtime thresholds with increasing multipliers.
|
|
787
|
-
*
|
|
788
|
-
* @remarks
|
|
789
|
-
* Each tier applies only to the hours between its threshold and the next.
|
|
790
|
-
* Tiers must be sorted by threshold ascending.
|
|
791
|
-
*
|
|
792
|
-
* @example
|
|
793
|
-
* ```typescript
|
|
794
|
-
* // Hours 0-40: base rate
|
|
795
|
-
* // Hours 40-48: 1.5x
|
|
796
|
-
* // Hours 48+: 2.0x
|
|
797
|
-
* tieredOvertimeMultiplier([
|
|
798
|
-
* { after: 40, factor: 1.5 },
|
|
799
|
-
* { after: 48, factor: 2.0 },
|
|
800
|
-
* ])
|
|
801
|
-
* ```
|
|
802
|
-
*/
|
|
803
|
-
export function tieredOvertimeMultiplier(
|
|
804
|
-
tiers: [OvertimeTier, ...OvertimeTier[]],
|
|
805
|
-
opts?: CostRuleOptions,
|
|
806
|
-
): RuleEntry {
|
|
807
|
-
return makeRule("overtime-tiered-multiplier", { tiers, ...opts });
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Blocks or penalizes assignments during specified time periods.
|
|
812
|
-
*
|
|
813
|
-
* @remarks
|
|
814
|
-
* At least one time scoping field is required (`dayOfWeek`, `dateRange`,
|
|
815
|
-
* `dates`, or `recurringPeriods`).
|
|
816
|
-
*
|
|
817
|
-
* Use `from` for "off from this time until end of day" and `until` for
|
|
818
|
-
* "off from start of day until this time."
|
|
819
|
-
*
|
|
820
|
-
* @example
|
|
821
|
-
* ```typescript
|
|
822
|
-
* timeOff({ appliesTo: "mauro", dayOfWeek: weekend }),
|
|
823
|
-
* timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) }),
|
|
824
|
-
* timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } }),
|
|
825
|
-
* ```
|
|
826
|
-
*/
|
|
827
|
-
export function timeOff(opts: TimeOffOptions): RuleEntry {
|
|
828
|
-
return makeRule("time-off", { ...opts });
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* Encourages or enforces that team members work the same shifts on a day.
|
|
833
|
-
*
|
|
834
|
-
* @example
|
|
835
|
-
* ```typescript
|
|
836
|
-
* assignTogether(["alice", "bob"], { priority: "HIGH" }),
|
|
837
|
-
* ```
|
|
838
|
-
*/
|
|
839
|
-
export function assignTogether(
|
|
840
|
-
members: [string, string, ...string[]],
|
|
841
|
-
opts?: AssignTogetherOptions,
|
|
842
|
-
): RuleEntry {
|
|
843
|
-
return makeRule("assign-together", { members, ...opts });
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// ============================================================================
|
|
847
|
-
// Schedule Definition
|
|
848
|
-
// ============================================================================
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* Runtime arguments passed to {@link ScheduleDefinition.createSchedulerConfig}.
|
|
852
|
-
*
|
|
853
|
-
* @remarks
|
|
854
|
-
* Separates data known at runtime (team roster, date range, ad-hoc rules)
|
|
855
|
-
* from the static schedule definition. Runtime rules are merged after the
|
|
856
|
-
* definition's own rules and undergo the same `appliesTo` resolution.
|
|
857
|
-
*/
|
|
858
|
-
export interface RuntimeArgs {
|
|
859
|
-
/** The scheduling period (date range + optional filters). */
|
|
860
|
-
schedulingPeriod: SchedulingPeriod;
|
|
861
|
-
/** Team members available for this scheduling run. */
|
|
862
|
-
members: SchedulingMember[];
|
|
863
|
-
/** Ad-hoc rules injected at runtime (e.g., vacation, holiday closures). */
|
|
864
|
-
runtimeRules?: RuleEntry[];
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
/** Result of {@link defineSchedule}. */
|
|
868
|
-
export interface ScheduleDefinition {
|
|
869
|
-
/** Produce a {@link ModelBuilderConfig} for the solver. */
|
|
870
|
-
createSchedulerConfig(args: RuntimeArgs): ModelBuilderConfig;
|
|
871
|
-
/** Declared role names. */
|
|
872
|
-
readonly roles: readonly string[];
|
|
873
|
-
/** Declared skill names. */
|
|
874
|
-
readonly skills: readonly string[];
|
|
875
|
-
/** Names of declared semantic times. */
|
|
876
|
-
readonly timeNames: readonly string[];
|
|
877
|
-
/** Shift pattern IDs. */
|
|
878
|
-
readonly shiftPatternIds: readonly string[];
|
|
879
|
-
/** Internal rule identifiers in kebab-case (e.g., "max-hours-day", "time-off"). */
|
|
880
|
-
readonly ruleNames: readonly string[];
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/**
|
|
884
|
-
* Configuration for {@link defineSchedule}.
|
|
885
|
-
*
|
|
886
|
-
* @remarks
|
|
887
|
-
* Coverage entries for the same semantic time and target stack additively.
|
|
888
|
-
* An unscoped entry applies every day; adding a weekend-only entry on top
|
|
889
|
-
* doubles the count on those days. Use mutually exclusive `dayOfWeek` on
|
|
890
|
-
* both entries to avoid stacking. See {@link cover} for details.
|
|
891
|
-
*/
|
|
892
|
-
export interface ScheduleConfig<
|
|
893
|
-
R extends readonly string[],
|
|
894
|
-
S extends readonly string[],
|
|
895
|
-
T extends Record<string, SemanticTimeEntry>,
|
|
896
|
-
> {
|
|
897
|
-
/** Declared role names. */
|
|
898
|
-
roles: R;
|
|
899
|
-
/** Declared skill names. */
|
|
900
|
-
skills?: S;
|
|
901
|
-
/** Named semantic time periods. */
|
|
902
|
-
times: T;
|
|
903
|
-
/** Staffing requirements per time period (entries stack additively). */
|
|
904
|
-
coverage: CoverageEntry<keyof T & string, R[number] | NonNullable<S>[number]>[];
|
|
905
|
-
/** Available shift patterns. */
|
|
906
|
-
shiftPatterns: ShiftPattern[];
|
|
907
|
-
/** Scheduling rules and constraints. */
|
|
908
|
-
rules?: RuleEntry[];
|
|
909
|
-
/** Days of the week the business operates (inclusion filter). */
|
|
910
|
-
dayOfWeek?: readonly DayOfWeek[];
|
|
911
|
-
/** Which day starts the week for weekly rules. Defaults to `"monday"`. */
|
|
912
|
-
weekStartsOn?: DayOfWeek;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Defines a complete schedule configuration.
|
|
917
|
-
*
|
|
918
|
-
* @remarks
|
|
919
|
-
* Validates the static config at call time (role/skill disjointness, coverage
|
|
920
|
-
* targets, shift pattern roles). Returns a {@link ScheduleDefinition} whose
|
|
921
|
-
* `createSchedulerConfig` method validates runtime data (member IDs,
|
|
922
|
-
* `appliesTo` resolution) and produces a {@link ModelBuilderConfig}.
|
|
923
|
-
*
|
|
924
|
-
* @example
|
|
925
|
-
* ```typescript
|
|
926
|
-
* import { defineSchedule, t, time, cover, shift, maxHoursPerDay } from "dabke";
|
|
927
|
-
*
|
|
928
|
-
* export default defineSchedule({
|
|
929
|
-
* roles: ["agent", "supervisor"],
|
|
930
|
-
* times: { peak: time({ startTime: t(9), endTime: t(17) }) },
|
|
931
|
-
* coverage: [cover("peak", "agent", 4)],
|
|
932
|
-
* shiftPatterns: [shift("day", t(9), t(17))],
|
|
933
|
-
* rules: [maxHoursPerDay(8)],
|
|
934
|
-
* });
|
|
935
|
-
* ```
|
|
936
|
-
*/
|
|
937
|
-
export function defineSchedule<
|
|
938
|
-
const R extends readonly string[],
|
|
939
|
-
const S extends readonly string[],
|
|
940
|
-
const T extends Record<string, SemanticTimeEntry>,
|
|
941
|
-
>(config: ScheduleConfig<R, S, T>): ScheduleDefinition {
|
|
942
|
-
const roles = new Set<string>(config.roles);
|
|
943
|
-
const skills = new Set<string>(config.skills ?? []);
|
|
944
|
-
|
|
945
|
-
// Validate role/skill disjointness
|
|
946
|
-
for (const skill of skills) {
|
|
947
|
-
if (roles.has(skill)) {
|
|
948
|
-
throw new Error(
|
|
949
|
-
`"${skill}" is declared as both a role and a skill. Roles and skills must be disjoint.`,
|
|
950
|
-
);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// Validate shift pattern role references
|
|
955
|
-
for (const sp of config.shiftPatterns) {
|
|
956
|
-
if (sp.roles) {
|
|
957
|
-
for (const role of sp.roles) {
|
|
958
|
-
if (!roles.has(role)) {
|
|
959
|
-
throw new Error(
|
|
960
|
-
`Shift pattern "${sp.id}" references unknown role "${role}". ` +
|
|
961
|
-
`Declared roles: ${[...roles].join(", ")}`,
|
|
962
|
-
);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Validate coverage entries
|
|
969
|
-
for (const entry of config.coverage) {
|
|
970
|
-
const targets = Array.isArray(entry.target) ? entry.target : [entry.target];
|
|
971
|
-
if (Array.isArray(entry.target)) {
|
|
972
|
-
// Array target: all must be roles (OR logic)
|
|
973
|
-
for (const target of targets) {
|
|
974
|
-
if (!roles.has(target)) {
|
|
975
|
-
throw new Error(
|
|
976
|
-
`Coverage for "${entry.timeName}" references "${target}" in a role OR group, ` +
|
|
977
|
-
`but it is not a declared role. Declared roles: ${[...roles].join(", ")}`,
|
|
978
|
-
);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
} else {
|
|
982
|
-
// Single target: must be role or skill
|
|
983
|
-
if (!roles.has(entry.target) && !skills.has(entry.target)) {
|
|
984
|
-
throw new Error(
|
|
985
|
-
`Coverage for "${entry.timeName}" references unknown target "${entry.target}". ` +
|
|
986
|
-
`Declared roles: ${[...roles].join(", ")}. ` +
|
|
987
|
-
`Declared skills: ${[...skills].join(", ")}`,
|
|
988
|
-
);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
// Validate skills option
|
|
992
|
-
if (entry.options.skills) {
|
|
993
|
-
for (const s of entry.options.skills) {
|
|
994
|
-
if (!skills.has(s)) {
|
|
995
|
-
throw new Error(
|
|
996
|
-
`Coverage for "${entry.timeName}" uses skill filter "${s}" ` +
|
|
997
|
-
`which is not a declared skill. Declared skills: ${[...skills].join(", ")}`,
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Build semantic time context
|
|
1005
|
-
const semanticTimes = defineSemanticTimes(config.times);
|
|
1006
|
-
|
|
1007
|
-
// Convert coverage entries to semantic coverage requirements
|
|
1008
|
-
const coverageReqs = buildCoverageRequirements(config.coverage, roles, skills);
|
|
1009
|
-
|
|
1010
|
-
const shiftPatterns = config.shiftPatterns;
|
|
1011
|
-
|
|
1012
|
-
return {
|
|
1013
|
-
roles: config.roles as readonly string[],
|
|
1014
|
-
skills: (config.skills ?? []) as readonly string[],
|
|
1015
|
-
timeNames: Object.keys(config.times) as readonly string[],
|
|
1016
|
-
shiftPatternIds: config.shiftPatterns.map((sp) => sp.id) as readonly string[],
|
|
1017
|
-
ruleNames: (config.rules ?? []).map((r: RuleEntry) => r._rule) as readonly string[],
|
|
1018
|
-
createSchedulerConfig(args: RuntimeArgs): ModelBuilderConfig {
|
|
1019
|
-
// Detect duplicate member IDs
|
|
1020
|
-
const memberIds = new Set<string>();
|
|
1021
|
-
for (const member of args.members) {
|
|
1022
|
-
if (memberIds.has(member.id)) {
|
|
1023
|
-
throw new Error(`Duplicate member ID "${member.id}".`);
|
|
1024
|
-
}
|
|
1025
|
-
memberIds.add(member.id);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// Validate member IDs don't collide with roles/skills
|
|
1029
|
-
for (const member of args.members) {
|
|
1030
|
-
if (roles.has(member.id)) {
|
|
1031
|
-
throw new Error(`Member ID "${member.id}" collides with a declared role name.`);
|
|
1032
|
-
}
|
|
1033
|
-
if (skills.has(member.id)) {
|
|
1034
|
-
throw new Error(`Member ID "${member.id}" collides with a declared skill name.`);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// Validate member roles/skills reference declared roles/skills
|
|
1039
|
-
for (const member of args.members) {
|
|
1040
|
-
for (const role of member.roles) {
|
|
1041
|
-
if (!roles.has(role)) {
|
|
1042
|
-
throw new Error(
|
|
1043
|
-
`Member "${member.id}" references unknown role "${role}". ` +
|
|
1044
|
-
`Declared roles: ${[...roles].join(", ")}`,
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
if (member.skills) {
|
|
1049
|
-
for (const skill of member.skills) {
|
|
1050
|
-
if (!skills.has(skill)) {
|
|
1051
|
-
throw new Error(
|
|
1052
|
-
`Member "${member.id}" references unknown skill "${skill}". ` +
|
|
1053
|
-
`Declared skills: ${[...skills].join(", ")}`,
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Resolve rules
|
|
1061
|
-
const specRules = config.rules ?? [];
|
|
1062
|
-
const runtimeRules = args.runtimeRules ?? [];
|
|
1063
|
-
const allRules = [...specRules, ...runtimeRules];
|
|
1064
|
-
|
|
1065
|
-
// Validate pay data when cost rules are present
|
|
1066
|
-
const costRuleNames = new Set([
|
|
1067
|
-
"minimize-cost",
|
|
1068
|
-
"day-cost-multiplier",
|
|
1069
|
-
"day-cost-surcharge",
|
|
1070
|
-
"time-cost-surcharge",
|
|
1071
|
-
"overtime-weekly-multiplier",
|
|
1072
|
-
"overtime-weekly-surcharge",
|
|
1073
|
-
"overtime-daily-multiplier",
|
|
1074
|
-
"overtime-daily-surcharge",
|
|
1075
|
-
"overtime-tiered-multiplier",
|
|
1076
|
-
]);
|
|
1077
|
-
const hasCostRules = allRules.some((r) => costRuleNames.has(r._rule));
|
|
1078
|
-
if (hasCostRules) {
|
|
1079
|
-
const missingPay = args.members.filter((m) => !m.pay).map((m) => m.id);
|
|
1080
|
-
if (missingPay.length > 0) {
|
|
1081
|
-
throw new Error(
|
|
1082
|
-
`Cost rules require pay data on all members. Missing pay: ${missingPay.join(", ")}`,
|
|
1083
|
-
);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// Sort rules so minimize-cost compiles before modifier rules
|
|
1088
|
-
const sortedRules = sortCostRulesFirst(allRules);
|
|
1089
|
-
const ruleConfigs = resolveRules(sortedRules, roles, skills, memberIds);
|
|
1090
|
-
|
|
1091
|
-
// Resolve scheduling period with dayOfWeek filter
|
|
1092
|
-
const resolvedPeriod = applyDaysFilter(args.schedulingPeriod, config.dayOfWeek);
|
|
1093
|
-
const days = resolveDaysFromPeriod(resolvedPeriod);
|
|
1094
|
-
|
|
1095
|
-
// Resolve coverage
|
|
1096
|
-
const resolvedCoverage = semanticTimes.resolve(coverageReqs, days);
|
|
1097
|
-
|
|
1098
|
-
return {
|
|
1099
|
-
members: args.members,
|
|
1100
|
-
shiftPatterns,
|
|
1101
|
-
schedulingPeriod: resolvedPeriod,
|
|
1102
|
-
coverage: resolvedCoverage,
|
|
1103
|
-
ruleConfigs,
|
|
1104
|
-
weekStartsOn: config.weekStartsOn,
|
|
1105
|
-
};
|
|
1106
|
-
},
|
|
1107
|
-
};
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// ============================================================================
|
|
1111
|
-
// Internal: Coverage Translation
|
|
1112
|
-
// ============================================================================
|
|
1113
|
-
|
|
1114
|
-
function buildCoverageRequirements<T extends string>(
|
|
1115
|
-
entries: CoverageEntry<T, string>[],
|
|
1116
|
-
roles: Set<string>,
|
|
1117
|
-
skills: Set<string>,
|
|
1118
|
-
): MixedCoverageRequirement<T>[] {
|
|
1119
|
-
return entries.map((entry) => {
|
|
1120
|
-
// Variant form: produce a VariantCoverageRequirement
|
|
1121
|
-
if (entry.variants) {
|
|
1122
|
-
return buildVariantCoverageRequirement(entry, roles, skills);
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// Simple form: produce a SemanticCoverageRequirement
|
|
1126
|
-
const base: {
|
|
1127
|
-
semanticTime: T;
|
|
1128
|
-
targetCount: number;
|
|
1129
|
-
priority?: Priority;
|
|
1130
|
-
dayOfWeek?: DayOfWeek[];
|
|
1131
|
-
dates?: string[];
|
|
1132
|
-
} = {
|
|
1133
|
-
semanticTime: entry.timeName,
|
|
1134
|
-
targetCount: entry.count,
|
|
1135
|
-
};
|
|
1136
|
-
|
|
1137
|
-
if (entry.options.priority) base.priority = entry.options.priority;
|
|
1138
|
-
if (entry.options.dayOfWeek) base.dayOfWeek = entry.options.dayOfWeek as DayOfWeek[];
|
|
1139
|
-
if (entry.options.dates) base.dates = entry.options.dates;
|
|
1140
|
-
|
|
1141
|
-
return buildSimpleCoverageTarget(entry, base, roles, skills);
|
|
1142
|
-
}) as MixedCoverageRequirement<T>[];
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/**
|
|
1146
|
-
* Resolve the target (role/skill) for a simple coverage entry.
|
|
1147
|
-
*/
|
|
1148
|
-
function buildSimpleCoverageTarget<T extends string>(
|
|
1149
|
-
entry: CoverageEntry<T, string>,
|
|
1150
|
-
base: {
|
|
1151
|
-
semanticTime: T;
|
|
1152
|
-
targetCount: number;
|
|
1153
|
-
priority?: Priority;
|
|
1154
|
-
dayOfWeek?: DayOfWeek[];
|
|
1155
|
-
dates?: string[];
|
|
1156
|
-
},
|
|
1157
|
-
roles: Set<string>,
|
|
1158
|
-
skills: Set<string>,
|
|
1159
|
-
): MixedCoverageRequirement<T> {
|
|
1160
|
-
if (Array.isArray(entry.target)) {
|
|
1161
|
-
return {
|
|
1162
|
-
...base,
|
|
1163
|
-
roles: entry.target as [string, ...string[]],
|
|
1164
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
const singleTarget = entry.target as string;
|
|
1168
|
-
if (roles.has(singleTarget)) {
|
|
1169
|
-
if (entry.options.skills) {
|
|
1170
|
-
return {
|
|
1171
|
-
...base,
|
|
1172
|
-
roles: [singleTarget] as [string, ...string[]],
|
|
1173
|
-
skills: entry.options.skills,
|
|
1174
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1175
|
-
}
|
|
1176
|
-
return {
|
|
1177
|
-
...base,
|
|
1178
|
-
roles: [singleTarget] as [string, ...string[]],
|
|
1179
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
if (skills.has(singleTarget)) {
|
|
1183
|
-
return {
|
|
1184
|
-
...base,
|
|
1185
|
-
skills: [singleTarget] as [string, ...string[]],
|
|
1186
|
-
} satisfies MixedCoverageRequirement<T>;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
/**
|
|
1193
|
-
* Build a VariantCoverageRequirement from a variant-form CoverageEntry.
|
|
1194
|
-
*/
|
|
1195
|
-
function buildVariantCoverageRequirement<T extends string>(
|
|
1196
|
-
entry: CoverageEntry<T, string>,
|
|
1197
|
-
roles: Set<string>,
|
|
1198
|
-
skills: Set<string>,
|
|
1199
|
-
): MixedCoverageRequirement<T> {
|
|
1200
|
-
const variants = entry.variants! as unknown as [CoverageVariant, ...CoverageVariant[]];
|
|
1201
|
-
|
|
1202
|
-
const resolveTarget = (): {
|
|
1203
|
-
roles?: [string, ...string[]];
|
|
1204
|
-
skills?: [string, ...string[]];
|
|
1205
|
-
} => {
|
|
1206
|
-
if (Array.isArray(entry.target)) {
|
|
1207
|
-
return { roles: entry.target as [string, ...string[]] };
|
|
1208
|
-
}
|
|
1209
|
-
const singleTarget = entry.target as string;
|
|
1210
|
-
if (roles.has(singleTarget)) {
|
|
1211
|
-
return { roles: [singleTarget] as [string, ...string[]] };
|
|
1212
|
-
}
|
|
1213
|
-
if (skills.has(singleTarget)) {
|
|
1214
|
-
return { skills: [singleTarget] as [string, ...string[]] };
|
|
1215
|
-
}
|
|
1216
|
-
throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
|
|
1217
|
-
};
|
|
1218
|
-
|
|
1219
|
-
return {
|
|
1220
|
-
semanticTime: entry.timeName,
|
|
1221
|
-
variants,
|
|
1222
|
-
...resolveTarget(),
|
|
1223
|
-
} as MixedCoverageRequirement<T>;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// ============================================================================
|
|
1227
|
-
// Internal: Rule Translation
|
|
1228
|
-
// ============================================================================
|
|
1229
|
-
|
|
1230
|
-
/**
|
|
1231
|
-
* Resolves an `appliesTo` value into entity scope fields.
|
|
1232
|
-
*
|
|
1233
|
-
* @remarks
|
|
1234
|
-
* Each target string is checked against roles, skills, then member IDs.
|
|
1235
|
-
* If all targets resolve to the same namespace, they are combined into one
|
|
1236
|
-
* scope field. If they span namespaces, an error is thrown; the caller
|
|
1237
|
-
* should use separate rule entries instead.
|
|
1238
|
-
*/
|
|
1239
|
-
function resolveAppliesTo(
|
|
1240
|
-
appliesTo: string | string[] | undefined,
|
|
1241
|
-
roles: Set<string>,
|
|
1242
|
-
skills: Set<string>,
|
|
1243
|
-
memberIds: Set<string>,
|
|
1244
|
-
): {
|
|
1245
|
-
memberIds?: [string, ...string[]];
|
|
1246
|
-
roleIds?: [string, ...string[]];
|
|
1247
|
-
skillIds?: [string, ...string[]];
|
|
1248
|
-
} {
|
|
1249
|
-
if (!appliesTo) return {};
|
|
1250
|
-
|
|
1251
|
-
const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
|
|
1252
|
-
if (targets.length === 0) return {};
|
|
1253
|
-
|
|
1254
|
-
const resolvedRoles: string[] = [];
|
|
1255
|
-
const resolvedSkills: string[] = [];
|
|
1256
|
-
const resolvedMembers: string[] = [];
|
|
1257
|
-
|
|
1258
|
-
for (const target of targets) {
|
|
1259
|
-
if (roles.has(target)) {
|
|
1260
|
-
resolvedRoles.push(target);
|
|
1261
|
-
} else if (skills.has(target)) {
|
|
1262
|
-
resolvedSkills.push(target);
|
|
1263
|
-
} else if (memberIds.has(target)) {
|
|
1264
|
-
resolvedMembers.push(target);
|
|
1265
|
-
} else {
|
|
1266
|
-
throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
// Count how many namespaces were used
|
|
1271
|
-
const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
|
|
1272
|
-
(arr) => arr.length > 0,
|
|
1273
|
-
).length;
|
|
1274
|
-
|
|
1275
|
-
if (namespacesUsed > 1) {
|
|
1276
|
-
// Mixed namespaces not supported in a single rule config.
|
|
1277
|
-
throw new Error(
|
|
1278
|
-
`appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
|
|
1279
|
-
`skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
|
|
1280
|
-
`Use separate rule entries for each namespace.`,
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
if (resolvedRoles.length > 0) {
|
|
1285
|
-
return { roleIds: resolvedRoles as [string, ...string[]] };
|
|
1286
|
-
}
|
|
1287
|
-
if (resolvedSkills.length > 0) {
|
|
1288
|
-
return { skillIds: resolvedSkills as [string, ...string[]] };
|
|
1289
|
-
}
|
|
1290
|
-
if (resolvedMembers.length > 0) {
|
|
1291
|
-
return { memberIds: resolvedMembers as [string, ...string[]] };
|
|
1292
|
-
}
|
|
1293
|
-
return {};
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
function resolveRules(
|
|
1297
|
-
rules: RuleEntry[],
|
|
1298
|
-
roles: Set<string>,
|
|
1299
|
-
skills: Set<string>,
|
|
1300
|
-
memberIds: Set<string>,
|
|
1301
|
-
): CpsatRuleConfigEntry[] {
|
|
1302
|
-
return rules.map((rule) => {
|
|
1303
|
-
const { _type, _rule, appliesTo, dates, ...passthrough } = rule as RuleEntry & {
|
|
1304
|
-
appliesTo?: string | string[];
|
|
1305
|
-
dates?: string[];
|
|
1306
|
-
};
|
|
1307
|
-
|
|
1308
|
-
const entityScope =
|
|
1309
|
-
_rule === "assign-together" ? {} : resolveAppliesTo(appliesTo, roles, skills, memberIds);
|
|
1310
|
-
|
|
1311
|
-
// Rename dates → specificDates (internal field name)
|
|
1312
|
-
const resolvedDates = dates ? { specificDates: dates } : {};
|
|
1313
|
-
|
|
1314
|
-
switch (_rule) {
|
|
1315
|
-
case "time-off": {
|
|
1316
|
-
const { from, until, ...timeOffRest } = passthrough as Record<string, unknown> & {
|
|
1317
|
-
from?: TimeOfDay;
|
|
1318
|
-
until?: TimeOfDay;
|
|
1319
|
-
};
|
|
1320
|
-
|
|
1321
|
-
if (
|
|
1322
|
-
!timeOffRest.dayOfWeek &&
|
|
1323
|
-
!timeOffRest.dateRange &&
|
|
1324
|
-
!dates &&
|
|
1325
|
-
!timeOffRest.recurringPeriods
|
|
1326
|
-
) {
|
|
1327
|
-
throw new Error(
|
|
1328
|
-
"timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
|
|
1329
|
-
);
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
const partialDay: Record<string, unknown> = {};
|
|
1333
|
-
if (from && until) {
|
|
1334
|
-
partialDay.startTime = from;
|
|
1335
|
-
partialDay.endTime = until;
|
|
1336
|
-
} else if (from) {
|
|
1337
|
-
partialDay.startTime = from;
|
|
1338
|
-
partialDay.endTime = { hours: 23, minutes: 59 };
|
|
1339
|
-
} else if (until) {
|
|
1340
|
-
partialDay.startTime = { hours: 0, minutes: 0 };
|
|
1341
|
-
partialDay.endTime = until;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
return {
|
|
1345
|
-
name: _rule,
|
|
1346
|
-
...timeOffRest,
|
|
1347
|
-
...entityScope,
|
|
1348
|
-
...resolvedDates,
|
|
1349
|
-
...partialDay,
|
|
1350
|
-
} as CpsatRuleConfigEntry;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
case "assign-together": {
|
|
1354
|
-
const { members, ...atRest } = passthrough as Record<string, unknown> & {
|
|
1355
|
-
members: [string, string, ...string[]];
|
|
1356
|
-
};
|
|
1357
|
-
for (const member of members) {
|
|
1358
|
-
if (!memberIds.has(member)) {
|
|
1359
|
-
throw new Error(
|
|
1360
|
-
`assignTogether references unknown member "${member}". ` +
|
|
1361
|
-
`Known member IDs: ${[...memberIds].join(", ")}`,
|
|
1362
|
-
);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
return { name: _rule, groupMemberIds: members, ...atRest } as CpsatRuleConfigEntry;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
default:
|
|
1369
|
-
return {
|
|
1370
|
-
name: _rule,
|
|
1371
|
-
...passthrough,
|
|
1372
|
-
...entityScope,
|
|
1373
|
-
...resolvedDates,
|
|
1374
|
-
} as CpsatRuleConfigEntry;
|
|
1375
|
-
}
|
|
1376
|
-
}) as CpsatRuleConfigEntry[];
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// ============================================================================
|
|
1380
|
-
// Internal: Cost Rule Ordering
|
|
1381
|
-
// ============================================================================
|
|
1382
|
-
|
|
1383
|
-
/**
|
|
1384
|
-
* Sorts rules so that `minimize-cost` compiles before cost modifier rules.
|
|
1385
|
-
*
|
|
1386
|
-
* @remarks
|
|
1387
|
-
* The `minimize-cost` rule must be compiled first because modifier rules
|
|
1388
|
-
* (multipliers, surcharges) reference cost variables it creates.
|
|
1389
|
-
* Non-cost rules retain their original relative order.
|
|
1390
|
-
*/
|
|
1391
|
-
function sortCostRulesFirst(rules: RuleEntry[]): RuleEntry[] {
|
|
1392
|
-
return rules.toSorted((a, b) => {
|
|
1393
|
-
const aIsCostBase = a._rule === "minimize-cost" ? 0 : 1;
|
|
1394
|
-
const bIsCostBase = b._rule === "minimize-cost" ? 0 : 1;
|
|
1395
|
-
return aIsCostBase - bIsCostBase;
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// ============================================================================
|
|
1400
|
-
// Internal: Scheduling Period Helpers
|
|
1401
|
-
// ============================================================================
|
|
1402
|
-
|
|
1403
|
-
function applyDaysFilter(
|
|
1404
|
-
schedulingPeriod: SchedulingPeriod,
|
|
1405
|
-
dayOfWeek?: readonly DayOfWeek[],
|
|
1406
|
-
): SchedulingPeriod {
|
|
1407
|
-
if (!dayOfWeek || dayOfWeek.length === 0) {
|
|
1408
|
-
return schedulingPeriod;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const existingDays = schedulingPeriod.dayOfWeek;
|
|
1412
|
-
if (!existingDays || existingDays.length === 0) {
|
|
1413
|
-
return { ...schedulingPeriod, dayOfWeek: dayOfWeek as DayOfWeek[] };
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
const existingSet = new Set(existingDays);
|
|
1417
|
-
const intersected = dayOfWeek.filter((day) => existingSet.has(day)) as DayOfWeek[];
|
|
1418
|
-
return { ...schedulingPeriod, dayOfWeek: intersected };
|
|
1419
|
-
}
|