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,130 @@
|
|
|
1
|
+
import type { SolverResponse } from "../client.types.js";
|
|
2
|
+
import type { ShiftPattern } from "./types.js";
|
|
3
|
+
import type { TimeOfDay } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A shift assignment extracted from the solver response.
|
|
7
|
+
*/
|
|
8
|
+
export interface ShiftAssignment {
|
|
9
|
+
employeeId: string;
|
|
10
|
+
shiftPatternId: string;
|
|
11
|
+
day: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A shift assignment with resolved times.
|
|
16
|
+
*/
|
|
17
|
+
export interface ResolvedShiftAssignment {
|
|
18
|
+
employeeId: string;
|
|
19
|
+
day: string;
|
|
20
|
+
startTime: TimeOfDay;
|
|
21
|
+
endTime: TimeOfDay;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parsed solver result with assignments and metadata.
|
|
26
|
+
*/
|
|
27
|
+
export interface SolverResult {
|
|
28
|
+
status: SolverResponse["status"];
|
|
29
|
+
assignments: ShiftAssignment[];
|
|
30
|
+
statistics?: SolverResponse["statistics"];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extracts shift assignments from solver response.
|
|
36
|
+
*
|
|
37
|
+
* Parses variable names matching the pattern `assign:${employeeId}:${patternId}:${day}`
|
|
38
|
+
* and returns assignments where the variable value is 1 (true).
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* IDs are validated by ModelBuilder to not contain colons,
|
|
42
|
+
* ensuring unambiguous parsing.
|
|
43
|
+
*
|
|
44
|
+
* @param response - The solver response containing variable values
|
|
45
|
+
* @returns Parsed schedule result with assignments
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const response = await client.solve(request);
|
|
50
|
+
* const result = parseSolverResponse(response);
|
|
51
|
+
*
|
|
52
|
+
* if (result.status === "OPTIMAL" || result.status === "FEASIBLE") {
|
|
53
|
+
* for (const assignment of result.assignments) {
|
|
54
|
+
* console.log(`${assignment.employeeId} works ${assignment.shiftPatternId} on ${assignment.day}`);
|
|
55
|
+
* }
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function parseSolverResponse(response: SolverResponse): SolverResult {
|
|
60
|
+
if (response.status === "INFEASIBLE" || response.status === "ERROR") {
|
|
61
|
+
return {
|
|
62
|
+
status: response.status,
|
|
63
|
+
assignments: [],
|
|
64
|
+
statistics: response.statistics,
|
|
65
|
+
error: response.error,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const assignments: ShiftAssignment[] = [];
|
|
70
|
+
|
|
71
|
+
for (const [varName, value] of Object.entries(response.values ?? {})) {
|
|
72
|
+
if (value !== 1) continue;
|
|
73
|
+
if (!varName.startsWith("assign:")) continue;
|
|
74
|
+
|
|
75
|
+
// Pattern: assign:${employeeId}:${patternId}:${day}
|
|
76
|
+
// IDs are validated to not contain colons, so splitting is unambiguous.
|
|
77
|
+
const parts = varName.split(":");
|
|
78
|
+
if (parts.length !== 4) continue;
|
|
79
|
+
|
|
80
|
+
const [, employeeId, shiftPatternId, day] = parts;
|
|
81
|
+
if (!employeeId || !shiftPatternId || !day) continue;
|
|
82
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
83
|
+
|
|
84
|
+
assignments.push({ employeeId, shiftPatternId, day });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
status: response.status,
|
|
89
|
+
assignments,
|
|
90
|
+
statistics: response.statistics,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves shift assignments to concrete times using shift patterns.
|
|
96
|
+
*
|
|
97
|
+
* @param assignments - Raw assignments from parseScheduleResult
|
|
98
|
+
* @param shiftPatterns - The shift patterns used in the model
|
|
99
|
+
* @returns Assignments with resolved start/end times
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* const result = parseScheduleResult(response);
|
|
104
|
+
* const resolved = resolveAssignments(result.assignments, shiftPatterns);
|
|
105
|
+
*
|
|
106
|
+
* for (const shift of resolved) {
|
|
107
|
+
* console.log(`${shift.employeeId} works ${shift.day} from ${shift.startTime.hours}:${shift.startTime.minutes}`);
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function resolveAssignments(
|
|
112
|
+
assignments: ShiftAssignment[],
|
|
113
|
+
shiftPatterns: ShiftPattern[],
|
|
114
|
+
): ResolvedShiftAssignment[] {
|
|
115
|
+
const patternMap = new Map(shiftPatterns.map((p) => [p.id, p]));
|
|
116
|
+
|
|
117
|
+
return assignments
|
|
118
|
+
.map((a) => {
|
|
119
|
+
const pattern = patternMap.get(a.shiftPatternId);
|
|
120
|
+
if (!pattern) return null;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
employeeId: a.employeeId,
|
|
124
|
+
day: a.day,
|
|
125
|
+
startTime: pattern.startTime,
|
|
126
|
+
endTime: pattern.endTime,
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
.filter((a): a is ResolvedShiftAssignment => a !== null);
|
|
130
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { CompilationRule } from "../model-builder.js";
|
|
3
|
+
import { priorityToPenalty } from "../utils.js";
|
|
4
|
+
|
|
5
|
+
const AssignTogetherSchema = z.object({
|
|
6
|
+
groupEmployeeIds: z
|
|
7
|
+
.tuple([z.string(), z.string()])
|
|
8
|
+
.rest(z.string())
|
|
9
|
+
.refine((ids) => new Set(ids).size === ids.length, {
|
|
10
|
+
message: "IDs must be unique",
|
|
11
|
+
}),
|
|
12
|
+
priority: z.union([
|
|
13
|
+
z.literal("LOW"),
|
|
14
|
+
z.literal("MEDIUM"),
|
|
15
|
+
z.literal("HIGH"),
|
|
16
|
+
z.literal("MANDATORY"),
|
|
17
|
+
]),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type AssignTogetherConfig = z.infer<typeof AssignTogetherSchema>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Encourages or enforces that team members in the group work the same shift patterns on a day.
|
|
24
|
+
* For each pair of team members in the group, ensures they are assigned to the same shifts.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const rule = createAssignTogetherRule({
|
|
29
|
+
* groupEmployeeIds: ["alice", "bob", "charlie"],
|
|
30
|
+
* priority: "HIGH",
|
|
31
|
+
* });
|
|
32
|
+
* builder = new ModelBuilder({ ...config, rules: [rule] });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function createAssignTogetherRule(config: AssignTogetherConfig): CompilationRule {
|
|
36
|
+
const { groupEmployeeIds, priority } = AssignTogetherSchema.parse(config);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
compile(b) {
|
|
40
|
+
const employees = groupEmployeeIds
|
|
41
|
+
.map((id) => b.employees.find((e) => e.id === id))
|
|
42
|
+
.filter((e): e is NonNullable<typeof e> => e !== undefined);
|
|
43
|
+
|
|
44
|
+
if (employees.length < 2) return;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < employees.length - 1; i++) {
|
|
47
|
+
const emp1 = employees[i]!;
|
|
48
|
+
const emp2 = employees[i + 1]!;
|
|
49
|
+
|
|
50
|
+
for (const pattern of b.shiftPatterns) {
|
|
51
|
+
const canAssign1 = b.canAssign(emp1, pattern);
|
|
52
|
+
const canAssign2 = b.canAssign(emp2, pattern);
|
|
53
|
+
if (!canAssign1 || !canAssign2) continue;
|
|
54
|
+
|
|
55
|
+
for (const day of b.days) {
|
|
56
|
+
if (!b.patternAvailableOnDay(pattern, day)) continue;
|
|
57
|
+
const var1 = b.assignment(emp1.id, pattern.id, day);
|
|
58
|
+
const var2 = b.assignment(emp2.id, pattern.id, day);
|
|
59
|
+
|
|
60
|
+
if (priority === "MANDATORY") {
|
|
61
|
+
b.addLinear(
|
|
62
|
+
[
|
|
63
|
+
{ var: var1, coeff: 1 },
|
|
64
|
+
{ var: var2, coeff: -1 },
|
|
65
|
+
],
|
|
66
|
+
"==",
|
|
67
|
+
0,
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
const diffVar = b.boolVar(`together_diff_${emp1.id}_${emp2.id}_${pattern.id}_${day}`);
|
|
71
|
+
b.addLinear(
|
|
72
|
+
[
|
|
73
|
+
{ var: diffVar, coeff: 1 },
|
|
74
|
+
{ var: var1, coeff: -1 },
|
|
75
|
+
{ var: var2, coeff: 1 },
|
|
76
|
+
],
|
|
77
|
+
">=",
|
|
78
|
+
0,
|
|
79
|
+
);
|
|
80
|
+
b.addLinear(
|
|
81
|
+
[
|
|
82
|
+
{ var: diffVar, coeff: 1 },
|
|
83
|
+
{ var: var1, coeff: 1 },
|
|
84
|
+
{ var: var2, coeff: -1 },
|
|
85
|
+
],
|
|
86
|
+
">=",
|
|
87
|
+
0,
|
|
88
|
+
);
|
|
89
|
+
b.addPenalty(diffVar, priorityToPenalty(priority));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { CompilationRule } from "../model-builder.js";
|
|
3
|
+
import type { SchedulingEmployee } from "../types.js";
|
|
4
|
+
import { OBJECTIVE_WEIGHTS, parseDayString } from "../utils.js";
|
|
5
|
+
import { normalizeScope, withScopes, type RuleScope } from "./scoping.js";
|
|
6
|
+
|
|
7
|
+
const EmployeeAssignmentPrioritySchema = withScopes(
|
|
8
|
+
z.object({
|
|
9
|
+
preference: z.union([z.literal("high"), z.literal("low")]),
|
|
10
|
+
}),
|
|
11
|
+
{
|
|
12
|
+
entities: ["employees", "roles", "skills"],
|
|
13
|
+
times: ["dateRange", "specificDates", "dayOfWeek", "recurring"],
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export type EmployeeAssignmentPriorityConfig = z.infer<typeof EmployeeAssignmentPrioritySchema>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Adds objective weight to prefer or avoid assigning team members.
|
|
21
|
+
*
|
|
22
|
+
* Supports entity scoping (people, roles, skills) and time scoping
|
|
23
|
+
* (date ranges, specific dates, days of week, recurring periods).
|
|
24
|
+
*
|
|
25
|
+
* @example Prefer specific team members
|
|
26
|
+
* ```ts
|
|
27
|
+
* createEmployeeAssignmentPriorityRule({
|
|
28
|
+
* employeeIds: ["alice", "bob"],
|
|
29
|
+
* preference: "high",
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example Avoid assigning students on weekdays
|
|
34
|
+
* ```ts
|
|
35
|
+
* createEmployeeAssignmentPriorityRule({
|
|
36
|
+
* roleIds: ["student"],
|
|
37
|
+
* dayOfWeek: ["monday", "tuesday", "wednesday", "thursday", "friday"],
|
|
38
|
+
* preference: "low",
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example Prefer experienced staff on weekends
|
|
43
|
+
* ```ts
|
|
44
|
+
* createEmployeeAssignmentPriorityRule({
|
|
45
|
+
* skillIds: ["senior"],
|
|
46
|
+
* dayOfWeek: ["saturday", "sunday"],
|
|
47
|
+
* preference: "high",
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function createEmployeeAssignmentPriorityRule(
|
|
52
|
+
config: EmployeeAssignmentPriorityConfig,
|
|
53
|
+
): CompilationRule {
|
|
54
|
+
const parsed = EmployeeAssignmentPrioritySchema.parse(config);
|
|
55
|
+
const { preference } = parsed;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
compile(b) {
|
|
59
|
+
const scope = normalizeScope(parsed, b.employees);
|
|
60
|
+
const targetEmployees = resolveEmployees(scope, b.employees);
|
|
61
|
+
const activeDays = resolveActiveDays(scope, b.days);
|
|
62
|
+
|
|
63
|
+
if (targetEmployees.length === 0 || activeDays.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const weight =
|
|
66
|
+
preference === "high"
|
|
67
|
+
? -OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE
|
|
68
|
+
: OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE;
|
|
69
|
+
|
|
70
|
+
for (const emp of targetEmployees) {
|
|
71
|
+
for (const pattern of b.shiftPatterns) {
|
|
72
|
+
if (!b.canAssign(emp, pattern)) continue;
|
|
73
|
+
for (const day of activeDays) {
|
|
74
|
+
if (!b.patternAvailableOnDay(pattern, day)) continue;
|
|
75
|
+
b.addPenalty(b.assignment(emp.id, pattern.id, day), weight);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveEmployees(scope: RuleScope, employees: SchedulingEmployee[]): SchedulingEmployee[] {
|
|
84
|
+
const entity = scope.entity;
|
|
85
|
+
switch (entity.type) {
|
|
86
|
+
case "employees":
|
|
87
|
+
return employees.filter((e) => entity.employeeIds.includes(e.id));
|
|
88
|
+
case "roles":
|
|
89
|
+
return employees.filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)));
|
|
90
|
+
case "skills":
|
|
91
|
+
return employees.filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)));
|
|
92
|
+
case "global":
|
|
93
|
+
default:
|
|
94
|
+
return employees;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
|
|
99
|
+
|
|
100
|
+
function getDayOfWeekName(dayIndex: number): DayName {
|
|
101
|
+
const names: Record<number, DayName> = {
|
|
102
|
+
0: "sunday",
|
|
103
|
+
1: "monday",
|
|
104
|
+
2: "tuesday",
|
|
105
|
+
3: "wednesday",
|
|
106
|
+
4: "thursday",
|
|
107
|
+
5: "friday",
|
|
108
|
+
6: "saturday",
|
|
109
|
+
};
|
|
110
|
+
return names[dayIndex % 7] ?? "sunday";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveActiveDays(scope: RuleScope, allDays: string[]): string[] {
|
|
114
|
+
const timeScope = scope.time;
|
|
115
|
+
|
|
116
|
+
if (!timeScope) {
|
|
117
|
+
return allDays;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
switch (timeScope.type) {
|
|
121
|
+
case "always":
|
|
122
|
+
return allDays;
|
|
123
|
+
|
|
124
|
+
case "dateRange": {
|
|
125
|
+
const start = timeScope.start;
|
|
126
|
+
const end = timeScope.end;
|
|
127
|
+
return allDays.filter((day) => day >= start && day <= end);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case "specificDates":
|
|
131
|
+
return allDays.filter((day) => timeScope.dates.includes(day));
|
|
132
|
+
|
|
133
|
+
case "dayOfWeek": {
|
|
134
|
+
const targetDays = new Set(timeScope.days);
|
|
135
|
+
return allDays.filter((day) => {
|
|
136
|
+
const date = parseDayString(day);
|
|
137
|
+
const dayName = getDayOfWeekName(date.getUTCDay());
|
|
138
|
+
return targetDays.has(dayName);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case "recurring": {
|
|
143
|
+
return allDays.filter((day) => {
|
|
144
|
+
const date = parseDayString(day);
|
|
145
|
+
const month = date.getUTCMonth() + 1;
|
|
146
|
+
const dayOfMonth = date.getUTCDate();
|
|
147
|
+
|
|
148
|
+
return timeScope.periods.some((period) =>
|
|
149
|
+
isDateInRecurringPeriod(month, dayOfMonth, period),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
default:
|
|
155
|
+
return allDays;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isDateInRecurringPeriod(
|
|
160
|
+
month: number,
|
|
161
|
+
dayOfMonth: number,
|
|
162
|
+
period: {
|
|
163
|
+
startMonth: number;
|
|
164
|
+
startDay: number;
|
|
165
|
+
endMonth: number;
|
|
166
|
+
endDay: number;
|
|
167
|
+
},
|
|
168
|
+
): boolean {
|
|
169
|
+
const { startMonth, startDay, endMonth, endDay } = period;
|
|
170
|
+
|
|
171
|
+
if (startMonth <= endMonth) {
|
|
172
|
+
if (month < startMonth || month > endMonth) return false;
|
|
173
|
+
if (month === startMonth && dayOfMonth < startDay) return false;
|
|
174
|
+
if (month === endMonth && dayOfMonth > endDay) return false;
|
|
175
|
+
return true;
|
|
176
|
+
} else {
|
|
177
|
+
if (month > endMonth && month < startMonth) return false;
|
|
178
|
+
if (month === startMonth && dayOfMonth < startDay) return false;
|
|
179
|
+
if (month === endMonth && dayOfMonth > endDay) return false;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createAssignTogetherRule } from "./assign-together.js";
|
|
2
|
+
export { createEmployeeAssignmentPriorityRule } from "./employee-assignment-priority.js";
|
|
3
|
+
export { createLocationPreferenceRule } from "./location-preference.js";
|
|
4
|
+
export { createMaxConsecutiveDaysRule } from "./max-consecutive-days.js";
|
|
5
|
+
export { createMaxHoursDayRule } from "./max-hours-day.js";
|
|
6
|
+
export { createMaxHoursWeekRule } from "./max-hours-week.js";
|
|
7
|
+
export { createMaxShiftsDayRule } from "./max-shifts-day.js";
|
|
8
|
+
export { createMinConsecutiveDaysRule } from "./min-consecutive-days.js";
|
|
9
|
+
export { createMinHoursDayRule } from "./min-hours-day.js";
|
|
10
|
+
export { createMinHoursWeekRule } from "./min-hours-week.js";
|
|
11
|
+
export { createMinRestBetweenShiftsRule } from "./min-rest-between-shifts.js";
|
|
12
|
+
export { createTimeOffRule } from "./time-off.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { CompilationRule } from "../model-builder.js";
|
|
3
|
+
import type { Priority } from "../types.js";
|
|
4
|
+
import { OBJECTIVE_WEIGHTS } from "../utils.js";
|
|
5
|
+
import { withScopes } from "./scoping.js";
|
|
6
|
+
|
|
7
|
+
const LocationPreferenceSchema = withScopes(
|
|
8
|
+
z.object({
|
|
9
|
+
locationId: z.string(),
|
|
10
|
+
priority: z.union([
|
|
11
|
+
z.literal("LOW"),
|
|
12
|
+
z.literal("MEDIUM"),
|
|
13
|
+
z.literal("HIGH"),
|
|
14
|
+
z.literal("MANDATORY"),
|
|
15
|
+
]),
|
|
16
|
+
}),
|
|
17
|
+
{ entities: ["employees", "roles", "skills"], times: [] },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type LocationPreferenceConfig = z.infer<typeof LocationPreferenceSchema>;
|
|
21
|
+
|
|
22
|
+
const PRIORITY_WEIGHTS: Record<Priority, number> = {
|
|
23
|
+
LOW: OBJECTIVE_WEIGHTS.FAIRNESS, // 5 - same as fairness, weak preference
|
|
24
|
+
MEDIUM: OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE, // 10 - standard preference
|
|
25
|
+
HIGH: OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE * 2.5, // 25 - strong preference
|
|
26
|
+
MANDATORY: OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE * 5, // 50 - very strong
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prefers assigning a person to shift patterns matching a specific location.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const rule = createLocationPreferenceRule({
|
|
35
|
+
* locationId: "terrace",
|
|
36
|
+
* priority: "HIGH",
|
|
37
|
+
* employeeIds: ["alice"],
|
|
38
|
+
* });
|
|
39
|
+
* builder = new ModelBuilder({ ...config, rules: [rule] });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function createLocationPreferenceRule(config: LocationPreferenceConfig): CompilationRule {
|
|
43
|
+
const { locationId, priority, employeeIds } = LocationPreferenceSchema.parse(config);
|
|
44
|
+
const weight = PRIORITY_WEIGHTS[priority] ?? 0;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
compile(b) {
|
|
48
|
+
if (weight === 0) return;
|
|
49
|
+
|
|
50
|
+
const employees = employeeIds
|
|
51
|
+
? b.employees.filter((e) => employeeIds.includes(e.id))
|
|
52
|
+
: b.employees;
|
|
53
|
+
|
|
54
|
+
for (const emp of employees) {
|
|
55
|
+
for (const pattern of b.shiftPatterns) {
|
|
56
|
+
if (!b.canAssign(emp, pattern)) continue;
|
|
57
|
+
const isPreferred = pattern.locationId === locationId;
|
|
58
|
+
if (isPreferred) continue;
|
|
59
|
+
|
|
60
|
+
for (const day of b.days) {
|
|
61
|
+
if (!b.patternAvailableOnDay(pattern, day)) continue;
|
|
62
|
+
b.addPenalty(b.assignment(emp.id, pattern.id, day), weight);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { CompilationRule } from "../model-builder.js";
|
|
3
|
+
import { priorityToPenalty } from "../utils.js";
|
|
4
|
+
import { withScopes } from "./scoping.js";
|
|
5
|
+
|
|
6
|
+
const MaxConsecutiveDaysSchema = withScopes(
|
|
7
|
+
z.object({
|
|
8
|
+
days: z.number().min(0),
|
|
9
|
+
priority: z.union([
|
|
10
|
+
z.literal("LOW"),
|
|
11
|
+
z.literal("MEDIUM"),
|
|
12
|
+
z.literal("HIGH"),
|
|
13
|
+
z.literal("MANDATORY"),
|
|
14
|
+
]),
|
|
15
|
+
}),
|
|
16
|
+
{ entities: ["employees", "roles", "skills"], times: [] },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export type MaxConsecutiveDaysConfig = z.infer<typeof MaxConsecutiveDaysSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Limits how many consecutive days a person can be assigned.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const rule = createMaxConsecutiveDaysRule({
|
|
27
|
+
* days: 5,
|
|
28
|
+
* priority: "MANDATORY",
|
|
29
|
+
* });
|
|
30
|
+
* builder = new ModelBuilder({ ...config, rules: [rule] });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function createMaxConsecutiveDaysRule(config: MaxConsecutiveDaysConfig): CompilationRule {
|
|
34
|
+
const { days, priority, employeeIds } = MaxConsecutiveDaysSchema.parse(config);
|
|
35
|
+
const windowSize = days + 1;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
compile(b) {
|
|
39
|
+
const employees = employeeIds
|
|
40
|
+
? b.employees.filter((e) => employeeIds.includes(e.id))
|
|
41
|
+
: b.employees;
|
|
42
|
+
|
|
43
|
+
for (const emp of employees) {
|
|
44
|
+
for (let i = 0; i <= b.days.length - windowSize; i++) {
|
|
45
|
+
const windowDays = b.days.slice(i, i + windowSize);
|
|
46
|
+
|
|
47
|
+
const worksDayVars: string[] = [];
|
|
48
|
+
for (const day of windowDays) {
|
|
49
|
+
const worksVar = b.boolVar(`works_${emp.id}_${day}`);
|
|
50
|
+
worksDayVars.push(worksVar);
|
|
51
|
+
|
|
52
|
+
const dayAssignments = b.shiftPatterns
|
|
53
|
+
.filter((p) => b.canAssign(emp, p) && b.patternAvailableOnDay(p, day))
|
|
54
|
+
.map((p) => b.assignment(emp.id, p.id, day));
|
|
55
|
+
|
|
56
|
+
if (dayAssignments.length === 0) {
|
|
57
|
+
b.addLinear([{ var: worksVar, coeff: 1 }], "==", 0);
|
|
58
|
+
} else {
|
|
59
|
+
for (const assignVar of dayAssignments) {
|
|
60
|
+
b.addLinear(
|
|
61
|
+
[
|
|
62
|
+
{ var: worksVar, coeff: 1 },
|
|
63
|
+
{ var: assignVar, coeff: -1 },
|
|
64
|
+
],
|
|
65
|
+
">=",
|
|
66
|
+
0,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
b.addLinear(
|
|
70
|
+
[
|
|
71
|
+
{ var: worksVar, coeff: 1 },
|
|
72
|
+
...dayAssignments.map((v) => ({ var: v, coeff: -1 })),
|
|
73
|
+
],
|
|
74
|
+
"<=",
|
|
75
|
+
0,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (priority === "MANDATORY") {
|
|
81
|
+
b.addLinear(
|
|
82
|
+
worksDayVars.map((v) => ({ var: v, coeff: 1 })),
|
|
83
|
+
"<=",
|
|
84
|
+
days,
|
|
85
|
+
);
|
|
86
|
+
} else {
|
|
87
|
+
b.addSoftLinear(
|
|
88
|
+
worksDayVars.map((v) => ({ var: v, coeff: 1 })),
|
|
89
|
+
"<=",
|
|
90
|
+
days,
|
|
91
|
+
priorityToPenalty(priority),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|