dabke 0.82.0 → 0.84.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 +44 -0
- package/README.md +22 -24
- package/dist/client.d.ts +39 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/client.schemas.d.ts +118 -18
- package/dist/client.schemas.d.ts.map +1 -1
- package/dist/client.schemas.js +110 -6
- package/dist/client.schemas.js.map +1 -1
- package/dist/client.types.d.ts +63 -7
- package/dist/client.types.d.ts.map +1 -1
- package/dist/client.types.js.map +1 -1
- package/dist/cpsat/cost.d.ts +5 -2
- package/dist/cpsat/cost.d.ts.map +1 -1
- package/dist/cpsat/cost.js +13 -6
- package/dist/cpsat/cost.js.map +1 -1
- package/dist/cpsat/model-builder.d.ts +42 -61
- package/dist/cpsat/model-builder.d.ts.map +1 -1
- package/dist/cpsat/model-builder.js +451 -56
- package/dist/cpsat/model-builder.js.map +1 -1
- package/dist/cpsat/response.d.ts +19 -7
- package/dist/cpsat/response.d.ts.map +1 -1
- package/dist/cpsat/response.js +48 -9
- package/dist/cpsat/response.js.map +1 -1
- package/dist/cpsat/rule-descriptor.d.ts +250 -0
- package/dist/cpsat/rule-descriptor.d.ts.map +1 -0
- package/dist/cpsat/rule-descriptor.js +51 -0
- package/dist/cpsat/rule-descriptor.js.map +1 -0
- package/dist/cpsat/rules/artifacts.d.ts +9 -0
- package/dist/cpsat/rules/artifacts.d.ts.map +1 -0
- package/dist/cpsat/rules/artifacts.js +31 -0
- package/dist/cpsat/rules/artifacts.js.map +1 -0
- package/dist/cpsat/rules/assign-together.d.ts +18 -20
- package/dist/cpsat/rules/assign-together.d.ts.map +1 -1
- package/dist/cpsat/rules/assign-together.js +90 -54
- package/dist/cpsat/rules/assign-together.js.map +1 -1
- package/dist/cpsat/rules/assigned-day.d.ts +12 -0
- package/dist/cpsat/rules/assigned-day.d.ts.map +1 -0
- package/dist/cpsat/rules/assigned-day.js +43 -0
- package/dist/cpsat/rules/assigned-day.js.map +1 -0
- package/dist/cpsat/rules/assignment-priority.d.ts +15 -29
- package/dist/cpsat/rules/assignment-priority.d.ts.map +1 -1
- package/dist/cpsat/rules/assignment-priority.js +47 -50
- package/dist/cpsat/rules/assignment-priority.js.map +1 -1
- package/dist/cpsat/rules/assignment-terms.d.ts +6 -0
- package/dist/cpsat/rules/assignment-terms.d.ts.map +1 -0
- package/dist/cpsat/rules/assignment-terms.js +19 -0
- package/dist/cpsat/rules/assignment-terms.js.map +1 -0
- package/dist/cpsat/rules/concurrent-intervals.d.ts +19 -0
- package/dist/cpsat/rules/concurrent-intervals.d.ts.map +1 -0
- package/dist/cpsat/rules/concurrent-intervals.js +57 -0
- package/dist/cpsat/rules/concurrent-intervals.js.map +1 -0
- package/dist/cpsat/rules/cost-utils.d.ts +1 -3
- package/dist/cpsat/rules/cost-utils.d.ts.map +1 -1
- package/dist/cpsat/rules/cost-utils.js +0 -7
- package/dist/cpsat/rules/cost-utils.js.map +1 -1
- package/dist/cpsat/rules/day-cost-multiplier.d.ts +10 -10
- package/dist/cpsat/rules/day-cost-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/day-cost-multiplier.js +88 -77
- package/dist/cpsat/rules/day-cost-multiplier.js.map +1 -1
- package/dist/cpsat/rules/day-cost-surcharge.d.ts +10 -10
- package/dist/cpsat/rules/day-cost-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/day-cost-surcharge.js +76 -65
- package/dist/cpsat/rules/day-cost-surcharge.js.map +1 -1
- package/dist/cpsat/rules/index.d.ts +30 -21
- package/dist/cpsat/rules/index.d.ts.map +1 -1
- package/dist/cpsat/rules/index.js +30 -21
- package/dist/cpsat/rules/index.js.map +1 -1
- package/dist/cpsat/rules/location-preference.d.ts +14 -21
- package/dist/cpsat/rules/location-preference.d.ts.map +1 -1
- package/dist/cpsat/rules/location-preference.js +45 -45
- package/dist/cpsat/rules/location-preference.js.map +1 -1
- package/dist/cpsat/rules/max-concurrent-assignments.d.ts +46 -0
- package/dist/cpsat/rules/max-concurrent-assignments.d.ts.map +1 -0
- package/dist/cpsat/rules/max-concurrent-assignments.js +121 -0
- package/dist/cpsat/rules/max-concurrent-assignments.js.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts +18 -17
- package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -1
- package/dist/cpsat/rules/max-consecutive-days.js +67 -66
- package/dist/cpsat/rules/max-consecutive-days.js.map +1 -1
- package/dist/cpsat/rules/max-days-of-week-per-period.d.ts +45 -0
- package/dist/cpsat/rules/max-days-of-week-per-period.d.ts.map +1 -0
- package/dist/cpsat/rules/max-days-of-week-per-period.js +95 -0
- package/dist/cpsat/rules/max-days-of-week-per-period.js.map +1 -0
- package/dist/cpsat/rules/max-days-week.d.ts +30 -0
- package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-days-week.js +83 -0
- package/dist/cpsat/rules/max-days-week.js.map +1 -0
- package/dist/cpsat/rules/max-hours-day.d.ts +14 -31
- package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -1
- package/dist/cpsat/rules/max-hours-day.js +56 -72
- package/dist/cpsat/rules/max-hours-day.js.map +1 -1
- package/dist/cpsat/rules/max-hours-week.d.ts +15 -31
- package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
- package/dist/cpsat/rules/max-hours-week.js +60 -75
- package/dist/cpsat/rules/max-hours-week.js.map +1 -1
- package/dist/cpsat/rules/max-shifts-day.d.ts +17 -33
- package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
- package/dist/cpsat/rules/max-shifts-day.js +57 -73
- package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
- package/dist/cpsat/rules/min-consecutive-days.d.ts +18 -18
- package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
- package/dist/cpsat/rules/min-consecutive-days.js +123 -94
- package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
- package/dist/cpsat/rules/min-days-of-week-per-period.d.ts +35 -0
- package/dist/cpsat/rules/min-days-of-week-per-period.d.ts.map +1 -0
- package/dist/cpsat/rules/min-days-of-week-per-period.js +108 -0
- package/dist/cpsat/rules/min-days-of-week-per-period.js.map +1 -0
- package/dist/cpsat/rules/min-days-week.d.ts +21 -0
- package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-days-week.js +95 -0
- package/dist/cpsat/rules/min-days-week.js.map +1 -0
- package/dist/cpsat/rules/min-hours-day.d.ts +14 -17
- package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -1
- package/dist/cpsat/rules/min-hours-day.js +72 -54
- package/dist/cpsat/rules/min-hours-day.js.map +1 -1
- package/dist/cpsat/rules/min-hours-week.d.ts +15 -18
- package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
- package/dist/cpsat/rules/min-hours-week.js +75 -57
- package/dist/cpsat/rules/min-hours-week.js.map +1 -1
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts +14 -17
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
- package/dist/cpsat/rules/min-rest-between-shifts.js +148 -138
- package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
- package/dist/cpsat/rules/minimize-cost.d.ts +2 -18
- package/dist/cpsat/rules/minimize-cost.d.ts.map +1 -1
- package/dist/cpsat/rules/minimize-cost.js +151 -163
- package/dist/cpsat/rules/minimize-cost.js.map +1 -1
- package/dist/cpsat/rules/must-assign.d.ts +12 -0
- package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
- package/dist/cpsat/rules/must-assign.js +54 -0
- package/dist/cpsat/rules/must-assign.js.map +1 -0
- package/dist/cpsat/rules/overtime-daily-multiplier.d.ts +6 -10
- package/dist/cpsat/rules/overtime-daily-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-daily-multiplier.js +118 -112
- package/dist/cpsat/rules/overtime-daily-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-daily-surcharge.d.ts +6 -11
- package/dist/cpsat/rules/overtime-daily-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-daily-surcharge.js +104 -99
- package/dist/cpsat/rules/overtime-daily-surcharge.js.map +1 -1
- package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts +9 -21
- package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-tiered-multiplier.js +181 -199
- package/dist/cpsat/rules/overtime-tiered-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts +13 -12
- package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-multiplier.js +143 -143
- package/dist/cpsat/rules/overtime-weekly-multiplier.js.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts +7 -11
- package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/overtime-weekly-surcharge.js +124 -119
- package/dist/cpsat/rules/overtime-weekly-surcharge.js.map +1 -1
- package/dist/cpsat/rules/pattern-eligibility.d.ts +6 -0
- package/dist/cpsat/rules/pattern-eligibility.d.ts.map +1 -0
- package/dist/cpsat/rules/pattern-eligibility.js +16 -0
- package/dist/cpsat/rules/pattern-eligibility.js.map +1 -0
- package/dist/cpsat/rules/pattern-time.d.ts +5 -0
- package/dist/cpsat/rules/pattern-time.d.ts.map +1 -0
- package/dist/cpsat/rules/pattern-time.js +20 -0
- package/dist/cpsat/rules/pattern-time.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +217 -27
- package/dist/cpsat/rules/registry.d.ts.map +1 -1
- package/dist/cpsat/rules/registry.js +98 -31
- package/dist/cpsat/rules/registry.js.map +1 -1
- package/dist/cpsat/rules/resolver.d.ts +9 -7
- package/dist/cpsat/rules/resolver.d.ts.map +1 -1
- package/dist/cpsat/rules/resolver.js +19 -18
- package/dist/cpsat/rules/resolver.js.map +1 -1
- package/dist/cpsat/rules/role-preference.d.ts +24 -0
- package/dist/cpsat/rules/role-preference.d.ts.map +1 -0
- package/dist/cpsat/rules/role-preference.js +72 -0
- package/dist/cpsat/rules/role-preference.js.map +1 -0
- package/dist/cpsat/rules/rules.types.d.ts +93 -17
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
- package/dist/cpsat/rules/scope.types.d.ts +25 -12
- package/dist/cpsat/rules/scope.types.d.ts.map +1 -1
- package/dist/cpsat/rules/scope.types.js +14 -19
- package/dist/cpsat/rules/scope.types.js.map +1 -1
- package/dist/cpsat/rules/target-days-week.d.ts +31 -0
- package/dist/cpsat/rules/target-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/target-days-week.js +80 -0
- package/dist/cpsat/rules/target-days-week.js.map +1 -0
- package/dist/cpsat/rules/target-peak-concurrent-assignments.d.ts +33 -0
- package/dist/cpsat/rules/target-peak-concurrent-assignments.d.ts.map +1 -0
- package/dist/cpsat/rules/target-peak-concurrent-assignments.js +146 -0
- package/dist/cpsat/rules/target-peak-concurrent-assignments.js.map +1 -0
- package/dist/cpsat/rules/time-cost-surcharge.d.ts +15 -14
- package/dist/cpsat/rules/time-cost-surcharge.d.ts.map +1 -1
- package/dist/cpsat/rules/time-cost-surcharge.js +82 -96
- package/dist/cpsat/rules/time-cost-surcharge.js.map +1 -1
- package/dist/cpsat/rules/time-off.d.ts +21 -57
- package/dist/cpsat/rules/time-off.d.ts.map +1 -1
- package/dist/cpsat/rules/time-off.js +114 -127
- package/dist/cpsat/rules/time-off.js.map +1 -1
- package/dist/cpsat/rules/variables.d.ts +4 -0
- package/dist/cpsat/rules/variables.d.ts.map +1 -0
- package/dist/cpsat/rules/variables.js +10 -0
- package/dist/cpsat/rules/variables.js.map +1 -0
- package/dist/cpsat/rules.d.ts +20 -3
- package/dist/cpsat/rules.d.ts.map +1 -1
- package/dist/cpsat/rules.js +19 -2
- package/dist/cpsat/rules.js.map +1 -1
- package/dist/cpsat/semantic-time.d.ts +7 -6
- package/dist/cpsat/semantic-time.d.ts.map +1 -1
- package/dist/cpsat/semantic-time.js +15 -28
- package/dist/cpsat/semantic-time.js.map +1 -1
- package/dist/cpsat/types.d.ts +47 -4
- package/dist/cpsat/types.d.ts.map +1 -1
- package/dist/cpsat/types.js +24 -1
- package/dist/cpsat/types.js.map +1 -1
- package/dist/cpsat/utils.d.ts +23 -6
- package/dist/cpsat/utils.d.ts.map +1 -1
- package/dist/cpsat/utils.js +76 -15
- package/dist/cpsat/utils.js.map +1 -1
- package/dist/cpsat/validation-reporter.d.ts +6 -2
- package/dist/cpsat/validation-reporter.d.ts.map +1 -1
- package/dist/cpsat/validation-reporter.js +122 -10
- package/dist/cpsat/validation-reporter.js.map +1 -1
- package/dist/cpsat/validation.types.d.ts +21 -10
- package/dist/cpsat/validation.types.d.ts.map +1 -1
- package/dist/datetime.utils.d.ts +7 -13
- package/dist/datetime.utils.d.ts.map +1 -1
- package/dist/datetime.utils.js +29 -36
- package/dist/datetime.utils.js.map +1 -1
- package/dist/index.d.ts +15 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/schedule/cost.d.ts +192 -0
- package/dist/schedule/cost.d.ts.map +1 -0
- package/dist/schedule/cost.js +151 -0
- package/dist/schedule/cost.js.map +1 -0
- package/dist/schedule/coverage.d.ts +114 -0
- package/dist/schedule/coverage.d.ts.map +1 -0
- package/dist/schedule/coverage.js +56 -0
- package/dist/schedule/coverage.js.map +1 -0
- package/dist/schedule/definition.d.ts +346 -0
- package/dist/schedule/definition.d.ts.map +1 -0
- package/dist/schedule/definition.js +668 -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 +678 -0
- package/dist/schedule/rules.d.ts.map +1 -0
- package/dist/schedule/rules.js +554 -0
- package/dist/schedule/rules.js.map +1 -0
- package/dist/schedule/shift-patterns.d.ts +17 -0
- package/dist/schedule/shift-patterns.d.ts.map +1 -0
- package/dist/schedule/shift-patterns.js +24 -0
- package/dist/schedule/shift-patterns.js.map +1 -0
- package/dist/schedule/time-periods.d.ts +79 -0
- package/dist/schedule/time-periods.d.ts.map +1 -0
- package/dist/schedule/time-periods.js +101 -0
- package/dist/schedule/time-periods.js.map +1 -0
- package/dist/types.d.ts +67 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +64 -0
- package/dist/types.js.map +1 -1
- package/package.json +8 -14
- package/solver/src/solver/models.py +41 -4
- package/solver/src/solver/solver.py +546 -157
- package/solver/tests/test_solver.py +363 -7
- package/src/client.schemas.ts +127 -10
- package/src/client.ts +3 -0
- package/src/client.types.ts +73 -7
- package/src/cpsat/cost.ts +17 -7
- package/src/cpsat/model-builder.ts +614 -129
- package/src/cpsat/response.ts +57 -13
- package/src/cpsat/rule-descriptor.ts +311 -0
- package/src/cpsat/rules/artifacts.ts +56 -0
- package/src/cpsat/rules/assign-together.ts +104 -69
- package/src/cpsat/rules/assigned-day.ts +70 -0
- package/src/cpsat/rules/assignment-priority.ts +54 -58
- package/src/cpsat/rules/assignment-terms.ts +43 -0
- package/src/cpsat/rules/concurrent-intervals.ts +93 -0
- package/src/cpsat/rules/cost-utils.ts +1 -9
- package/src/cpsat/rules/day-cost-multiplier.ts +101 -81
- package/src/cpsat/rules/day-cost-surcharge.ts +91 -70
- package/src/cpsat/rules/index.ts +30 -21
- package/src/cpsat/rules/location-preference.ts +47 -52
- package/src/cpsat/rules/max-concurrent-assignments.ts +170 -0
- package/src/cpsat/rules/max-consecutive-days.ts +74 -91
- package/src/cpsat/rules/max-days-of-week-per-period.ts +127 -0
- package/src/cpsat/rules/max-days-week.ts +117 -0
- package/src/cpsat/rules/max-hours-day.ts +64 -84
- package/src/cpsat/rules/max-hours-week.ts +74 -90
- package/src/cpsat/rules/max-shifts-day.ts +65 -85
- package/src/cpsat/rules/min-consecutive-days.ts +161 -127
- package/src/cpsat/rules/min-days-of-week-per-period.ts +144 -0
- package/src/cpsat/rules/min-days-week.ts +122 -0
- package/src/cpsat/rules/min-hours-day.ts +79 -58
- package/src/cpsat/rules/min-hours-week.ts +87 -62
- package/src/cpsat/rules/min-rest-between-shifts.ts +191 -178
- package/src/cpsat/rules/minimize-cost.ts +175 -188
- package/src/cpsat/rules/must-assign.ts +71 -0
- package/src/cpsat/rules/overtime-daily-multiplier.ts +158 -140
- package/src/cpsat/rules/overtime-daily-surcharge.ts +141 -126
- package/src/cpsat/rules/overtime-tiered-multiplier.ts +234 -253
- package/src/cpsat/rules/overtime-weekly-multiplier.ts +183 -175
- package/src/cpsat/rules/overtime-weekly-surcharge.ts +161 -150
- package/src/cpsat/rules/pattern-eligibility.ts +26 -0
- package/src/cpsat/rules/pattern-time.ts +30 -0
- package/src/cpsat/rules/registry.ts +140 -56
- package/src/cpsat/rules/resolver.ts +58 -30
- package/src/cpsat/rules/role-preference.ts +92 -0
- package/src/cpsat/rules/rules.types.ts +92 -17
- package/src/cpsat/rules/scope.types.ts +32 -27
- package/src/cpsat/rules/target-days-week.ts +105 -0
- package/src/cpsat/rules/target-peak-concurrent-assignments.ts +212 -0
- package/src/cpsat/rules/time-cost-surcharge.ts +116 -122
- package/src/cpsat/rules/time-off.ts +154 -163
- package/src/cpsat/rules/variables.ts +11 -0
- package/src/cpsat/rules.ts +43 -17
- package/src/cpsat/semantic-time.ts +29 -43
- package/src/cpsat/types.ts +53 -4
- package/src/cpsat/utils.ts +92 -20
- package/src/cpsat/validation-reporter.ts +170 -19
- package/src/cpsat/validation.types.ts +22 -11
- package/src/datetime.utils.ts +45 -44
- package/src/index.ts +72 -15
- package/src/schedule/cost.ts +221 -0
- package/src/schedule/coverage.ts +164 -0
- package/src/schedule/definition.ts +1447 -0
- package/src/schedule/index.ts +132 -0
- package/src/schedule/rules.ts +1028 -0
- package/src/schedule/shift-patterns.ts +29 -0
- package/src/schedule/time-periods.ts +120 -0
- package/src/types.ts +114 -2
- 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 +0 -1323
- 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,1028 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduling rules: constraints, preferences, and time-off.
|
|
3
|
+
*
|
|
4
|
+
* Rules answer "what schedules are allowed or preferred?". Use
|
|
5
|
+
* {@link cover} for lower-bound demand, then use rules for caps, limits, rest
|
|
6
|
+
* periods, time off, fairness, and other constraints on assignments.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* rules: [
|
|
11
|
+
* // Hour and day limits
|
|
12
|
+
* maxHoursPerDay(10),
|
|
13
|
+
* maxHoursPerWeek(48),
|
|
14
|
+
* maxHoursPerWeek(20, { appliesTo: "part-time" }),
|
|
15
|
+
* maxDaysPerWeek(5, { priority: "HIGH" }),
|
|
16
|
+
* targetDaysPerWeek(4, { appliesTo: "full-time", priority: "HIGH" }),
|
|
17
|
+
* minDaysPerWeek(4, { appliesTo: "full-time", priority: "HIGH" }),
|
|
18
|
+
* maxShiftsPerDay(1),
|
|
19
|
+
* maxConcurrentAssignments(5, { appliesTo: "chair_stylist" }),
|
|
20
|
+
* targetPeakConcurrentAssignments(5, { appliesTo: "chair_stylist", dayOfWeek: ["thursday"], priority: "HIGH" }),
|
|
21
|
+
* minRestBetweenShifts(11),
|
|
22
|
+
*
|
|
23
|
+
* // Staffing obligations and preferences
|
|
24
|
+
* mustAssign({ appliesTo: ["diana", "yavuz"] }),
|
|
25
|
+
* timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } }),
|
|
26
|
+
* timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) }),
|
|
27
|
+
* preferAssignment({ appliesTo: "waiter" }),
|
|
28
|
+
* avoidAssignment({ appliesTo: "student", dayOfWeek: weekdays }),
|
|
29
|
+
* assignTogether(["alice", "bob"]),
|
|
30
|
+
* ]
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Custom rules can stay type-safe too:
|
|
34
|
+
*
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const debugRuleRegistry = createCpsatRuleRegistry({
|
|
37
|
+
* debug: defineRuleDescriptor({
|
|
38
|
+
* name: "debug",
|
|
39
|
+
* schema: z.object({ flag: z.boolean() }),
|
|
40
|
+
* compile() {
|
|
41
|
+
* return { rule: "debug", artifacts: [] };
|
|
42
|
+
* },
|
|
43
|
+
* }),
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* const defineDebugRule = defineRuleFor(debugRuleRegistry);
|
|
47
|
+
*
|
|
48
|
+
* schedule({
|
|
49
|
+
* roleIds: ["waiter"],
|
|
50
|
+
* times: { lunch: time({ startTime: t(12), endTime: t(15) }) },
|
|
51
|
+
* coverage: [cover("lunch", "waiter", 1)],
|
|
52
|
+
* shiftPatterns: [shift("lunch", t(12), t(15))],
|
|
53
|
+
* ruleRegistry: debugRuleRegistry,
|
|
54
|
+
* rules: [defineDebugRule("debug", { flag: true })],
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @module
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import type { DayOfWeek, TimeOfDay } from "../types.js";
|
|
62
|
+
import type { Priority, SoftPriority } from "../cpsat/types.js";
|
|
63
|
+
import type {
|
|
64
|
+
BuiltInCpsatRuleRegistry,
|
|
65
|
+
CpsatRuleConfigEntryFor,
|
|
66
|
+
CpsatRuleName,
|
|
67
|
+
CpsatRuleRegistry,
|
|
68
|
+
} from "../cpsat/rules/rules.types.js";
|
|
69
|
+
import type { RecurringPeriod } from "../cpsat/rules/scope.types.js";
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Rule Options
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Scoping options shared by most rule functions.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* Default priority is `MANDATORY`. Use `appliesTo` to scope to a
|
|
80
|
+
* role, skill, or member ID. Use time scoping options (`dayOfWeek`,
|
|
81
|
+
* `dateRange`, `dates`) to limit when the rule applies.
|
|
82
|
+
* Scope answers where a rule is evaluated. It does not change the rule's core
|
|
83
|
+
* meaning. For example, `maxDaysPerWeek(4, { dayOfWeek: ["monday"] })` is still
|
|
84
|
+
* a day-cap concept, just scoped to matching scheduling days. Priority is just
|
|
85
|
+
* as important as scope: scope decides where a rule applies, and priority decides how hard the solver should enforce it. Use
|
|
86
|
+
* `"MANDATORY"` for requirements the schedule cannot violate, and softer
|
|
87
|
+
* priorities for things the requirements describe as preferred, ideal, or
|
|
88
|
+
* "where possible".
|
|
89
|
+
* Not all rules support all scoping options. Entity-only rules
|
|
90
|
+
* (e.g., {@link maxConsecutiveDays}) ignore time scoping.
|
|
91
|
+
*
|
|
92
|
+
* @category Rules
|
|
93
|
+
*/
|
|
94
|
+
export interface RuleOptions {
|
|
95
|
+
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
96
|
+
appliesTo?: string | string[];
|
|
97
|
+
/** Restrict to specific days of the week. */
|
|
98
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
99
|
+
/** Restrict to a date range. */
|
|
100
|
+
dateRange?: { start: string; end: string };
|
|
101
|
+
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
102
|
+
dates?: string[];
|
|
103
|
+
/** Restrict to recurring calendar periods. */
|
|
104
|
+
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
105
|
+
/** Defaults to `"MANDATORY"`. */
|
|
106
|
+
priority?: Priority;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Options for rules that support entity scoping only (no time scoping).
|
|
111
|
+
*
|
|
112
|
+
* @remarks
|
|
113
|
+
* Used by rules whose semantics are inherently per-day or per-week
|
|
114
|
+
* (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
|
|
115
|
+
* be meaningfully restricted to a date range or day of week. `priority`
|
|
116
|
+
* still controls whether the rule is a hard requirement or a softer target.
|
|
117
|
+
*
|
|
118
|
+
* @category Rules
|
|
119
|
+
*/
|
|
120
|
+
export interface EntityOnlyRuleOptions {
|
|
121
|
+
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
122
|
+
appliesTo?: string | string[];
|
|
123
|
+
/** Defaults to `"MANDATORY"`. */
|
|
124
|
+
priority?: Priority;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Options for {@link timeOff}.
|
|
129
|
+
*
|
|
130
|
+
* @remarks
|
|
131
|
+
* At least one time scoping field is required (`dayOfWeek`, `dateRange`,
|
|
132
|
+
* `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
|
|
133
|
+
* of a day.
|
|
134
|
+
*
|
|
135
|
+
* @category Rules
|
|
136
|
+
*/
|
|
137
|
+
export interface TimeOffOptions {
|
|
138
|
+
/** Who this rule applies to (role name, skill name, or member ID). */
|
|
139
|
+
appliesTo?: string | string[];
|
|
140
|
+
/** Off from this time until end of day. */
|
|
141
|
+
from?: TimeOfDay;
|
|
142
|
+
/** Off from start of day until this time. */
|
|
143
|
+
until?: TimeOfDay;
|
|
144
|
+
/** Restrict to specific days of the week. */
|
|
145
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
146
|
+
/** Restrict to a date range. */
|
|
147
|
+
dateRange?: { start: string; end: string };
|
|
148
|
+
/** Restrict to specific dates (YYYY-MM-DD). */
|
|
149
|
+
dates?: string[];
|
|
150
|
+
/** Restrict to recurring calendar periods. */
|
|
151
|
+
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
152
|
+
/** Defaults to `"MANDATORY"`. */
|
|
153
|
+
priority?: Priority;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Options for {@link assignTogether}.
|
|
158
|
+
*
|
|
159
|
+
* @category Rules
|
|
160
|
+
*/
|
|
161
|
+
export interface AssignTogetherOptions {
|
|
162
|
+
/** Defaults to `"MANDATORY"`. */
|
|
163
|
+
priority?: Priority;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Options for {@link maxConcurrentAssignments}.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* Use this when the business has a hard productive cap that is independent from
|
|
171
|
+
* its coverage floor, for example when only a fixed number of assignments can
|
|
172
|
+
* overlap productively at once. Omit `startTime`/`endTime` to cap concurrent
|
|
173
|
+
* assignments across the full day. Provide both together to limit concurrency
|
|
174
|
+
* only within a specific time window.
|
|
175
|
+
*
|
|
176
|
+
* @category Rules
|
|
177
|
+
*/
|
|
178
|
+
export interface MaxConcurrentAssignmentsOptions extends RuleOptions {
|
|
179
|
+
/** Optional start of the capped time window. */
|
|
180
|
+
startTime?: TimeOfDay;
|
|
181
|
+
/** Optional end of the capped time window. */
|
|
182
|
+
endTime?: TimeOfDay;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Options for {@link targetDaysPerWeek}.
|
|
187
|
+
*
|
|
188
|
+
* @remarks
|
|
189
|
+
* Use this for stated weekly patterns like "works a 4-day week" when that
|
|
190
|
+
* pattern should remain flexible. This is soft-only by design. Pair it with
|
|
191
|
+
* {@link maxDaysPerWeek} when the same number is also a hard cap, and use
|
|
192
|
+
* {@link minDaysPerWeek} only when the requirements explicitly require a minimum.
|
|
193
|
+
*
|
|
194
|
+
* @category Rules
|
|
195
|
+
*/
|
|
196
|
+
export interface TargetDaysPerWeekOptions extends Omit<EntityOnlyRuleOptions, "priority"> {
|
|
197
|
+
/** Soft only. Defaults to `"HIGH"`. */
|
|
198
|
+
priority?: SoftPriority;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Options for {@link targetPeakConcurrentAssignments}.
|
|
203
|
+
*
|
|
204
|
+
* @remarks
|
|
205
|
+
* Use this when the business wants to hit a peak concurrency target within a
|
|
206
|
+
* day, without making that target a lower bound across the whole day. This is
|
|
207
|
+
* soft-only by design. Unlike {@link cover}, this helper does not establish a
|
|
208
|
+
* minimum that must hold throughout a semantic time. Instead it shifts the
|
|
209
|
+
* solution toward reaching the desired peak within the scoped days. Use
|
|
210
|
+
* {@link cover} for a whole-window minimum, and pair
|
|
211
|
+
* this helper with
|
|
212
|
+
* {@link maxConcurrentAssignments} when the same number is also a hard cap.
|
|
213
|
+
* This is useful for language like "fill all 5 chairs on peak days" or
|
|
214
|
+
* "ideally reach full capacity during the busy point".
|
|
215
|
+
*
|
|
216
|
+
* @category Rules
|
|
217
|
+
*/
|
|
218
|
+
export interface TargetPeakConcurrentAssignmentsOptions extends Omit<RuleOptions, "priority"> {
|
|
219
|
+
/** Soft only. Defaults to `"HIGH"`. */
|
|
220
|
+
priority?: SoftPriority;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Rule Entry
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Context passed to a rule's resolve function during compilation.
|
|
229
|
+
*
|
|
230
|
+
* Contains the declared roles, skills, and member IDs so the resolver
|
|
231
|
+
* can translate user-facing fields (like `appliesTo`) into internal
|
|
232
|
+
* scoping fields.
|
|
233
|
+
*/
|
|
234
|
+
export interface RuleResolveContext {
|
|
235
|
+
readonly roles: ReadonlySet<string>;
|
|
236
|
+
readonly skills: ReadonlySet<string>;
|
|
237
|
+
readonly memberIds: ReadonlySet<string>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Internal rule entry type
|
|
241
|
+
interface RuleEntryBase<Name extends string = string> {
|
|
242
|
+
readonly _type: "rule";
|
|
243
|
+
readonly _rule: Name;
|
|
244
|
+
/**
|
|
245
|
+
* Optional custom resolver. When present, `resolveRules()` calls this
|
|
246
|
+
* instead of the default translation path. Built-in rules that need
|
|
247
|
+
* special field mapping (e.g., `timeOff`, `assignTogether`) attach one;
|
|
248
|
+
* all other rules use the default resolver.
|
|
249
|
+
*/
|
|
250
|
+
readonly _resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: Name };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A typed rule entry returned by rule helpers and {@link defineRule}.
|
|
255
|
+
*
|
|
256
|
+
* @remarks
|
|
257
|
+
* Pass these directly into the `rules` array of {@link ScheduleConfig}. The
|
|
258
|
+
* internal fields are resolved during compilation.
|
|
259
|
+
*
|
|
260
|
+
* @category Rules
|
|
261
|
+
*/
|
|
262
|
+
export type RuleEntry<
|
|
263
|
+
Name extends string = string,
|
|
264
|
+
Fields extends object = Record<string, unknown>,
|
|
265
|
+
> = RuleEntryBase<Name> & Fields & Record<string, unknown>;
|
|
266
|
+
|
|
267
|
+
type TypedRuleEntryFor<Registry extends CpsatRuleRegistry> = {
|
|
268
|
+
[K in keyof Registry & string]: RuleEntry<
|
|
269
|
+
K,
|
|
270
|
+
Omit<CpsatRuleConfigEntryFor<Pick<Registry, K>>, "name">
|
|
271
|
+
>;
|
|
272
|
+
}[keyof Registry & string];
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A rule entry accepted by {@link ScheduleConfig.rules}.
|
|
276
|
+
*
|
|
277
|
+
* @remarks
|
|
278
|
+
* Built-in rule helpers return entries that are always valid. Custom rules created
|
|
279
|
+
* via {@link defineRule} are checked against the active rule registry when passed
|
|
280
|
+
* to {@link schedule} or {@link partialSchedule}. For registry-bound authoring,
|
|
281
|
+
* prefer {@link defineRuleFor}.
|
|
282
|
+
*
|
|
283
|
+
* @category Rules
|
|
284
|
+
*/
|
|
285
|
+
export type ScheduleRuleEntry<TRuleRegistry extends CpsatRuleRegistry = BuiltInCpsatRuleRegistry> =
|
|
286
|
+
TypedRuleEntryFor<TRuleRegistry>;
|
|
287
|
+
|
|
288
|
+
type SanitizedRuleFields<Fields extends object> = Omit<Fields, "_type" | "_rule">;
|
|
289
|
+
type BuiltInResolvedRuleConfig<Name extends CpsatRuleName> = CpsatRuleConfigEntryFor<
|
|
290
|
+
Pick<BuiltInCpsatRuleRegistry, Name>
|
|
291
|
+
>;
|
|
292
|
+
type PeriodLength = { type: "weeks"; value: number } | { type: "months"; value: number };
|
|
293
|
+
type ResolvedEntityScope = {
|
|
294
|
+
memberIds?: [string, ...string[]];
|
|
295
|
+
roleIds?: [string, ...string[]];
|
|
296
|
+
skillIds?: [string, ...string[]];
|
|
297
|
+
};
|
|
298
|
+
type ResolvedTimeOffRuleConfig = {
|
|
299
|
+
name: "time-off";
|
|
300
|
+
priority: Priority;
|
|
301
|
+
dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
302
|
+
dateRange?: { start: string; end: string };
|
|
303
|
+
specificDates?: string[];
|
|
304
|
+
recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
|
|
305
|
+
startTime?: TimeOfDay;
|
|
306
|
+
endTime?: TimeOfDay;
|
|
307
|
+
} & ResolvedEntityScope;
|
|
308
|
+
type ResolvedDaysOfWeekPerPeriodRuleConfig<
|
|
309
|
+
Name extends "max-days-of-week-per-period" | "min-days-of-week-per-period",
|
|
310
|
+
> = {
|
|
311
|
+
name: Name;
|
|
312
|
+
days: number;
|
|
313
|
+
dayOfWeek: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
314
|
+
period: PeriodLength;
|
|
315
|
+
priority: Priority;
|
|
316
|
+
} & ResolvedEntityScope;
|
|
317
|
+
type TimeOffRuleEntry = RuleEntry<"time-off", TimeOffOptions>;
|
|
318
|
+
type AssignTogetherRuleEntry = RuleEntry<
|
|
319
|
+
"assign-together",
|
|
320
|
+
{ members: [string, string, ...string[]] } & AssignTogetherOptions
|
|
321
|
+
>;
|
|
322
|
+
type DaysOfWeekPerPeriodRuleFields = {
|
|
323
|
+
days: number;
|
|
324
|
+
dayOfWeek: readonly [DayOfWeek, ...DayOfWeek[]];
|
|
325
|
+
period: PeriodLength;
|
|
326
|
+
appliesTo?: string | string[];
|
|
327
|
+
priority?: Priority;
|
|
328
|
+
};
|
|
329
|
+
type MaxDaysOfWeekPerPeriodRuleEntry = RuleEntry<
|
|
330
|
+
"max-days-of-week-per-period",
|
|
331
|
+
DaysOfWeekPerPeriodRuleFields
|
|
332
|
+
>;
|
|
333
|
+
type MinDaysOfWeekPerPeriodRuleEntry = RuleEntry<
|
|
334
|
+
"min-days-of-week-per-period",
|
|
335
|
+
DaysOfWeekPerPeriodRuleFields
|
|
336
|
+
>;
|
|
337
|
+
/**
|
|
338
|
+
* Creates a rule entry for use in {@link ScheduleConfig.rules}.
|
|
339
|
+
*
|
|
340
|
+
* Built-in rules use the helpers (`maxHoursPerDay`, `timeOff`, etc.). Custom
|
|
341
|
+
* rules can use `defineRule` to create entries that plug into the same
|
|
342
|
+
* resolution and compilation pipeline.
|
|
343
|
+
*
|
|
344
|
+
* @remarks
|
|
345
|
+
* The returned entry always preserves its rule name and fields. To validate custom
|
|
346
|
+
* rules at the point of creation, use {@link defineRuleFor} to bind `defineRule`
|
|
347
|
+
* to a specific registry.
|
|
348
|
+
*
|
|
349
|
+
* @param name - Rule name. Must match a key in the active rule registry.
|
|
350
|
+
* @param fields - Rule-specific configuration fields.
|
|
351
|
+
* @param resolve - Optional custom resolver. When omitted, the default
|
|
352
|
+
* resolution applies: `appliesTo` is mapped to `roleIds`/`skillIds`/`memberIds`,
|
|
353
|
+
* `dates` is renamed to `specificDates`, and all other fields pass through.
|
|
354
|
+
*/
|
|
355
|
+
export function defineRule<const Name extends string, const Fields extends object>(
|
|
356
|
+
name: Name,
|
|
357
|
+
fields: Fields,
|
|
358
|
+
resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: Name },
|
|
359
|
+
): RuleEntry<Name, SanitizedRuleFields<Fields>>;
|
|
360
|
+
export function defineRule<const Name extends string, const Fields extends object>(
|
|
361
|
+
name: Name,
|
|
362
|
+
fields: Fields,
|
|
363
|
+
resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: Name },
|
|
364
|
+
): RuleEntry<Name, SanitizedRuleFields<Fields>> {
|
|
365
|
+
const safeFields = { ...fields } as SanitizedRuleFields<Fields> & {
|
|
366
|
+
_type?: unknown;
|
|
367
|
+
_rule?: unknown;
|
|
368
|
+
};
|
|
369
|
+
delete safeFields._type;
|
|
370
|
+
delete safeFields._rule;
|
|
371
|
+
|
|
372
|
+
const entry: RuleEntry<Name, SanitizedRuleFields<Fields>> = {
|
|
373
|
+
_type: "rule",
|
|
374
|
+
_rule: name,
|
|
375
|
+
...safeFields,
|
|
376
|
+
};
|
|
377
|
+
if (resolve) {
|
|
378
|
+
Object.defineProperty(entry, "_resolve", { value: resolve, enumerable: false });
|
|
379
|
+
}
|
|
380
|
+
return entry;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Creates a registry-bound `defineRule` helper.
|
|
385
|
+
*
|
|
386
|
+
* @remarks
|
|
387
|
+
* Use this when authoring custom rules directly and you want immediate
|
|
388
|
+
* compile-time validation of the rule name and config fields against a specific
|
|
389
|
+
* rule registry.
|
|
390
|
+
*
|
|
391
|
+
* @category Rules
|
|
392
|
+
*/
|
|
393
|
+
export function defineRuleFor<TRuleRegistry extends CpsatRuleRegistry>(
|
|
394
|
+
ruleRegistry: TRuleRegistry,
|
|
395
|
+
): <const Name extends keyof TRuleRegistry & string>(
|
|
396
|
+
name: Name,
|
|
397
|
+
fields: Omit<CpsatRuleConfigEntryFor<Pick<TRuleRegistry, Name>>, "name">,
|
|
398
|
+
resolve?: (ctx: RuleResolveContext) => CpsatRuleConfigEntryFor<Pick<TRuleRegistry, Name>>,
|
|
399
|
+
) => RuleEntry<
|
|
400
|
+
Name,
|
|
401
|
+
SanitizedRuleFields<Omit<CpsatRuleConfigEntryFor<Pick<TRuleRegistry, Name>>, "name">>
|
|
402
|
+
> {
|
|
403
|
+
void ruleRegistry;
|
|
404
|
+
|
|
405
|
+
return (name, fields, resolve) => defineRule(name, fields, resolve);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function makeRule<const Name extends CpsatRuleName, const Fields extends object>(
|
|
409
|
+
rule: Name,
|
|
410
|
+
fields: Fields,
|
|
411
|
+
): RuleEntry<Name, SanitizedRuleFields<Fields>> {
|
|
412
|
+
return defineRule(rule, fields);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ============================================================================
|
|
416
|
+
// Constraint Rules
|
|
417
|
+
// ============================================================================
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Limits hours per day.
|
|
421
|
+
*
|
|
422
|
+
* @remarks
|
|
423
|
+
* Use this when the requirements or operating constraints explicitly impose a
|
|
424
|
+
* daily hours cap. Do not infer it just from the chosen shift pattern lengths.
|
|
425
|
+
*
|
|
426
|
+
* @category Rules
|
|
427
|
+
*/
|
|
428
|
+
export function maxHoursPerDay(
|
|
429
|
+
hours: number,
|
|
430
|
+
opts?: RuleOptions,
|
|
431
|
+
): RuleEntry<"max-hours-day", { hours: number } & RuleOptions> {
|
|
432
|
+
return makeRule("max-hours-day", { hours, ...opts });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Limits hours per scheduling week.
|
|
437
|
+
*
|
|
438
|
+
* @remarks
|
|
439
|
+
* Use this when the requirements or operating constraints explicitly impose a
|
|
440
|
+
* weekly hours cap. Do not infer it just from a stated working pattern such
|
|
441
|
+
* as "4-day week" unless the requirements also make the hour limit explicit.
|
|
442
|
+
*
|
|
443
|
+
* @category Rules
|
|
444
|
+
*/
|
|
445
|
+
export function maxHoursPerWeek(
|
|
446
|
+
hours: number,
|
|
447
|
+
opts?: RuleOptions,
|
|
448
|
+
): RuleEntry<"max-hours-week", { hours: number } & RuleOptions> {
|
|
449
|
+
return makeRule("max-hours-week", { hours, ...opts });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Minimum hours when assigned on a day.
|
|
454
|
+
*
|
|
455
|
+
* @category Rules
|
|
456
|
+
*/
|
|
457
|
+
export function minHoursPerDay(
|
|
458
|
+
hours: number,
|
|
459
|
+
opts?: EntityOnlyRuleOptions,
|
|
460
|
+
): RuleEntry<"min-hours-day", { hours: number } & EntityOnlyRuleOptions> {
|
|
461
|
+
return makeRule("min-hours-day", { hours, ...opts });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Minimum hours per scheduling week.
|
|
466
|
+
*
|
|
467
|
+
* @category Rules
|
|
468
|
+
*/
|
|
469
|
+
export function minHoursPerWeek(
|
|
470
|
+
hours: number,
|
|
471
|
+
opts?: EntityOnlyRuleOptions,
|
|
472
|
+
): RuleEntry<"min-hours-week", { hours: number } & EntityOnlyRuleOptions> {
|
|
473
|
+
return makeRule("min-hours-week", { hours, ...opts });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Caps assigned days per scheduling week.
|
|
478
|
+
*
|
|
479
|
+
* @remarks
|
|
480
|
+
* Use this when the requirements explicitly impose a weekly day cap. If the same
|
|
481
|
+
* number is the normal weekly pattern rather than just a hard limit, pair it
|
|
482
|
+
* with {@link targetDaysPerWeek}.
|
|
483
|
+
*
|
|
484
|
+
* @category Rules
|
|
485
|
+
*/
|
|
486
|
+
export function maxDaysPerWeek(
|
|
487
|
+
days: number,
|
|
488
|
+
opts?: RuleOptions,
|
|
489
|
+
): RuleEntry<"max-days-week", { days: number } & RuleOptions> {
|
|
490
|
+
return makeRule("max-days-week", { days, ...opts });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Enforces a minimum number of assigned days per scheduling week.
|
|
495
|
+
*
|
|
496
|
+
* @remarks
|
|
497
|
+
* Use this when the requirements explicitly require a weekly minimum. Language like
|
|
498
|
+
* "works a 4-day week" often implies a hard cap via {@link maxDaysPerWeek}
|
|
499
|
+
* and possibly a softer target via {@link targetDaysPerWeek}, not a mandatory
|
|
500
|
+
* weekly minimum for every member.
|
|
501
|
+
*
|
|
502
|
+
* @category Rules
|
|
503
|
+
*/
|
|
504
|
+
export function minDaysPerWeek(
|
|
505
|
+
days: number,
|
|
506
|
+
opts?: EntityOnlyRuleOptions,
|
|
507
|
+
): RuleEntry<"min-days-week", { days: number } & EntityOnlyRuleOptions> {
|
|
508
|
+
return makeRule("min-days-week", { days, ...opts });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Softly targets assigned days per scheduling week.
|
|
513
|
+
*
|
|
514
|
+
* @remarks
|
|
515
|
+
* Use this for stated working patterns like "works a 4-day week" when the
|
|
516
|
+
* pattern should remain the norm but can flex around holidays, time off, or
|
|
517
|
+
* other tradeoffs. Deviations in either direction are penalized, so working
|
|
518
|
+
* fewer days or more days than the target both count as misses. Pair it with
|
|
519
|
+
* {@link maxDaysPerWeek} when the same number is also a hard cap, and use
|
|
520
|
+
* {@link minDaysPerWeek} only when the requirements explicitly require a minimum.
|
|
521
|
+
*
|
|
522
|
+
* @param days - Target number of assigned days per scheduling week.
|
|
523
|
+
* @param opts - Optional entity scope and soft priority.
|
|
524
|
+
*
|
|
525
|
+
* @category Rules
|
|
526
|
+
*/
|
|
527
|
+
export function targetDaysPerWeek(
|
|
528
|
+
days: number,
|
|
529
|
+
opts?: TargetDaysPerWeekOptions,
|
|
530
|
+
): RuleEntry<"target-days-week", { days: number } & TargetDaysPerWeekOptions> {
|
|
531
|
+
return makeRule("target-days-week", { days, ...opts });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Maximum distinct shifts per day.
|
|
536
|
+
*
|
|
537
|
+
* @category Rules
|
|
538
|
+
*/
|
|
539
|
+
export function maxShiftsPerDay(
|
|
540
|
+
shifts: number,
|
|
541
|
+
opts?: RuleOptions,
|
|
542
|
+
): RuleEntry<"max-shifts-day", { shifts: number } & RuleOptions> {
|
|
543
|
+
return makeRule("max-shifts-day", { shifts, ...opts });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Caps how many targeted assignments may overlap at the same time.
|
|
548
|
+
*
|
|
549
|
+
* @remarks
|
|
550
|
+
* Use this for hard productive capacity limits that should stay separate from
|
|
551
|
+
* the minimum staffing requirement expressed by {@link cover}. Pair a
|
|
552
|
+
* lower-bound `cover(...)` with `maxConcurrentAssignments(...)` when the
|
|
553
|
+
* business needs both demand and capacity modeled explicitly.
|
|
554
|
+
*
|
|
555
|
+
* On days with staggered opening and closing shifts, keep lower-bound coverage
|
|
556
|
+
* scoped to the windows that truly need minimum staffing. Do not turn a total
|
|
557
|
+
* daily headcount target into a whole-day lower-bound `cover(...)` unless the
|
|
558
|
+
* business explicitly requires that many people in every overlapping bucket.
|
|
559
|
+
*
|
|
560
|
+
* Unlike {@link maxShiftsPerDay}, this limits simultaneous overlap, not total
|
|
561
|
+
* assignments on a day.
|
|
562
|
+
*
|
|
563
|
+
* @param assignments - Maximum number of concurrent overlapping assignments.
|
|
564
|
+
* @param opts - Optional scope and time-window settings.
|
|
565
|
+
*
|
|
566
|
+
* @category Rules
|
|
567
|
+
*/
|
|
568
|
+
export function maxConcurrentAssignments(
|
|
569
|
+
assignments: number,
|
|
570
|
+
opts?: RuleOptions & {
|
|
571
|
+
startTime?: TimeOfDay;
|
|
572
|
+
endTime?: TimeOfDay;
|
|
573
|
+
},
|
|
574
|
+
): RuleEntry<
|
|
575
|
+
"max-concurrent-assignments",
|
|
576
|
+
{ assignments: number } & MaxConcurrentAssignmentsOptions
|
|
577
|
+
> {
|
|
578
|
+
return makeRule("max-concurrent-assignments", { assignments, ...opts });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Softly targets the daily peak number of concurrent overlapping assignments.
|
|
583
|
+
*
|
|
584
|
+
* @remarks
|
|
585
|
+
* Use this when the business wants to hit full productive occupancy at the
|
|
586
|
+
* busy point of a day, but does not mean that same number must be present
|
|
587
|
+
* throughout the full span. This is not a whole-day lower bound. Pair it with
|
|
588
|
+
* {@link cover} for edge minima and with
|
|
589
|
+
* {@link maxConcurrentAssignments} when the same value is also a hard cap.
|
|
590
|
+
* Prefer this over a whole-day `cover(...)` when the brief means "reach the
|
|
591
|
+
* peak" rather than "maintain this minimum throughout the day".
|
|
592
|
+
*
|
|
593
|
+
* This is a rule-level peak target, not a variant of {@link cover}. Do not
|
|
594
|
+
* introduce a synthetic semantic time just to attach a whole-window
|
|
595
|
+
* `cover(...)` when the requirement is really about the day's peak.
|
|
596
|
+
*
|
|
597
|
+
* @param assignments - Target peak number of concurrent overlapping assignments.
|
|
598
|
+
* @param opts - Optional scope and soft priority.
|
|
599
|
+
*
|
|
600
|
+
* @category Rules
|
|
601
|
+
*/
|
|
602
|
+
export function targetPeakConcurrentAssignments(
|
|
603
|
+
assignments: number,
|
|
604
|
+
opts?: TargetPeakConcurrentAssignmentsOptions,
|
|
605
|
+
): RuleEntry<
|
|
606
|
+
"target-peak-concurrent-assignments",
|
|
607
|
+
{ assignments: number } & TargetPeakConcurrentAssignmentsOptions
|
|
608
|
+
> {
|
|
609
|
+
return makeRule("target-peak-concurrent-assignments", { assignments, ...opts });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Maximum consecutive assigned days.
|
|
614
|
+
*
|
|
615
|
+
* @category Rules
|
|
616
|
+
*/
|
|
617
|
+
export function maxConsecutiveDays(
|
|
618
|
+
days: number,
|
|
619
|
+
opts?: EntityOnlyRuleOptions,
|
|
620
|
+
): RuleEntry<"max-consecutive-days", { days: number } & EntityOnlyRuleOptions> {
|
|
621
|
+
return makeRule("max-consecutive-days", { days, ...opts });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Once working, continue for at least this many consecutive days.
|
|
626
|
+
*
|
|
627
|
+
* @category Rules
|
|
628
|
+
*/
|
|
629
|
+
export function minConsecutiveDays(
|
|
630
|
+
days: number,
|
|
631
|
+
opts?: EntityOnlyRuleOptions,
|
|
632
|
+
): RuleEntry<"min-consecutive-days", { days: number } & EntityOnlyRuleOptions> {
|
|
633
|
+
return makeRule("min-consecutive-days", { days, ...opts });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Minimum rest hours between shifts.
|
|
638
|
+
*
|
|
639
|
+
* @category Rules
|
|
640
|
+
*/
|
|
641
|
+
export function minRestBetweenShifts(
|
|
642
|
+
hours: number,
|
|
643
|
+
opts?: EntityOnlyRuleOptions,
|
|
644
|
+
): RuleEntry<"min-rest-between-shifts", { hours: number } & EntityOnlyRuleOptions> {
|
|
645
|
+
return makeRule("min-rest-between-shifts", { hours, ...opts });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Nudge the solver toward scheduling targeted members.
|
|
650
|
+
*
|
|
651
|
+
* @remarks
|
|
652
|
+
* Soft constraint. The solver adds a bonus for each assignment of the
|
|
653
|
+
* targeted members, making them more likely to appear on the schedule.
|
|
654
|
+
* Use `priority` to control how strong the nudge is; higher priority
|
|
655
|
+
* means a larger bonus. Even at `"MANDATORY"` strength the solver can
|
|
656
|
+
* still leave a member unassigned if hard constraints prevent it.
|
|
657
|
+
*
|
|
658
|
+
* Opposite of {@link avoidAssignment}. Both map to the same underlying
|
|
659
|
+
* `assignment-priority` rule with different directions.
|
|
660
|
+
*
|
|
661
|
+
* @param opts - See {@link RuleOptions}
|
|
662
|
+
*
|
|
663
|
+
* @category Rules
|
|
664
|
+
*/
|
|
665
|
+
export function preferAssignment(
|
|
666
|
+
opts?: RuleOptions,
|
|
667
|
+
): RuleEntry<"assignment-priority", { preference: "prefer" } & RuleOptions> {
|
|
668
|
+
return makeRule("assignment-priority", { preference: "prefer", ...opts });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Nudge the solver away from scheduling targeted members.
|
|
673
|
+
*
|
|
674
|
+
* @remarks
|
|
675
|
+
* Soft constraint. The solver adds a penalty for each assignment of the
|
|
676
|
+
* targeted members, making them less likely to appear on the schedule.
|
|
677
|
+
* The member is still assigned when coverage requires it; the penalty
|
|
678
|
+
* only matters when the solver has a choice.
|
|
679
|
+
*
|
|
680
|
+
* Opposite of {@link preferAssignment}. Both map to the same underlying
|
|
681
|
+
* `assignment-priority` rule with different directions.
|
|
682
|
+
*
|
|
683
|
+
* @param opts - See {@link RuleOptions}
|
|
684
|
+
*
|
|
685
|
+
* @category Rules
|
|
686
|
+
*/
|
|
687
|
+
export function avoidAssignment(
|
|
688
|
+
opts?: RuleOptions,
|
|
689
|
+
): RuleEntry<"assignment-priority", { preference: "avoid" } & RuleOptions> {
|
|
690
|
+
return makeRule("assignment-priority", { preference: "avoid", ...opts });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Steer a multi-role member toward shifts tagged with a specific role.
|
|
695
|
+
*
|
|
696
|
+
* @remarks
|
|
697
|
+
* Penalizes assignment to shift patterns whose `roleIds` do not include
|
|
698
|
+
* the preferred role. The member is still assigned to other roles when
|
|
699
|
+
* coverage requires it; the penalty only matters when the solver has a
|
|
700
|
+
* choice. Shift patterns without any `roleIds` (open to all) are also
|
|
701
|
+
* penalized since they are not explicitly tagged with the preferred role.
|
|
702
|
+
*
|
|
703
|
+
* The targeted members must actually hold the preferred role in their
|
|
704
|
+
* `roleIds`. If none of them do, compilation reports an error because
|
|
705
|
+
* the rule would silently penalize all their assignments (the opposite
|
|
706
|
+
* of the intent).
|
|
707
|
+
*
|
|
708
|
+
* Typical use case: a waiter who can cover kitchen pass shifts in a
|
|
709
|
+
* pinch. Give the member both roles (`roleIds: ["waiter", "pass"]`)
|
|
710
|
+
* and use `preferRole("waiter", ...)` so the solver keeps them on the
|
|
711
|
+
* floor unless the pass team is short-staffed.
|
|
712
|
+
*
|
|
713
|
+
* @param roleId - The role to prefer
|
|
714
|
+
* @param opts - See {@link EntityOnlyRuleOptions}
|
|
715
|
+
*
|
|
716
|
+
* @category Rules
|
|
717
|
+
*/
|
|
718
|
+
export function preferRole(
|
|
719
|
+
roleId: string,
|
|
720
|
+
opts?: EntityOnlyRuleOptions,
|
|
721
|
+
): RuleEntry<"role-preference", { roleId: string } & EntityOnlyRuleOptions> {
|
|
722
|
+
return makeRule("role-preference", { roleId, ...opts });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Steer a member toward shifts at a specific location.
|
|
727
|
+
*
|
|
728
|
+
* @remarks
|
|
729
|
+
* Penalizes assignment to shift patterns whose `locationId` does not
|
|
730
|
+
* match the preferred location. The member is still assigned elsewhere
|
|
731
|
+
* when coverage requires it.
|
|
732
|
+
*
|
|
733
|
+
* @param locationId - The location to prefer
|
|
734
|
+
* @param opts - See {@link EntityOnlyRuleOptions}
|
|
735
|
+
*
|
|
736
|
+
* @category Rules
|
|
737
|
+
*/
|
|
738
|
+
export function preferLocation(
|
|
739
|
+
locationId: string,
|
|
740
|
+
opts?: EntityOnlyRuleOptions,
|
|
741
|
+
): RuleEntry<"location-preference", { locationId: string } & EntityOnlyRuleOptions> {
|
|
742
|
+
return makeRule("location-preference", { locationId, ...opts });
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Guarantees that targeted members appear on the schedule each week.
|
|
747
|
+
*
|
|
748
|
+
* @remarks
|
|
749
|
+
* Use for staffing obligations: salaried employees who are paid regardless
|
|
750
|
+
* of whether they work, or contracted staff who must be rostered. The solver
|
|
751
|
+
* ensures each targeted member has at least one assignment per scheduling week.
|
|
752
|
+
*
|
|
753
|
+
* Always a soft constraint (HIGH priority internally). The schedule still
|
|
754
|
+
* generates when a member genuinely cannot be placed (e.g., full week of
|
|
755
|
+
* absences). Violations surface as validation warnings with distinct
|
|
756
|
+
* messaging from {@link minDaysPerWeek}. Priority is not configurable;
|
|
757
|
+
* the rule name communicates the intent.
|
|
758
|
+
*
|
|
759
|
+
* @category Rules
|
|
760
|
+
*/
|
|
761
|
+
export function mustAssign(opts?: {
|
|
762
|
+
appliesTo?: string | string[];
|
|
763
|
+
}): RuleEntry<"must-assign", { appliesTo?: string | string[] }> {
|
|
764
|
+
return makeRule("must-assign", { ...opts });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ============================================================================
|
|
768
|
+
// Special Rules (custom resolvers)
|
|
769
|
+
// ============================================================================
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Block assignments during specified periods.
|
|
773
|
+
* Requires at least one time scope (`dayOfWeek`, `dateRange`, `dates`, or `from`/`until`).
|
|
774
|
+
*
|
|
775
|
+
* @category Rules
|
|
776
|
+
*/
|
|
777
|
+
export function timeOff(opts: TimeOffOptions): TimeOffRuleEntry {
|
|
778
|
+
const { from, until, ...rest } = opts;
|
|
779
|
+
return defineRule("time-off", { from, until, ...rest }, (ctx) => {
|
|
780
|
+
if (!rest.dayOfWeek && !rest.dateRange && !rest.dates && !rest.recurringPeriods) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
"timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const { appliesTo, dates, priority, ...passthrough } = rest;
|
|
787
|
+
const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
|
|
788
|
+
const resolvedDates = dates ? { specificDates: dates } : {};
|
|
789
|
+
|
|
790
|
+
const partialDay: Record<string, unknown> = {};
|
|
791
|
+
if (from && until) {
|
|
792
|
+
partialDay.startTime = from;
|
|
793
|
+
partialDay.endTime = until;
|
|
794
|
+
} else if (from) {
|
|
795
|
+
partialDay.startTime = from;
|
|
796
|
+
partialDay.endTime = { hours: 23, minutes: 59 };
|
|
797
|
+
} else if (until) {
|
|
798
|
+
partialDay.startTime = { hours: 0, minutes: 0 };
|
|
799
|
+
partialDay.endTime = until;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const resolved = {
|
|
803
|
+
name: "time-off",
|
|
804
|
+
priority: priority ?? "MANDATORY",
|
|
805
|
+
...passthrough,
|
|
806
|
+
...entityScope,
|
|
807
|
+
...resolvedDates,
|
|
808
|
+
...partialDay,
|
|
809
|
+
} satisfies ResolvedTimeOffRuleConfig;
|
|
810
|
+
|
|
811
|
+
return resolved;
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Members work the same shifts on days they are both assigned.
|
|
817
|
+
*
|
|
818
|
+
* @category Rules
|
|
819
|
+
*/
|
|
820
|
+
export function assignTogether(
|
|
821
|
+
memberIds: [string, string, ...string[]],
|
|
822
|
+
opts?: AssignTogetherOptions,
|
|
823
|
+
): AssignTogetherRuleEntry {
|
|
824
|
+
return defineRule("assign-together", { members: memberIds, ...opts }, (ctx) => {
|
|
825
|
+
for (const member of memberIds) {
|
|
826
|
+
if (!ctx.memberIds.has(member)) {
|
|
827
|
+
throw new Error(
|
|
828
|
+
`assignTogether references unknown member "${member}". ` +
|
|
829
|
+
`Known member IDs: ${[...ctx.memberIds].join(", ")}`,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const resolved = {
|
|
834
|
+
name: "assign-together",
|
|
835
|
+
groupMemberIds: memberIds,
|
|
836
|
+
priority: opts?.priority ?? "MANDATORY",
|
|
837
|
+
} satisfies BuiltInResolvedRuleConfig<"assign-together">;
|
|
838
|
+
|
|
839
|
+
return resolved;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ============================================================================
|
|
844
|
+
// Period-Based Day-of-Week Rules
|
|
845
|
+
// ============================================================================
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Options for {@link maxDaysOfWeekPerPeriod} and {@link minDaysOfWeekPerPeriod}.
|
|
849
|
+
*
|
|
850
|
+
* Exactly one of `weeks` or `months` must be provided to define the
|
|
851
|
+
* repeating period. Week periods align to `weekStartsOn`. Month periods
|
|
852
|
+
* align to calendar month boundaries.
|
|
853
|
+
*
|
|
854
|
+
* @category Rules
|
|
855
|
+
*/
|
|
856
|
+
export type DaysOfWeekPerPeriodOptions = {
|
|
857
|
+
/** Scope to role, skill, or member ID. */
|
|
858
|
+
appliesTo?: string | string[];
|
|
859
|
+
/** Default `MANDATORY`. */
|
|
860
|
+
priority?: Priority;
|
|
861
|
+
} & ({ weeks: number; months?: never } | { months: number; weeks?: never });
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Caps how many times a person works on specific days of the week within
|
|
865
|
+
* each period.
|
|
866
|
+
*
|
|
867
|
+
* @remarks
|
|
868
|
+
* The scheduling window is divided into non-overlapping chunks of the
|
|
869
|
+
* configured period length. Within each chunk, only days matching the
|
|
870
|
+
* `dayOfWeek` filter are counted. The constraint bounds that count.
|
|
871
|
+
*
|
|
872
|
+
* @param days - Maximum number of matching days allowed per period.
|
|
873
|
+
* @param dayOfWeek - Which days of the week count toward the limit.
|
|
874
|
+
* @param opts - Period length and optional scoping.
|
|
875
|
+
*
|
|
876
|
+
* @category Rules
|
|
877
|
+
*/
|
|
878
|
+
export function maxDaysOfWeekPerPeriod(
|
|
879
|
+
days: number,
|
|
880
|
+
dayOfWeek: readonly [DayOfWeek, ...DayOfWeek[]],
|
|
881
|
+
opts: DaysOfWeekPerPeriodOptions,
|
|
882
|
+
): MaxDaysOfWeekPerPeriodRuleEntry {
|
|
883
|
+
const { appliesTo, priority, ...periodOpts } = opts;
|
|
884
|
+
const period =
|
|
885
|
+
"weeks" in periodOpts && periodOpts.weeks != null
|
|
886
|
+
? { type: "weeks" as const, value: periodOpts.weeks }
|
|
887
|
+
: { type: "months" as const, value: (periodOpts as { months: number }).months };
|
|
888
|
+
|
|
889
|
+
return defineRule(
|
|
890
|
+
"max-days-of-week-per-period",
|
|
891
|
+
{ days, dayOfWeek: [...dayOfWeek], period, appliesTo, priority },
|
|
892
|
+
(ctx) => {
|
|
893
|
+
const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
|
|
894
|
+
const resolved = {
|
|
895
|
+
name: "max-days-of-week-per-period",
|
|
896
|
+
days,
|
|
897
|
+
dayOfWeek: [...dayOfWeek],
|
|
898
|
+
period,
|
|
899
|
+
priority: priority ?? "MANDATORY",
|
|
900
|
+
...entityScope,
|
|
901
|
+
} satisfies ResolvedDaysOfWeekPerPeriodRuleConfig<"max-days-of-week-per-period">;
|
|
902
|
+
|
|
903
|
+
return resolved;
|
|
904
|
+
},
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Enforces a minimum number of times a person works on specific days of the
|
|
910
|
+
* week within each period.
|
|
911
|
+
*
|
|
912
|
+
* @remarks
|
|
913
|
+
* The scheduling window is divided into non-overlapping chunks of the
|
|
914
|
+
* configured period length. Within each chunk, only days matching the
|
|
915
|
+
* `dayOfWeek` filter are counted. The constraint ensures the count meets
|
|
916
|
+
* the minimum.
|
|
917
|
+
*
|
|
918
|
+
* When the scheduling window does not align evenly with the period
|
|
919
|
+
* boundaries, the last chunk may contain fewer matching days than the
|
|
920
|
+
* minimum. A MANDATORY priority will make the model infeasible in that
|
|
921
|
+
* case. Use a soft priority (HIGH, MEDIUM, LOW) if partial periods at
|
|
922
|
+
* the edges of the window are expected.
|
|
923
|
+
*
|
|
924
|
+
* @param days - Minimum number of matching days required per period.
|
|
925
|
+
* @param dayOfWeek - Which days of the week count toward the limit.
|
|
926
|
+
* @param opts - Period length and optional scoping.
|
|
927
|
+
*
|
|
928
|
+
* @category Rules
|
|
929
|
+
*/
|
|
930
|
+
export function minDaysOfWeekPerPeriod(
|
|
931
|
+
days: number,
|
|
932
|
+
dayOfWeek: readonly [DayOfWeek, ...DayOfWeek[]],
|
|
933
|
+
opts: DaysOfWeekPerPeriodOptions,
|
|
934
|
+
): MinDaysOfWeekPerPeriodRuleEntry {
|
|
935
|
+
const { appliesTo, priority, ...periodOpts } = opts;
|
|
936
|
+
const period =
|
|
937
|
+
"weeks" in periodOpts && periodOpts.weeks != null
|
|
938
|
+
? { type: "weeks" as const, value: periodOpts.weeks }
|
|
939
|
+
: { type: "months" as const, value: (periodOpts as { months: number }).months };
|
|
940
|
+
|
|
941
|
+
return defineRule(
|
|
942
|
+
"min-days-of-week-per-period",
|
|
943
|
+
{ days, dayOfWeek: [...dayOfWeek], period, appliesTo, priority },
|
|
944
|
+
(ctx) => {
|
|
945
|
+
const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
|
|
946
|
+
const resolved = {
|
|
947
|
+
name: "min-days-of-week-per-period",
|
|
948
|
+
days,
|
|
949
|
+
dayOfWeek: [...dayOfWeek],
|
|
950
|
+
period,
|
|
951
|
+
priority: priority ?? "MANDATORY",
|
|
952
|
+
...entityScope,
|
|
953
|
+
} satisfies ResolvedDaysOfWeekPerPeriodRuleConfig<"min-days-of-week-per-period">;
|
|
954
|
+
|
|
955
|
+
return resolved;
|
|
956
|
+
},
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ============================================================================
|
|
961
|
+
// Internal: appliesTo resolution (shared with definition.ts)
|
|
962
|
+
// ============================================================================
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Resolves an `appliesTo` value into entity scope fields.
|
|
966
|
+
*
|
|
967
|
+
* Each target string is checked against roles, skills, then member IDs.
|
|
968
|
+
* If all targets resolve to the same namespace, they are combined into one
|
|
969
|
+
* scope field. If they span namespaces, an error is thrown; the caller
|
|
970
|
+
* should use separate rule entries instead.
|
|
971
|
+
*
|
|
972
|
+
* @internal
|
|
973
|
+
*/
|
|
974
|
+
export function resolveAppliesTo(
|
|
975
|
+
appliesTo: string | string[] | undefined,
|
|
976
|
+
roles: ReadonlySet<string>,
|
|
977
|
+
skills: ReadonlySet<string>,
|
|
978
|
+
memberIds: ReadonlySet<string>,
|
|
979
|
+
): {
|
|
980
|
+
memberIds?: [string, ...string[]];
|
|
981
|
+
roleIds?: [string, ...string[]];
|
|
982
|
+
skillIds?: [string, ...string[]];
|
|
983
|
+
} {
|
|
984
|
+
if (!appliesTo) return {};
|
|
985
|
+
|
|
986
|
+
const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
|
|
987
|
+
if (targets.length === 0) return {};
|
|
988
|
+
|
|
989
|
+
const resolvedRoles: string[] = [];
|
|
990
|
+
const resolvedSkills: string[] = [];
|
|
991
|
+
const resolvedMembers: string[] = [];
|
|
992
|
+
|
|
993
|
+
for (const target of targets) {
|
|
994
|
+
if (roles.has(target)) {
|
|
995
|
+
resolvedRoles.push(target);
|
|
996
|
+
} else if (skills.has(target)) {
|
|
997
|
+
resolvedSkills.push(target);
|
|
998
|
+
} else if (memberIds.has(target)) {
|
|
999
|
+
resolvedMembers.push(target);
|
|
1000
|
+
} else {
|
|
1001
|
+
throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Count how many namespaces were used
|
|
1006
|
+
const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
|
|
1007
|
+
(arr) => arr.length > 0,
|
|
1008
|
+
).length;
|
|
1009
|
+
|
|
1010
|
+
if (namespacesUsed > 1) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
`appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
|
|
1013
|
+
`skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
|
|
1014
|
+
`Use separate rule entries for each namespace.`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (resolvedRoles.length > 0) {
|
|
1019
|
+
return { roleIds: resolvedRoles as [string, ...string[]] };
|
|
1020
|
+
}
|
|
1021
|
+
if (resolvedSkills.length > 0) {
|
|
1022
|
+
return { skillIds: resolvedSkills as [string, ...string[]] };
|
|
1023
|
+
}
|
|
1024
|
+
if (resolvedMembers.length > 0) {
|
|
1025
|
+
return { memberIds: resolvedMembers as [string, ...string[]] };
|
|
1026
|
+
}
|
|
1027
|
+
return {};
|
|
1028
|
+
}
|