dabke 0.78.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 +120 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +42 -0
- package/dist/client.js.map +1 -0
- package/dist/client.schemas.d.ts +250 -0
- package/dist/client.schemas.d.ts.map +1 -0
- package/dist/client.schemas.js +137 -0
- package/dist/client.schemas.js.map +1 -0
- package/dist/client.types.d.ts +34 -0
- package/dist/client.types.d.ts.map +1 -0
- package/dist/client.types.js +18 -0
- package/dist/client.types.js.map +1 -0
- package/dist/cpsat/model-builder.d.ts +128 -0
- package/dist/cpsat/model-builder.d.ts.map +1 -0
- package/dist/cpsat/model-builder.js +640 -0
- package/dist/cpsat/model-builder.js.map +1 -0
- package/dist/cpsat/response.d.ts +74 -0
- package/dist/cpsat/response.d.ts.map +1 -0
- package/dist/cpsat/response.js +92 -0
- package/dist/cpsat/response.js.map +1 -0
- package/dist/cpsat/rules/assign-together.d.ts +23 -0
- package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
- package/dist/cpsat/rules/assign-together.js +78 -0
- package/dist/cpsat/rules/assign-together.js.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
- package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
- package/dist/cpsat/rules/index.d.ts +13 -0
- package/dist/cpsat/rules/index.d.ts.map +1 -0
- package/dist/cpsat/rules/index.js +13 -0
- package/dist/cpsat/rules/index.js.map +1 -0
- package/dist/cpsat/rules/location-preference.d.ts +29 -0
- package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
- package/dist/cpsat/rules/location-preference.js +59 -0
- package/dist/cpsat/rules/location-preference.js.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.js +70 -0
- package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
- package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-day.js +159 -0
- package/dist/cpsat/rules/max-hours-day.js.map +1 -0
- package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
- package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-week.js +169 -0
- package/dist/cpsat/rules/max-hours-week.js.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.js +170 -0
- package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.js +104 -0
- package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
- package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-day.js +61 -0
- package/dist/cpsat/rules/min-hours-day.js.map +1 -0
- package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
- package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-week.js +68 -0
- package/dist/cpsat/rules/min-hours-week.js.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +7 -0
- package/dist/cpsat/rules/registry.d.ts.map +1 -0
- package/dist/cpsat/rules/registry.js +28 -0
- package/dist/cpsat/rules/registry.js.map +1 -0
- package/dist/cpsat/rules/resolver.d.ts +31 -0
- package/dist/cpsat/rules/resolver.d.ts.map +1 -0
- package/dist/cpsat/rules/resolver.js +124 -0
- package/dist/cpsat/rules/resolver.js.map +1 -0
- package/dist/cpsat/rules/rules.types.d.ts +32 -0
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
- package/dist/cpsat/rules/rules.types.js +2 -0
- package/dist/cpsat/rules/rules.types.js.map +1 -0
- package/dist/cpsat/rules/scoping.d.ts +129 -0
- package/dist/cpsat/rules/scoping.d.ts.map +1 -0
- package/dist/cpsat/rules/scoping.js +190 -0
- package/dist/cpsat/rules/scoping.js.map +1 -0
- package/dist/cpsat/rules/time-off.d.ts +78 -0
- package/dist/cpsat/rules/time-off.d.ts.map +1 -0
- package/dist/cpsat/rules/time-off.js +261 -0
- package/dist/cpsat/rules/time-off.js.map +1 -0
- package/dist/cpsat/rules.d.ts +5 -0
- package/dist/cpsat/rules.d.ts.map +1 -0
- package/dist/cpsat/rules.js +4 -0
- package/dist/cpsat/rules.js.map +1 -0
- package/dist/cpsat/semantic-time.d.ts +198 -0
- package/dist/cpsat/semantic-time.d.ts.map +1 -0
- package/dist/cpsat/semantic-time.js +222 -0
- package/dist/cpsat/semantic-time.js.map +1 -0
- package/dist/cpsat/types.d.ts +180 -0
- package/dist/cpsat/types.d.ts.map +1 -0
- package/dist/cpsat/types.js +2 -0
- package/dist/cpsat/types.js.map +1 -0
- package/dist/cpsat/utils.d.ts +47 -0
- package/dist/cpsat/utils.d.ts.map +1 -0
- package/dist/cpsat/utils.js +92 -0
- package/dist/cpsat/utils.js.map +1 -0
- package/dist/cpsat/validation-reporter.d.ts +54 -0
- package/dist/cpsat/validation-reporter.d.ts.map +1 -0
- package/dist/cpsat/validation-reporter.js +261 -0
- package/dist/cpsat/validation-reporter.js.map +1 -0
- package/dist/cpsat/validation.types.d.ts +141 -0
- package/dist/cpsat/validation.types.d.ts.map +1 -0
- package/dist/cpsat/validation.types.js +14 -0
- package/dist/cpsat/validation.types.js.map +1 -0
- package/dist/datetime.utils.d.ts +245 -0
- package/dist/datetime.utils.d.ts.map +1 -0
- package/dist/datetime.utils.js +372 -0
- package/dist/datetime.utils.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/llms.d.ts +5 -0
- package/dist/llms.d.ts.map +1 -0
- package/dist/llms.js +8 -0
- package/dist/llms.js.map +1 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/solver-container.d.ts +49 -0
- package/dist/testing/solver-container.d.ts.map +1 -0
- package/dist/testing/solver-container.js +127 -0
- package/dist/testing/solver-container.js.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +105 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +130 -0
- package/dist/validation.js.map +1 -0
- package/llms.txt +2188 -0
- package/package.json +76 -0
- package/solver/Dockerfile +31 -0
- package/solver/README.md +23 -0
- package/solver/pyproject.toml +28 -0
- package/solver/src/solver/__init__.py +1 -0
- package/solver/src/solver/app.py +24 -0
- package/solver/src/solver/models.py +120 -0
- package/solver/src/solver/solver.py +359 -0
- package/solver/tests/test_solver.py +156 -0
- package/solver/uv.lock +661 -0
- package/src/client.schemas.ts +163 -0
- package/src/client.ts +67 -0
- package/src/client.types.ts +66 -0
- package/src/cpsat/model-builder.ts +858 -0
- package/src/cpsat/response.ts +130 -0
- package/src/cpsat/rules/assign-together.ts +96 -0
- package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
- package/src/cpsat/rules/index.ts +12 -0
- package/src/cpsat/rules/location-preference.ts +68 -0
- package/src/cpsat/rules/max-consecutive-days.ts +98 -0
- package/src/cpsat/rules/max-hours-day.ts +187 -0
- package/src/cpsat/rules/max-hours-week.ts +197 -0
- package/src/cpsat/rules/max-shifts-day.ts +198 -0
- package/src/cpsat/rules/min-consecutive-days.ts +140 -0
- package/src/cpsat/rules/min-hours-day.ts +69 -0
- package/src/cpsat/rules/min-hours-week.ts +77 -0
- package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
- package/src/cpsat/rules/registry.ts +49 -0
- package/src/cpsat/rules/resolver.ts +181 -0
- package/src/cpsat/rules/rules.types.ts +41 -0
- package/src/cpsat/rules/scoping.ts +340 -0
- package/src/cpsat/rules/time-off.ts +336 -0
- package/src/cpsat/rules.ts +27 -0
- package/src/cpsat/semantic-time.ts +463 -0
- package/src/cpsat/types.ts +194 -0
- package/src/cpsat/utils.ts +105 -0
- package/src/cpsat/validation-reporter.ts +366 -0
- package/src/cpsat/validation.types.ts +185 -0
- package/src/datetime.utils.ts +426 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +289 -0
- package/src/llms.ts +9 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/solver-container.ts +172 -0
- package/src/types.ts +191 -0
- package/src/validation.ts +188 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { DayOfWeek } from "../../types.js";
|
|
3
|
+
import type { SchedulingEmployee } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export type EntityScope =
|
|
6
|
+
| { type: "global" }
|
|
7
|
+
| { type: "employees"; employeeIds: string[] }
|
|
8
|
+
| { type: "roles"; roleIds: string[] }
|
|
9
|
+
| { type: "skills"; skillIds: string[] };
|
|
10
|
+
|
|
11
|
+
export type TimeScope =
|
|
12
|
+
| { type: "always" }
|
|
13
|
+
| { type: "dateRange"; start: string; end: string }
|
|
14
|
+
| { type: "specificDates"; dates: string[] }
|
|
15
|
+
| { type: "dayOfWeek"; days: DayOfWeek[] }
|
|
16
|
+
| {
|
|
17
|
+
type: "recurring";
|
|
18
|
+
periods: {
|
|
19
|
+
name: string;
|
|
20
|
+
startMonth: number;
|
|
21
|
+
startDay: number;
|
|
22
|
+
endMonth: number;
|
|
23
|
+
endDay: number;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface RuleScope {
|
|
28
|
+
entity: EntityScope;
|
|
29
|
+
time?: TimeScope;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScopeConfig {
|
|
33
|
+
employeeIds?: string[];
|
|
34
|
+
roleIds?: string[];
|
|
35
|
+
skillIds?: string[];
|
|
36
|
+
dateRange?: { start: string; end: string };
|
|
37
|
+
specificDates?: string[];
|
|
38
|
+
dayOfWeek?: DayOfWeek[];
|
|
39
|
+
recurringPeriods?: {
|
|
40
|
+
name: string;
|
|
41
|
+
startMonth: number;
|
|
42
|
+
startDay: number;
|
|
43
|
+
endMonth: number;
|
|
44
|
+
endDay: number;
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type SupportedEntities = ReadonlyArray<EntityKey>;
|
|
49
|
+
type SupportedTimes = ReadonlyArray<TimeKey>;
|
|
50
|
+
|
|
51
|
+
const dayOfWeekSchema = z.union([
|
|
52
|
+
z.literal("monday"),
|
|
53
|
+
z.literal("tuesday"),
|
|
54
|
+
z.literal("wednesday"),
|
|
55
|
+
z.literal("thursday"),
|
|
56
|
+
z.literal("friday"),
|
|
57
|
+
z.literal("saturday"),
|
|
58
|
+
z.literal("sunday"),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
type EntityKey = "employees" | "roles" | "skills";
|
|
62
|
+
type TimeKey = "dateRange" | "specificDates" | "dayOfWeek" | "recurring";
|
|
63
|
+
|
|
64
|
+
type EntityScopeShape<T extends readonly EntityKey[]> = ("employees" extends T[number]
|
|
65
|
+
? { employeeIds: z.ZodOptional<z.ZodArray<z.ZodString>> }
|
|
66
|
+
: {}) &
|
|
67
|
+
("roles" extends T[number] ? { roleIds: z.ZodOptional<z.ZodArray<z.ZodString>> } : {}) &
|
|
68
|
+
("skills" extends T[number] ? { skillIds: z.ZodOptional<z.ZodArray<z.ZodString>> } : {});
|
|
69
|
+
|
|
70
|
+
type TimeScopeShape<T extends readonly TimeKey[]> = ("dateRange" extends T[number]
|
|
71
|
+
? {
|
|
72
|
+
dateRange: z.ZodOptional<
|
|
73
|
+
z.ZodObject<{
|
|
74
|
+
start: z.ZodString;
|
|
75
|
+
end: z.ZodString;
|
|
76
|
+
}>
|
|
77
|
+
>;
|
|
78
|
+
}
|
|
79
|
+
: {}) &
|
|
80
|
+
("specificDates" extends T[number]
|
|
81
|
+
? { specificDates: z.ZodOptional<z.ZodArray<z.ZodString>> }
|
|
82
|
+
: {}) &
|
|
83
|
+
("dayOfWeek" extends T[number]
|
|
84
|
+
? { dayOfWeek: z.ZodOptional<z.ZodArray<typeof dayOfWeekSchema>> }
|
|
85
|
+
: {}) &
|
|
86
|
+
("recurring" extends T[number]
|
|
87
|
+
? {
|
|
88
|
+
recurringPeriods: z.ZodOptional<
|
|
89
|
+
z.ZodArray<
|
|
90
|
+
z.ZodObject<{
|
|
91
|
+
name: z.ZodString;
|
|
92
|
+
startMonth: z.ZodNumber;
|
|
93
|
+
startDay: z.ZodNumber;
|
|
94
|
+
endMonth: z.ZodNumber;
|
|
95
|
+
endDay: z.ZodNumber;
|
|
96
|
+
}>
|
|
97
|
+
>
|
|
98
|
+
>;
|
|
99
|
+
}
|
|
100
|
+
: {});
|
|
101
|
+
|
|
102
|
+
const timeScopeSchema = <T extends readonly TimeKey[]>(supported: T) =>
|
|
103
|
+
({
|
|
104
|
+
...(supported.includes("dateRange")
|
|
105
|
+
? {
|
|
106
|
+
dateRange: z
|
|
107
|
+
.object({
|
|
108
|
+
start: z.iso.date(),
|
|
109
|
+
end: z.iso.date(),
|
|
110
|
+
})
|
|
111
|
+
.optional(),
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
...(supported.includes("specificDates")
|
|
115
|
+
? { specificDates: z.array(z.iso.date()).optional() }
|
|
116
|
+
: {}),
|
|
117
|
+
...(supported.includes("dayOfWeek") ? { dayOfWeek: z.array(dayOfWeekSchema).optional() } : {}),
|
|
118
|
+
...(supported.includes("recurring")
|
|
119
|
+
? {
|
|
120
|
+
recurringPeriods: z
|
|
121
|
+
.array(
|
|
122
|
+
z.object({
|
|
123
|
+
name: z.string(),
|
|
124
|
+
startMonth: z.number(),
|
|
125
|
+
startDay: z.number(),
|
|
126
|
+
endMonth: z.number(),
|
|
127
|
+
endDay: z.number(),
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
.optional(),
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
}) as TimeScopeShape<T>;
|
|
134
|
+
|
|
135
|
+
const entityScopeSchema = <T extends readonly EntityKey[]>(supported: T) =>
|
|
136
|
+
({
|
|
137
|
+
...(supported.includes("employees") ? { employeeIds: z.array(z.string()).optional() } : {}),
|
|
138
|
+
...(supported.includes("roles") ? { roleIds: z.array(z.string()).optional() } : {}),
|
|
139
|
+
...(supported.includes("skills") ? { skillIds: z.array(z.string()).optional() } : {}),
|
|
140
|
+
}) as EntityScopeShape<T>;
|
|
141
|
+
|
|
142
|
+
type ScopedShape<
|
|
143
|
+
T extends z.ZodRawShape,
|
|
144
|
+
TEntities extends readonly EntityKey[],
|
|
145
|
+
TTimes extends readonly TimeKey[],
|
|
146
|
+
> = T & EntityScopeShape<TEntities> & TimeScopeShape<TTimes>;
|
|
147
|
+
type ScopedSchema<
|
|
148
|
+
T extends z.ZodRawShape,
|
|
149
|
+
TEntities extends readonly EntityKey[],
|
|
150
|
+
TTimes extends readonly TimeKey[],
|
|
151
|
+
> = z.ZodObject<ScopedShape<T, TEntities, TTimes>>;
|
|
152
|
+
type ScopedSchemaWithRefinement<
|
|
153
|
+
T extends z.ZodRawShape,
|
|
154
|
+
TEntities extends readonly EntityKey[],
|
|
155
|
+
TTimes extends readonly TimeKey[],
|
|
156
|
+
> = ReturnType<ScopedSchema<T, TEntities, TTimes>["superRefine"]>;
|
|
157
|
+
|
|
158
|
+
export const withScopes = <
|
|
159
|
+
T extends z.ZodRawShape,
|
|
160
|
+
TEntities extends SupportedEntities,
|
|
161
|
+
TTimes extends SupportedTimes,
|
|
162
|
+
>(
|
|
163
|
+
base: z.ZodObject<T>,
|
|
164
|
+
opts: { entities: TEntities; times: TTimes },
|
|
165
|
+
): ScopedSchemaWithRefinement<T, TEntities, TTimes> => {
|
|
166
|
+
const entityFields = entityScopeSchema(opts.entities);
|
|
167
|
+
const timeFields = timeScopeSchema(opts.times);
|
|
168
|
+
|
|
169
|
+
const extended = base.extend({
|
|
170
|
+
...entityFields,
|
|
171
|
+
...timeFields,
|
|
172
|
+
}) as ScopedSchema<T, TEntities, TTimes>;
|
|
173
|
+
|
|
174
|
+
type ExtendedOutput = z.output<typeof extended>;
|
|
175
|
+
|
|
176
|
+
const refined = extended.superRefine((val: ExtendedOutput, ctx) => {
|
|
177
|
+
const entityKeys = ["employeeIds", "roleIds", "skillIds"] as const;
|
|
178
|
+
const activeEntities = entityKeys.filter((key) => {
|
|
179
|
+
const value = (val as Record<(typeof entityKeys)[number], unknown>)[key];
|
|
180
|
+
return Array.isArray(value) && value.length > 0;
|
|
181
|
+
});
|
|
182
|
+
if (activeEntities.length > 1) {
|
|
183
|
+
ctx.addIssue({
|
|
184
|
+
code: z.ZodIssueCode.custom,
|
|
185
|
+
message: "Only one of employeeIds/roleIds/skillIds is allowed",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const timeKeys = ["dateRange", "specificDates", "dayOfWeek", "recurringPeriods"] as const;
|
|
190
|
+
const activeTimes = timeKeys.filter((key) => {
|
|
191
|
+
const value = (val as Record<(typeof timeKeys)[number], unknown>)[key];
|
|
192
|
+
if (!value) return false;
|
|
193
|
+
if (key === "dateRange") return true;
|
|
194
|
+
return Array.isArray(value) && value.length > 0;
|
|
195
|
+
});
|
|
196
|
+
if (activeTimes.length > 1) {
|
|
197
|
+
ctx.addIssue({
|
|
198
|
+
code: z.ZodIssueCode.custom,
|
|
199
|
+
message: "Only one of dateRange/specificDates/dayOfWeek/recurringPeriods is allowed",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return refined;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const normalizeScope = (
|
|
208
|
+
raw: {
|
|
209
|
+
employeeIds?: string[];
|
|
210
|
+
roleIds?: string[];
|
|
211
|
+
skillIds?: string[];
|
|
212
|
+
dateRange?: { start: string; end: string };
|
|
213
|
+
specificDates?: string[];
|
|
214
|
+
dayOfWeek?: DayOfWeek[];
|
|
215
|
+
recurringPeriods?: {
|
|
216
|
+
name: string;
|
|
217
|
+
startMonth: number;
|
|
218
|
+
startDay: number;
|
|
219
|
+
endMonth: number;
|
|
220
|
+
endDay: number;
|
|
221
|
+
}[];
|
|
222
|
+
},
|
|
223
|
+
employees: SchedulingEmployee[],
|
|
224
|
+
): RuleScope => {
|
|
225
|
+
const employeeSet = new Set(employees.map((e) => e.id));
|
|
226
|
+
|
|
227
|
+
// Check if scopes were explicitly provided (even if empty arrays)
|
|
228
|
+
const hasExplicitEmployeeIds = raw.employeeIds !== undefined && raw.employeeIds.length > 0;
|
|
229
|
+
const hasExplicitRoleIds = raw.roleIds !== undefined && raw.roleIds.length > 0;
|
|
230
|
+
const hasExplicitSkillIds = raw.skillIds !== undefined && raw.skillIds.length > 0;
|
|
231
|
+
|
|
232
|
+
const roleIds = raw.roleIds ?? [];
|
|
233
|
+
const skillIds = raw.skillIds ?? [];
|
|
234
|
+
|
|
235
|
+
// Filter employee IDs to only those that exist
|
|
236
|
+
const filteredEmployees = hasExplicitEmployeeIds
|
|
237
|
+
? raw.employeeIds!.filter((id) => employeeSet.has(id))
|
|
238
|
+
: [];
|
|
239
|
+
|
|
240
|
+
let entity: EntityScope;
|
|
241
|
+
if (hasExplicitEmployeeIds) {
|
|
242
|
+
// If employeeIds was explicitly provided, preserve that scope type
|
|
243
|
+
// even if all IDs were filtered out (matched no employees).
|
|
244
|
+
// This prevents a rule intended for specific employees from
|
|
245
|
+
// accidentally becoming a global rule affecting everyone.
|
|
246
|
+
entity = { type: "employees", employeeIds: filteredEmployees };
|
|
247
|
+
} else if (hasExplicitRoleIds) {
|
|
248
|
+
entity = { type: "roles", roleIds };
|
|
249
|
+
} else if (hasExplicitSkillIds) {
|
|
250
|
+
entity = { type: "skills", skillIds };
|
|
251
|
+
} else {
|
|
252
|
+
entity = { type: "global" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let time: TimeScope | undefined;
|
|
256
|
+
if (raw.dateRange) {
|
|
257
|
+
time = {
|
|
258
|
+
type: "dateRange",
|
|
259
|
+
start: raw.dateRange.start,
|
|
260
|
+
end: raw.dateRange.end,
|
|
261
|
+
};
|
|
262
|
+
} else if (raw.specificDates && raw.specificDates.length > 0) {
|
|
263
|
+
time = { type: "specificDates", dates: raw.specificDates };
|
|
264
|
+
} else if (raw.dayOfWeek && raw.dayOfWeek.length > 0) {
|
|
265
|
+
time = { type: "dayOfWeek", days: raw.dayOfWeek };
|
|
266
|
+
} else if (raw.recurringPeriods && raw.recurringPeriods.length > 0) {
|
|
267
|
+
time = { type: "recurring", periods: raw.recurringPeriods };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { entity, ...(time ? { time } : {}) };
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const specificity = (scope: EntityScope): number => {
|
|
274
|
+
switch (scope.type) {
|
|
275
|
+
case "employees":
|
|
276
|
+
return 4;
|
|
277
|
+
case "roles":
|
|
278
|
+
return 3;
|
|
279
|
+
case "skills":
|
|
280
|
+
return 2;
|
|
281
|
+
case "global":
|
|
282
|
+
default:
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export const effectiveEmployeeIds = (
|
|
288
|
+
scope: EntityScope,
|
|
289
|
+
employees: SchedulingEmployee[],
|
|
290
|
+
): string[] => {
|
|
291
|
+
const ids = employees.map((e) => e.id);
|
|
292
|
+
const idSet = new Set(ids);
|
|
293
|
+
|
|
294
|
+
switch (scope.type) {
|
|
295
|
+
case "employees":
|
|
296
|
+
return scope.employeeIds.filter((id) => idSet.has(id));
|
|
297
|
+
default:
|
|
298
|
+
return ids;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const subtractIds = (ids: string[], assigned: Set<string>): string[] =>
|
|
303
|
+
ids.filter((id) => !assigned.has(id));
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Creates a stable key for a time scope to use in grouping.
|
|
307
|
+
* Rules with different time scope keys don't compete for deduplication.
|
|
308
|
+
*/
|
|
309
|
+
export const timeScopeKey = (time: TimeScope | undefined): string => {
|
|
310
|
+
if (!time) return "always";
|
|
311
|
+
switch (time.type) {
|
|
312
|
+
case "always":
|
|
313
|
+
return "always";
|
|
314
|
+
case "dateRange":
|
|
315
|
+
return `dateRange:${time.start}:${time.end}`;
|
|
316
|
+
case "specificDates":
|
|
317
|
+
return `specificDates:${[...time.dates].toSorted().join(",")}`;
|
|
318
|
+
case "dayOfWeek":
|
|
319
|
+
return `dayOfWeek:${[...time.days].toSorted().join(",")}`;
|
|
320
|
+
case "recurring":
|
|
321
|
+
return `recurring:${time.periods.map((p) => `${p.startMonth}-${p.startDay}:${p.endMonth}-${p.endDay}`).join(";")}`;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Creates a stable key for an entity scope type.
|
|
327
|
+
* Used to prevent merging different scope types during resolution.
|
|
328
|
+
*/
|
|
329
|
+
export const entityScopeTypeKey = (entity: EntityScope): string => {
|
|
330
|
+
switch (entity.type) {
|
|
331
|
+
case "employees":
|
|
332
|
+
return "employees";
|
|
333
|
+
case "roles":
|
|
334
|
+
return `roles:${entity.roleIds.toSorted().join(",")}`;
|
|
335
|
+
case "skills":
|
|
336
|
+
return `skills:${entity.skillIds.toSorted().join(",")}`;
|
|
337
|
+
case "global":
|
|
338
|
+
return "global";
|
|
339
|
+
}
|
|
340
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { TimeOfDay } from "../../types.js";
|
|
3
|
+
import type { CompilationRule, RuleValidationContext } from "../model-builder.js";
|
|
4
|
+
import type { ResolvedShiftAssignment } from "../response.js";
|
|
5
|
+
import type { SchedulingEmployee } from "../types.js";
|
|
6
|
+
import {
|
|
7
|
+
normalizeEndMinutes,
|
|
8
|
+
parseDayString,
|
|
9
|
+
priorityToPenalty,
|
|
10
|
+
timeOfDayToMinutes,
|
|
11
|
+
} from "../utils.js";
|
|
12
|
+
import type { ValidationReporter } from "../validation-reporter.js";
|
|
13
|
+
import { normalizeScope, withScopes, type RuleScope } from "./scoping.js";
|
|
14
|
+
|
|
15
|
+
const timeOfDaySchema = z.object({
|
|
16
|
+
hours: z.number().int().min(0).max(23),
|
|
17
|
+
minutes: z.number().int().min(0).max(59),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const TimeOffSchema = withScopes(
|
|
21
|
+
z.object({
|
|
22
|
+
priority: z.union([
|
|
23
|
+
z.literal("LOW"),
|
|
24
|
+
z.literal("MEDIUM"),
|
|
25
|
+
z.literal("HIGH"),
|
|
26
|
+
z.literal("MANDATORY"),
|
|
27
|
+
]),
|
|
28
|
+
startTime: timeOfDaySchema.optional(),
|
|
29
|
+
endTime: timeOfDaySchema.optional(),
|
|
30
|
+
}),
|
|
31
|
+
{
|
|
32
|
+
entities: ["employees", "roles", "skills"],
|
|
33
|
+
times: ["dateRange", "specificDates", "dayOfWeek", "recurring"],
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
.refine(
|
|
37
|
+
(config) => {
|
|
38
|
+
const hasStartTime = config.startTime !== undefined;
|
|
39
|
+
const hasEndTime = config.endTime !== undefined;
|
|
40
|
+
return hasStartTime === hasEndTime;
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
message: "Both startTime and endTime must be provided together for partial day time-off",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
.refine(
|
|
47
|
+
(config) => {
|
|
48
|
+
return !!(
|
|
49
|
+
config.dateRange ||
|
|
50
|
+
(config.specificDates && config.specificDates.length > 0) ||
|
|
51
|
+
(config.dayOfWeek && config.dayOfWeek.length > 0) ||
|
|
52
|
+
(config.recurringPeriods && config.recurringPeriods.length > 0)
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
message:
|
|
57
|
+
"Must provide time scoping (dateRange, specificDates, dayOfWeek, or recurringPeriods)",
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export type TimeOffConfig = z.infer<typeof TimeOffSchema>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Blocks or penalizes assignments during specified time periods.
|
|
65
|
+
*
|
|
66
|
+
* Supports entity scoping (people, roles, skills) and time scoping
|
|
67
|
+
* (date ranges, specific dates, days of week, recurring periods).
|
|
68
|
+
* Optionally supports partial-day time-off with startTime/endTime.
|
|
69
|
+
*
|
|
70
|
+
* @example Full day vacation
|
|
71
|
+
* ```ts
|
|
72
|
+
* createTimeOffRule({
|
|
73
|
+
* employeeIds: ["alice"],
|
|
74
|
+
* dateRange: { start: "2024-02-01", end: "2024-02-05" },
|
|
75
|
+
* priority: "MANDATORY",
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @example Every Wednesday afternoon off for students
|
|
80
|
+
* ```ts
|
|
81
|
+
* createTimeOffRule({
|
|
82
|
+
* roleIds: ["student"],
|
|
83
|
+
* dayOfWeek: ["wednesday"],
|
|
84
|
+
* startTime: { hours: 14, minutes: 0 },
|
|
85
|
+
* endTime: { hours: 23, minutes: 59 },
|
|
86
|
+
* priority: "MANDATORY",
|
|
87
|
+
* });
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* @example Specific date, partial day
|
|
91
|
+
* ```ts
|
|
92
|
+
* createTimeOffRule({
|
|
93
|
+
* employeeIds: ["bob"],
|
|
94
|
+
* specificDates: ["2024-03-15"],
|
|
95
|
+
* startTime: { hours: 16, minutes: 0 },
|
|
96
|
+
* endTime: { hours: 23, minutes: 59 },
|
|
97
|
+
* priority: "MANDATORY",
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function createTimeOffRule(config: TimeOffConfig): CompilationRule {
|
|
102
|
+
const parsed = TimeOffSchema.parse(config);
|
|
103
|
+
const { priority, startTime, endTime } = parsed;
|
|
104
|
+
|
|
105
|
+
const fullDayStart: TimeOfDay = { hours: 0, minutes: 0 };
|
|
106
|
+
const fullDayEnd: TimeOfDay = { hours: 23, minutes: 59 };
|
|
107
|
+
const timeWindowStart = startTime ?? fullDayStart;
|
|
108
|
+
const timeWindowEnd = endTime ?? fullDayEnd;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
compile(builder) {
|
|
112
|
+
const scope = normalizeScope(parsed, builder.employees);
|
|
113
|
+
const targetEmployees = resolveEmployees(scope, builder.employees);
|
|
114
|
+
if (targetEmployees.length === 0) return;
|
|
115
|
+
|
|
116
|
+
const activeDays = resolveActiveDays(scope, builder.days);
|
|
117
|
+
if (activeDays.length === 0) return;
|
|
118
|
+
|
|
119
|
+
for (const emp of targetEmployees) {
|
|
120
|
+
for (const day of activeDays) {
|
|
121
|
+
// Report exclusion for coverage analysis (MANDATORY only)
|
|
122
|
+
if (priority === "MANDATORY") {
|
|
123
|
+
builder.reporter.excludeFromCoverage({
|
|
124
|
+
employeeId: emp.id,
|
|
125
|
+
day,
|
|
126
|
+
startTime: timeWindowStart,
|
|
127
|
+
endTime: timeWindowEnd,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const pattern of builder.shiftPatterns) {
|
|
132
|
+
if (!builder.canAssign(emp, pattern)) continue;
|
|
133
|
+
if (!builder.patternAvailableOnDay(pattern, day)) continue;
|
|
134
|
+
|
|
135
|
+
if (startTime && endTime) {
|
|
136
|
+
if (!shiftOverlapsTimeWindow(pattern, startTime, endTime)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const assignVar = builder.assignment(emp.id, pattern.id, day);
|
|
142
|
+
|
|
143
|
+
if (priority === "MANDATORY") {
|
|
144
|
+
builder.addLinear([{ var: assignVar, coeff: 1 }], "==", 0);
|
|
145
|
+
} else {
|
|
146
|
+
builder.addSoftLinear(
|
|
147
|
+
[{ var: assignVar, coeff: 1 }],
|
|
148
|
+
"<=",
|
|
149
|
+
0,
|
|
150
|
+
priorityToPenalty(priority),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
validate(
|
|
159
|
+
assignments: ResolvedShiftAssignment[],
|
|
160
|
+
reporter: ValidationReporter,
|
|
161
|
+
context: RuleValidationContext,
|
|
162
|
+
) {
|
|
163
|
+
// MANDATORY time-off is a hard constraint - can't be violated
|
|
164
|
+
if (priority === "MANDATORY") return;
|
|
165
|
+
|
|
166
|
+
// Re-resolve scoping using context
|
|
167
|
+
const scope = normalizeScope(parsed, context.employees);
|
|
168
|
+
const targetEmployees = resolveEmployees(scope, context.employees);
|
|
169
|
+
if (targetEmployees.length === 0) return;
|
|
170
|
+
|
|
171
|
+
const activeDays = resolveActiveDays(scope, context.days);
|
|
172
|
+
if (activeDays.length === 0) return;
|
|
173
|
+
|
|
174
|
+
// Check each employee/day combination
|
|
175
|
+
for (const emp of targetEmployees) {
|
|
176
|
+
for (const day of activeDays) {
|
|
177
|
+
const violated = assignments.some(
|
|
178
|
+
(a) =>
|
|
179
|
+
a.employeeId === emp.id &&
|
|
180
|
+
a.day === day &&
|
|
181
|
+
assignmentOverlapsTimeWindow(a, timeWindowStart, timeWindowEnd),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (violated) {
|
|
185
|
+
reporter.reportRuleViolation({
|
|
186
|
+
rule: "time-off",
|
|
187
|
+
reason: `Time-off request for ${emp.id} on ${day} could not be honored`,
|
|
188
|
+
context: {
|
|
189
|
+
employeeIds: [emp.id],
|
|
190
|
+
days: [day],
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
reporter.reportRulePassed({
|
|
195
|
+
rule: "time-off",
|
|
196
|
+
description: `Time-off honored for ${emp.id} on ${day}`,
|
|
197
|
+
context: {
|
|
198
|
+
employeeIds: [emp.id],
|
|
199
|
+
days: [day],
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveEmployees(scope: RuleScope, employees: SchedulingEmployee[]): SchedulingEmployee[] {
|
|
210
|
+
const entity = scope.entity;
|
|
211
|
+
switch (entity.type) {
|
|
212
|
+
case "employees":
|
|
213
|
+
return employees.filter((e) => entity.employeeIds.includes(e.id));
|
|
214
|
+
case "roles":
|
|
215
|
+
return employees.filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)));
|
|
216
|
+
case "skills":
|
|
217
|
+
return employees.filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)));
|
|
218
|
+
case "global":
|
|
219
|
+
default:
|
|
220
|
+
return employees;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolveActiveDays(scope: RuleScope, allDays: string[]): string[] {
|
|
225
|
+
const timeScope = scope.time;
|
|
226
|
+
|
|
227
|
+
if (!timeScope) {
|
|
228
|
+
return allDays;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
switch (timeScope.type) {
|
|
232
|
+
case "always":
|
|
233
|
+
return allDays;
|
|
234
|
+
|
|
235
|
+
case "dateRange": {
|
|
236
|
+
const start = timeScope.start;
|
|
237
|
+
const end = timeScope.end;
|
|
238
|
+
return allDays.filter((day) => day >= start && day <= end);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case "specificDates":
|
|
242
|
+
return allDays.filter((day) => timeScope.dates.includes(day));
|
|
243
|
+
|
|
244
|
+
case "dayOfWeek": {
|
|
245
|
+
const targetDays = new Set(timeScope.days);
|
|
246
|
+
return allDays.filter((day) => {
|
|
247
|
+
const date = parseDayString(day);
|
|
248
|
+
const dayName = getDayOfWeekName(date.getUTCDay());
|
|
249
|
+
return targetDays.has(dayName);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case "recurring": {
|
|
254
|
+
return allDays.filter((day) => {
|
|
255
|
+
const date = parseDayString(day);
|
|
256
|
+
const month = date.getUTCMonth() + 1;
|
|
257
|
+
const dayOfMonth = date.getUTCDate();
|
|
258
|
+
|
|
259
|
+
return timeScope.periods.some((period) =>
|
|
260
|
+
isDateInRecurringPeriod(month, dayOfMonth, period),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
default:
|
|
266
|
+
return allDays;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
|
|
271
|
+
|
|
272
|
+
function getDayOfWeekName(dayIndex: number): DayName {
|
|
273
|
+
const names: Record<number, DayName> = {
|
|
274
|
+
0: "sunday",
|
|
275
|
+
1: "monday",
|
|
276
|
+
2: "tuesday",
|
|
277
|
+
3: "wednesday",
|
|
278
|
+
4: "thursday",
|
|
279
|
+
5: "friday",
|
|
280
|
+
6: "saturday",
|
|
281
|
+
};
|
|
282
|
+
return names[dayIndex % 7] ?? "sunday";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isDateInRecurringPeriod(
|
|
286
|
+
month: number,
|
|
287
|
+
dayOfMonth: number,
|
|
288
|
+
period: {
|
|
289
|
+
startMonth: number;
|
|
290
|
+
startDay: number;
|
|
291
|
+
endMonth: number;
|
|
292
|
+
endDay: number;
|
|
293
|
+
},
|
|
294
|
+
): boolean {
|
|
295
|
+
const { startMonth, startDay, endMonth, endDay } = period;
|
|
296
|
+
|
|
297
|
+
if (startMonth <= endMonth) {
|
|
298
|
+
if (month < startMonth || month > endMonth) return false;
|
|
299
|
+
if (month === startMonth && dayOfMonth < startDay) return false;
|
|
300
|
+
if (month === endMonth && dayOfMonth > endDay) return false;
|
|
301
|
+
return true;
|
|
302
|
+
} else {
|
|
303
|
+
if (month > endMonth && month < startMonth) return false;
|
|
304
|
+
if (month === startMonth && dayOfMonth < startDay) return false;
|
|
305
|
+
if (month === endMonth && dayOfMonth > endDay) return false;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function shiftOverlapsTimeWindow(
|
|
311
|
+
pattern: { startTime: TimeOfDay; endTime: TimeOfDay },
|
|
312
|
+
windowStart: TimeOfDay,
|
|
313
|
+
windowEnd: TimeOfDay,
|
|
314
|
+
): boolean {
|
|
315
|
+
const shiftStart = timeOfDayToMinutes(pattern.startTime);
|
|
316
|
+
const shiftEnd = normalizeEndMinutes(shiftStart, timeOfDayToMinutes(pattern.endTime));
|
|
317
|
+
|
|
318
|
+
const winStart = timeOfDayToMinutes(windowStart);
|
|
319
|
+
const winEnd = normalizeEndMinutes(winStart, timeOfDayToMinutes(windowEnd));
|
|
320
|
+
|
|
321
|
+
return Math.max(shiftStart, winStart) < Math.min(shiftEnd, winEnd);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assignmentOverlapsTimeWindow(
|
|
325
|
+
assignment: ResolvedShiftAssignment,
|
|
326
|
+
windowStart: TimeOfDay,
|
|
327
|
+
windowEnd: TimeOfDay,
|
|
328
|
+
): boolean {
|
|
329
|
+
const assignStart = timeOfDayToMinutes(assignment.startTime);
|
|
330
|
+
const assignEnd = normalizeEndMinutes(assignStart, timeOfDayToMinutes(assignment.endTime));
|
|
331
|
+
|
|
332
|
+
const winStart = timeOfDayToMinutes(windowStart);
|
|
333
|
+
const winEnd = normalizeEndMinutes(winStart, timeOfDayToMinutes(windowEnd));
|
|
334
|
+
|
|
335
|
+
return Math.max(assignStart, winStart) < Math.min(assignEnd, winEnd);
|
|
336
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createAssignTogetherRule,
|
|
3
|
+
createEmployeeAssignmentPriorityRule,
|
|
4
|
+
createLocationPreferenceRule,
|
|
5
|
+
createMaxConsecutiveDaysRule,
|
|
6
|
+
createMaxHoursDayRule,
|
|
7
|
+
createMaxHoursWeekRule,
|
|
8
|
+
createMinConsecutiveDaysRule,
|
|
9
|
+
createMinHoursDayRule,
|
|
10
|
+
createMinHoursWeekRule,
|
|
11
|
+
createMinRestBetweenShiftsRule,
|
|
12
|
+
createTimeOffRule,
|
|
13
|
+
} from "./rules/index.js";
|
|
14
|
+
|
|
15
|
+
export { builtInCpsatRuleFactories, createCpsatRuleFactory } from "./rules/registry.js";
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
CpsatRuleName,
|
|
19
|
+
CpsatRuleRegistry,
|
|
20
|
+
CpsatRuleFactories,
|
|
21
|
+
BuiltInCpsatRuleFactories,
|
|
22
|
+
CpsatRuleRegistryFromFactories,
|
|
23
|
+
CreateCpsatRuleFunction,
|
|
24
|
+
CpsatRuleConfigEntry,
|
|
25
|
+
} from "./rules/rules.types.js";
|
|
26
|
+
|
|
27
|
+
export { buildCpsatRules, getEmployeeIdsForScope, resolveRuleScopes } from "./rules/resolver.js";
|