skilldrop-cli 1.6.0 → 1.7.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/README.md +2 -0
- package/catalogue.json +18 -2
- package/model-routing.json +16 -0
- package/package.json +1 -1
- package/packs/event-planner/pack.json +61 -0
- package/packs/event-planner/skills/run-of-show/SKILL.md +141 -0
- package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
- package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
- package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
- package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
- package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
- package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
- package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
- package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
- package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
- package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
- package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
- package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
- package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
- package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
- package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
- package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
- package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
- package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
- package/packs/event-planner/skills/seating-plan/SKILL.md +152 -0
- package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
- package/packs/event-planner/skills/seating-plan/evals/evals.json +134 -0
- package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
- package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
- package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
- package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
- package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
- package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
- package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
- package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
- package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
- package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
- package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
- package/packs/experience-design/pack.json +5 -0
- package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
- package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
- package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
- package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
- package/packs/experience-design/skills/locale-check/manifest.json +45 -0
- package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
- package/packs/operations-manager/pack.json +60 -0
- package/packs/operations-manager/skills/shift-roster/SKILL.md +166 -0
- package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
- package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
- package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
- package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
- package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
- package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
- package/packs/operations-manager/skills/shift-roster/templates/roster.json +22 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check a staff rota against its shifts' needs and the working-time rules, and draw the week. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads a roster.json: the staff (skills, weekly maximum and contracted hours, times they
|
|
5
|
+
can't work), the shifts (day or date, start and end, overnight allowed, and how many of each
|
|
6
|
+
skill they need), who is assigned to each shift, and the rules (minimum rest between
|
|
7
|
+
shifts, maximum consecutive days, maximum hours in a day). It fails a rota where
|
|
8
|
+
- a shift is short of a skill it needs (people are matched to needs, one need each)
|
|
9
|
+
- someone is on two shifts that overlap
|
|
10
|
+
- someone works a shift during a time they marked unavailable
|
|
11
|
+
- someone goes over their weekly maximum hours, or the daily maximum
|
|
12
|
+
- someone gets less than the minimum rest between two shifts, across midnight and days
|
|
13
|
+
- someone works more consecutive days than the limit
|
|
14
|
+
- someone fills a role they don't have the skill for, or has none of the skills a shift needs
|
|
15
|
+
and warns where
|
|
16
|
+
- someone's hours are well off their contracted hours
|
|
17
|
+
- weekend or night shifts fall unevenly across the people working that period
|
|
18
|
+
- a shift is short while qualified people are free to take it (they are named)
|
|
19
|
+
Then it can draw the week: a grid of staff rows by day columns, each shift as a block with
|
|
20
|
+
its times and role, failures marked in red, and a coverage row per day.
|
|
21
|
+
|
|
22
|
+
With --summary it also prints, from the rota as checked, each person's shifts, hours, nights,
|
|
23
|
+
weekend days and longest run of days, and each shift's cover against its needs. With
|
|
24
|
+
--compare ORIGINAL.json it lists every assignment the rota adds or removes against the
|
|
25
|
+
original, shift by shift.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
python3 roster_check.py roster.json [--svg week.svg] [--json] [--summary] [--compare original.json]
|
|
29
|
+
Exit codes: 0 no failures, 1 a failure, 2 bad input.
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import datetime as dt
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
37
|
+
DEFAULT_RULES = {"min_rest_hours": 11, "max_consecutive_days": 6, "max_hours_per_day": 12}
|
|
38
|
+
ANY = "any"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InputError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hhmm(s, what):
|
|
46
|
+
if not isinstance(s, str) or ":" not in s:
|
|
47
|
+
raise InputError(f"{what} must be a time like 07:30")
|
|
48
|
+
h, _, m = s.partition(":")
|
|
49
|
+
if not (h.isdigit() and m.isdigit()):
|
|
50
|
+
raise InputError(f"{what} must be a time like 07:30")
|
|
51
|
+
h, m = int(h), int(m)
|
|
52
|
+
if h == 24 and m == 0:
|
|
53
|
+
return 1440
|
|
54
|
+
if not (0 <= h < 24 and 0 <= m < 60):
|
|
55
|
+
raise InputError(f"{what} is not a time of day: {s}")
|
|
56
|
+
return h * 60 + m
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fmt(mins):
|
|
60
|
+
mins %= 1440
|
|
61
|
+
return "%02d:%02d" % (mins // 60, mins % 60)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def num(v, what, lo=0):
|
|
65
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v < lo:
|
|
66
|
+
raise InputError(f"{what} must be a number of at least {lo}")
|
|
67
|
+
return float(v)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Calendar(object):
|
|
71
|
+
"""Turns a day name (with an optional week number) or an ISO date into a day index from
|
|
72
|
+
the roster's first day, so every time is minutes from one origin."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, items):
|
|
75
|
+
dates = [i["date"] for i in items if isinstance(i, dict) and "date" in i]
|
|
76
|
+
days = [i for i in items if isinstance(i, dict) and "day" in i]
|
|
77
|
+
if dates and days:
|
|
78
|
+
raise InputError("use either a day name or a date for every shift and absence, not both")
|
|
79
|
+
self.by_date = bool(dates)
|
|
80
|
+
if self.by_date:
|
|
81
|
+
try:
|
|
82
|
+
self.origin = min(dt.date.fromisoformat(d) for d in dates)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
raise InputError("a date must look like 2026-11-09")
|
|
85
|
+
self.origin -= dt.timedelta(days=self.origin.weekday()) # start the grid on a Monday
|
|
86
|
+
|
|
87
|
+
def index(self, item, what):
|
|
88
|
+
if self.by_date:
|
|
89
|
+
if "date" not in item:
|
|
90
|
+
raise InputError(f"{what} needs a date, like the rest of the roster")
|
|
91
|
+
try:
|
|
92
|
+
d = dt.date.fromisoformat(item["date"])
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
raise InputError(f"{what}: a date must look like 2026-11-09")
|
|
95
|
+
return (d - self.origin).days
|
|
96
|
+
day = item.get("day")
|
|
97
|
+
if day not in DAYS:
|
|
98
|
+
raise InputError(f"{what}: day must be one of {', '.join(DAYS)}")
|
|
99
|
+
week = item.get("week", 1)
|
|
100
|
+
if isinstance(week, bool) or not isinstance(week, int) or week < 1:
|
|
101
|
+
raise InputError(f"{what}: week must be 1, 2, ...")
|
|
102
|
+
return (week - 1) * 7 + DAYS.index(day)
|
|
103
|
+
|
|
104
|
+
def label(self, idx):
|
|
105
|
+
if self.by_date:
|
|
106
|
+
return (self.origin + dt.timedelta(days=idx)).strftime("%a %d %b")
|
|
107
|
+
return DAYS[idx % 7] + (f" wk{idx // 7 + 1}" if idx >= 7 else "")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load(doc):
|
|
111
|
+
if not isinstance(doc, dict):
|
|
112
|
+
raise InputError("the roster must be a JSON object")
|
|
113
|
+
staff_in, shifts_in = doc.get("staff"), doc.get("shifts")
|
|
114
|
+
if not isinstance(staff_in, list) or not staff_in:
|
|
115
|
+
raise InputError("no staff: the roster needs a staff list")
|
|
116
|
+
if not isinstance(shifts_in, list) or not shifts_in:
|
|
117
|
+
raise InputError("no shifts: the roster needs a shifts list")
|
|
118
|
+
rules = dict(DEFAULT_RULES)
|
|
119
|
+
given = doc.get("rules") or {}
|
|
120
|
+
if not isinstance(given, dict):
|
|
121
|
+
raise InputError("rules must be an object")
|
|
122
|
+
for k, v in given.items():
|
|
123
|
+
if k not in DEFAULT_RULES:
|
|
124
|
+
raise InputError(f"unknown rule {k!r}; known rules are {', '.join(DEFAULT_RULES)}")
|
|
125
|
+
rules[k] = num(v, f"rules.{k}")
|
|
126
|
+
for i, p in enumerate(staff_in):
|
|
127
|
+
if isinstance(p, dict) and not isinstance(p.get("unavailable", []), list):
|
|
128
|
+
raise InputError(f"staff[{i}].unavailable must be a list")
|
|
129
|
+
absences = [a for p in staff_in if isinstance(p, dict) for a in (p.get("unavailable") or []) if isinstance(a, dict)]
|
|
130
|
+
cal = Calendar([s for s in shifts_in if isinstance(s, dict)] + absences)
|
|
131
|
+
|
|
132
|
+
staff, order = {}, []
|
|
133
|
+
for i, p in enumerate(staff_in):
|
|
134
|
+
if not isinstance(p, dict) or not isinstance(p.get("id"), str) or not p["id"]:
|
|
135
|
+
raise InputError(f"staff[{i}] needs an id")
|
|
136
|
+
sid = p["id"]
|
|
137
|
+
if sid in staff:
|
|
138
|
+
raise InputError(f"staff id {sid!r} appears twice")
|
|
139
|
+
skills = p.get("skills", [])
|
|
140
|
+
if not isinstance(skills, list) or not all(isinstance(s, str) for s in skills):
|
|
141
|
+
raise InputError(f"{sid}: skills must be a list of names")
|
|
142
|
+
away = []
|
|
143
|
+
for j, a in enumerate(p.get("unavailable") or []):
|
|
144
|
+
if not isinstance(a, dict):
|
|
145
|
+
raise InputError(f"{sid}: unavailable[{j}] must be an object")
|
|
146
|
+
d = cal.index(a, f"{sid} unavailable[{j}]")
|
|
147
|
+
st = hhmm(a["start"], f"{sid} unavailable[{j}].start") if "start" in a else 0
|
|
148
|
+
en = hhmm(a["end"], f"{sid} unavailable[{j}].end") if "end" in a else 1440
|
|
149
|
+
if en <= st:
|
|
150
|
+
en += 1440
|
|
151
|
+
away.append((d * 1440 + st, d * 1440 + en, a.get("reason", "")))
|
|
152
|
+
staff[sid] = {"id": sid, "name": str(p.get("name") or sid), "skills": set(skills),
|
|
153
|
+
"max_hours": num(p["max_hours"], f"{sid}.max_hours") if "max_hours" in p else None,
|
|
154
|
+
"contracted": num(p["contracted_hours"], f"{sid}.contracted_hours") if "contracted_hours" in p else None,
|
|
155
|
+
"away": away}
|
|
156
|
+
order.append(sid)
|
|
157
|
+
|
|
158
|
+
shifts, sorder = {}, []
|
|
159
|
+
for i, s in enumerate(shifts_in):
|
|
160
|
+
if not isinstance(s, dict) or not isinstance(s.get("id"), str) or not s["id"]:
|
|
161
|
+
raise InputError(f"shifts[{i}] needs an id")
|
|
162
|
+
hid = s["id"]
|
|
163
|
+
if hid in shifts:
|
|
164
|
+
raise InputError(f"shift id {hid!r} appears twice")
|
|
165
|
+
d = cal.index(s, f"shift {hid}")
|
|
166
|
+
st, en = hhmm(s.get("start"), f"{hid}.start"), hhmm(s.get("end"), f"{hid}.end")
|
|
167
|
+
if en <= st:
|
|
168
|
+
en += 1440 # overnight
|
|
169
|
+
needs = s.get("needs") or {}
|
|
170
|
+
if not isinstance(needs, dict):
|
|
171
|
+
raise InputError(f"{hid}: needs must map a skill to a count")
|
|
172
|
+
if "label" in s and not isinstance(s["label"], str):
|
|
173
|
+
raise InputError(f"{hid}: label must be text")
|
|
174
|
+
clean = {}
|
|
175
|
+
for k, v in needs.items():
|
|
176
|
+
if isinstance(v, bool) or not isinstance(v, int) or v < 0:
|
|
177
|
+
raise InputError(f"{hid}: needs.{k} must be a whole number")
|
|
178
|
+
if v:
|
|
179
|
+
clean[k] = v
|
|
180
|
+
shifts[hid] = {"id": hid, "day": d, "start": d * 1440 + st, "end": d * 1440 + en,
|
|
181
|
+
"needs": clean, "label": s.get("label") or hid, "assigned": []}
|
|
182
|
+
sorder.append(hid)
|
|
183
|
+
|
|
184
|
+
assign = doc.get("assignments", {})
|
|
185
|
+
pairs = []
|
|
186
|
+
if assign is None:
|
|
187
|
+
assign = {}
|
|
188
|
+
if isinstance(assign, dict):
|
|
189
|
+
pairs = list(assign.items())
|
|
190
|
+
elif isinstance(assign, list):
|
|
191
|
+
for j, a in enumerate(assign):
|
|
192
|
+
if not isinstance(a, dict) or "shift" not in a:
|
|
193
|
+
raise InputError(f"assignments[{j}] needs a shift")
|
|
194
|
+
pairs.append((a["shift"], a.get("staff", [])))
|
|
195
|
+
else:
|
|
196
|
+
raise InputError("assignments must map a shift id to its staff")
|
|
197
|
+
for s in shifts_in:
|
|
198
|
+
if isinstance(s, dict) and s.get("assigned"):
|
|
199
|
+
pairs.append((s["id"], s["assigned"]))
|
|
200
|
+
for hid, people in pairs:
|
|
201
|
+
if not isinstance(hid, str) or hid not in shifts:
|
|
202
|
+
raise InputError(f"assignments name shift {hid!r}, which is not in the shifts list")
|
|
203
|
+
if isinstance(people, (str, dict)):
|
|
204
|
+
people = [people]
|
|
205
|
+
if not isinstance(people, list):
|
|
206
|
+
raise InputError(f"assignments.{hid} must be a list of staff ids")
|
|
207
|
+
for x in people:
|
|
208
|
+
sid, role = (x.get("id"), x.get("as")) if isinstance(x, dict) else (x, None)
|
|
209
|
+
if role is not None and not isinstance(role, str):
|
|
210
|
+
raise InputError(f"shift {hid}: a role given with \"as\" must be a skill name")
|
|
211
|
+
if not isinstance(sid, str) or sid not in staff:
|
|
212
|
+
raise InputError(f"shift {hid} names staff {sid!r}, who is not in the staff list")
|
|
213
|
+
if any(a[0] == sid for a in shifts[hid]["assigned"]):
|
|
214
|
+
raise InputError(f"{sid} is assigned to {hid} twice")
|
|
215
|
+
shifts[hid]["assigned"].append((sid, role))
|
|
216
|
+
return {"rules": rules, "staff": staff, "order": order, "shifts": shifts, "sorder": sorder, "cal": cal,
|
|
217
|
+
"ndays": max(7, 7 * ((max(s["day"] for s in shifts.values()) // 7) + 1))}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def hours(shift):
|
|
221
|
+
return (shift["end"] - shift["start"]) / 60.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cover(shift, staff):
|
|
225
|
+
"""Match assigned people to the shift's needs, one need each, preferring the role a
|
|
226
|
+
person was assigned as. Returns (filled per skill, short per skill, {sid: skill or None},
|
|
227
|
+
[role errors])."""
|
|
228
|
+
slots = [k for k, n in sorted(shift["needs"].items()) for _ in range(n)]
|
|
229
|
+
people = shift["assigned"]
|
|
230
|
+
errors = []
|
|
231
|
+
|
|
232
|
+
def can(sid, role, skill):
|
|
233
|
+
if role is not None:
|
|
234
|
+
return role == skill
|
|
235
|
+
return skill == ANY or skill in staff[sid]["skills"]
|
|
236
|
+
|
|
237
|
+
for sid, role in people:
|
|
238
|
+
if role is not None and role != ANY and role not in staff[sid]["skills"]:
|
|
239
|
+
errors.append(f"{staff[sid]['name']} is on {shift['label']} as {role} but doesn't have that skill")
|
|
240
|
+
match = {} # slot index -> person index
|
|
241
|
+
|
|
242
|
+
def augment(pi, seen):
|
|
243
|
+
sid, role = people[pi]
|
|
244
|
+
if role is not None and role != ANY and role not in staff[sid]["skills"]:
|
|
245
|
+
return False
|
|
246
|
+
for si, skill in enumerate(slots):
|
|
247
|
+
if si in seen or not can(sid, role, skill):
|
|
248
|
+
continue
|
|
249
|
+
seen.add(si)
|
|
250
|
+
if si not in match or augment(match[si], seen):
|
|
251
|
+
match[si] = pi
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
for pi in range(len(people)):
|
|
256
|
+
augment(pi, set())
|
|
257
|
+
filled, short = {}, {}
|
|
258
|
+
for si, skill in enumerate(slots):
|
|
259
|
+
if si in match:
|
|
260
|
+
filled[skill] = filled.get(skill, 0) + 1
|
|
261
|
+
else:
|
|
262
|
+
short[skill] = short.get(skill, 0) + 1
|
|
263
|
+
role_of = {people[pi][0]: slots[si] for si, pi in match.items()}
|
|
264
|
+
for sid, role in people:
|
|
265
|
+
if sid not in role_of and role is None and shift["needs"] and not (
|
|
266
|
+
staff[sid]["skills"] & set(shift["needs"]) or ANY in shift["needs"]):
|
|
267
|
+
errors.append(f"{staff[sid]['name']} is on {shift['label']} but has none of the skills it needs "
|
|
268
|
+
f"({', '.join(sorted(shift['needs']))})")
|
|
269
|
+
role_of.setdefault(sid, None)
|
|
270
|
+
return filled, short, role_of, errors
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def overlaps(a0, a1, b0, b1):
|
|
274
|
+
return a0 < b1 and b0 < a1
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def is_night(shift):
|
|
278
|
+
return shift["end"] - shift["day"] * 1440 > 1440 or (shift["start"] % 1440) >= 20 * 60
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def is_weekend(shift):
|
|
282
|
+
return shift["day"] % 7 in (5, 6)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def check(r):
|
|
286
|
+
staff, shifts, rules = r["staff"], r["shifts"], r["rules"]
|
|
287
|
+
fails, warns = [], []
|
|
288
|
+
bad_shift, bad_cell = set(), set() # shift ids short; (sid, shift id) involved in a failure
|
|
289
|
+
roles, coverage = {}, {}
|
|
290
|
+
for hid in r["sorder"]:
|
|
291
|
+
sh = shifts[hid]
|
|
292
|
+
filled, short, role_of, errs = cover(sh, staff)
|
|
293
|
+
roles[hid] = role_of
|
|
294
|
+
coverage[hid] = {"needs": sh["needs"], "filled": filled, "short": short}
|
|
295
|
+
for e in errs:
|
|
296
|
+
fails.append(e)
|
|
297
|
+
for sid, _ in sh["assigned"]:
|
|
298
|
+
if staff[sid]["name"] in e:
|
|
299
|
+
bad_cell.add((sid, hid))
|
|
300
|
+
if short:
|
|
301
|
+
bad_shift.add(hid)
|
|
302
|
+
fails.append(f"{sh['label']} ({r['cal'].label(sh['day'])} {fmt(sh['start'])}–{fmt(sh['end'])}) is short "
|
|
303
|
+
+ ", ".join(f"{n} {k}" for k, n in sorted(short.items())))
|
|
304
|
+
|
|
305
|
+
worked = {sid: sorted((shifts[h] for h in r["sorder"] if any(a[0] == sid for a in shifts[h]["assigned"])),
|
|
306
|
+
key=lambda s: s["start"]) for sid in r["order"]}
|
|
307
|
+
per = {}
|
|
308
|
+
for sid in r["order"]:
|
|
309
|
+
p, mine = staff[sid], worked[sid]
|
|
310
|
+
name = p["name"]
|
|
311
|
+
for a, b in zip(mine, mine[1:]):
|
|
312
|
+
if overlaps(a["start"], a["end"], b["start"], b["end"]):
|
|
313
|
+
fails.append(f"{name} is on {a['label']} and {b['label']}, which overlap")
|
|
314
|
+
bad_cell |= {(sid, a["id"]), (sid, b["id"])}
|
|
315
|
+
else:
|
|
316
|
+
rest = (b["start"] - a["end"]) / 60.0
|
|
317
|
+
if rest < rules["min_rest_hours"]:
|
|
318
|
+
fails.append(f"{name} gets {rest:g} h rest between {a['label']} (ends {r['cal'].label(a['end'] // 1440)} "
|
|
319
|
+
f"{fmt(a['end'])}) and {b['label']}, under the {rules['min_rest_hours']:g} h minimum")
|
|
320
|
+
bad_cell |= {(sid, a["id"]), (sid, b["id"])}
|
|
321
|
+
for sh in mine:
|
|
322
|
+
for a0, a1, why in p["away"]:
|
|
323
|
+
if overlaps(sh["start"], sh["end"], a0, a1):
|
|
324
|
+
fails.append(f"{name} is on {sh['label']} but is unavailable then" + (f" ({why})" if why else ""))
|
|
325
|
+
bad_cell.add((sid, sh["id"]))
|
|
326
|
+
break
|
|
327
|
+
weeks = {}
|
|
328
|
+
for sh in mine:
|
|
329
|
+
weeks.setdefault(sh["day"] // 7, []).append(sh)
|
|
330
|
+
for w, lst in sorted(weeks.items()):
|
|
331
|
+
total = sum(hours(s) for s in lst)
|
|
332
|
+
if p["max_hours"] is not None and total > p["max_hours"] + 1e-9:
|
|
333
|
+
fails.append(f"{name} works {total:g} h in week {w + 1}, over their {p['max_hours']:g} h maximum")
|
|
334
|
+
bad_cell |= {(sid, s["id"]) for s in lst}
|
|
335
|
+
days = {}
|
|
336
|
+
for sh in mine:
|
|
337
|
+
days.setdefault(sh["day"], []).append(sh)
|
|
338
|
+
for d, lst in sorted(days.items()):
|
|
339
|
+
total = sum(hours(s) for s in lst)
|
|
340
|
+
if total > rules["max_hours_per_day"] + 1e-9:
|
|
341
|
+
fails.append(f"{name} works {total:g} h on {r['cal'].label(d)}, over the {rules['max_hours_per_day']:g} h daily maximum")
|
|
342
|
+
bad_cell |= {(sid, s["id"]) for s in lst}
|
|
343
|
+
run, best, prev, run_days = 0, (0, []), None, []
|
|
344
|
+
for d in sorted(days):
|
|
345
|
+
run = run + 1 if prev is not None and d == prev + 1 else 1
|
|
346
|
+
run_days = run_days + [d] if run > 1 else [d]
|
|
347
|
+
if run > best[0]:
|
|
348
|
+
best = (run, list(run_days))
|
|
349
|
+
prev = d
|
|
350
|
+
if best[0] > rules["max_consecutive_days"]:
|
|
351
|
+
fails.append(f"{name} works {best[0]} days in a row ({r['cal'].label(best[1][0])} to {r['cal'].label(best[1][-1])}), "
|
|
352
|
+
f"over the {rules['max_consecutive_days']:g}-day limit")
|
|
353
|
+
bad_cell |= {(sid, s["id"]) for d in best[1] for s in days[d]}
|
|
354
|
+
total = sum(hours(s) for s in mine)
|
|
355
|
+
nweeks = max(1, r["ndays"] // 7)
|
|
356
|
+
per[sid] = {"name": name, "hours": total, "shifts": len(mine),
|
|
357
|
+
"weekend": sum(1 for s in mine if is_weekend(s)), "night": sum(1 for s in mine if is_night(s)),
|
|
358
|
+
"weekend_days": len({s["day"] for s in mine if is_weekend(s)}),
|
|
359
|
+
"days_in_a_row": best[0], "worked": [s["id"] for s in mine],
|
|
360
|
+
"contracted": p["contracted"], "max": p["max_hours"]}
|
|
361
|
+
if p["contracted"] is not None:
|
|
362
|
+
want = p["contracted"] * nweeks
|
|
363
|
+
if abs(total - want) > max(4.0, 0.2 * want):
|
|
364
|
+
warns.append(f"{name} works {total:g} h against {want:g} h contracted")
|
|
365
|
+
|
|
366
|
+
for kind, test in (("weekend", is_weekend), ("night", is_night)):
|
|
367
|
+
if not any(test(s) for s in shifts.values()):
|
|
368
|
+
continue
|
|
369
|
+
# among the people who could work those shifts: anyone with a skill one of them needs
|
|
370
|
+
needed = {k for s in shifts.values() if test(s) for k in s["needs"]}
|
|
371
|
+
pool = [sid for sid in r["order"] if ANY in needed or staff[sid]["skills"] & needed]
|
|
372
|
+
counts = {sid: per[sid][kind] for sid in pool}
|
|
373
|
+
if len(counts) > 1 and max(counts.values()) - min(counts.values()) >= 2:
|
|
374
|
+
hi = max(counts, key=counts.get)
|
|
375
|
+
lo = min(counts, key=counts.get)
|
|
376
|
+
warns.append(f"{kind} shifts fall unevenly: {staff[hi]['name']} has {counts[hi]}, "
|
|
377
|
+
f"{staff[lo]['name']} has {counts[lo]}")
|
|
378
|
+
|
|
379
|
+
for hid in r["sorder"]:
|
|
380
|
+
short = coverage[hid]["short"]
|
|
381
|
+
if not short:
|
|
382
|
+
continue
|
|
383
|
+
sh = shifts[hid]
|
|
384
|
+
for skill in sorted(short):
|
|
385
|
+
free = []
|
|
386
|
+
for sid in r["order"]:
|
|
387
|
+
p = staff[sid]
|
|
388
|
+
if any(a[0] == sid for a in sh["assigned"]):
|
|
389
|
+
continue
|
|
390
|
+
if skill != ANY and skill not in p["skills"]:
|
|
391
|
+
continue
|
|
392
|
+
if any(overlaps(sh["start"], sh["end"], a0, a1) for a0, a1, _ in p["away"]):
|
|
393
|
+
continue
|
|
394
|
+
clash = False
|
|
395
|
+
for o in worked[sid]:
|
|
396
|
+
gap = max(o["start"] - sh["end"], sh["start"] - o["end"]) / 60.0
|
|
397
|
+
if overlaps(o["start"], o["end"], sh["start"], sh["end"]) or gap < rules["min_rest_hours"]:
|
|
398
|
+
clash = True
|
|
399
|
+
break
|
|
400
|
+
week = sum(hours(o) for o in worked[sid] if o["day"] // 7 == sh["day"] // 7)
|
|
401
|
+
if clash or (p["max_hours"] is not None and week + hours(sh) > p["max_hours"] + 1e-9):
|
|
402
|
+
continue
|
|
403
|
+
free.append(p["name"])
|
|
404
|
+
if free:
|
|
405
|
+
warns.append(f"{sh['label']} is short {short[skill]} {skill}, and qualified people are free to take it: "
|
|
406
|
+
+ ", ".join(free))
|
|
407
|
+
return {"fails": fails, "warns": warns, "coverage": coverage, "people": per, "roles": roles,
|
|
408
|
+
"bad_shift": bad_shift, "bad_cell": bad_cell}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def report(r, res):
|
|
412
|
+
out = []
|
|
413
|
+
sh, cal = r["shifts"], r["cal"]
|
|
414
|
+
out.append("Coverage")
|
|
415
|
+
for hid in r["sorder"]:
|
|
416
|
+
s, c = sh[hid], res["coverage"][hid]
|
|
417
|
+
need = ", ".join(f"{k} {c['filled'].get(k, 0)}/{n}" for k, n in sorted(c["needs"].items())) or "no stated needs"
|
|
418
|
+
who = ", ".join(r["staff"][sid]["name"] for sid, _ in s["assigned"]) or "nobody"
|
|
419
|
+
out.append(f" {'SHORT' if c['short'] else 'ok '} {s['label']:<14} {cal.label(s['day']):<10} "
|
|
420
|
+
f"{fmt(s['start'])}–{fmt(s['end'])} {need} [{who}]")
|
|
421
|
+
out.append("")
|
|
422
|
+
out.append("Hours")
|
|
423
|
+
for sid in r["order"]:
|
|
424
|
+
p = res["people"][sid]
|
|
425
|
+
extra = []
|
|
426
|
+
if p["contracted"] is not None:
|
|
427
|
+
extra.append(f"contracted {p['contracted']:g}")
|
|
428
|
+
if p["max"] is not None:
|
|
429
|
+
extra.append(f"max {p['max']:g}")
|
|
430
|
+
out.append(f" {p['name']:<16} {p['hours']:>5g} h {p['shifts']} shifts, {p['weekend']} weekend, "
|
|
431
|
+
f"{p['night']} night" + (f" ({', '.join(extra)})" if extra else ""))
|
|
432
|
+
out.append("")
|
|
433
|
+
for f in res["fails"]:
|
|
434
|
+
out.append("FAIL " + f)
|
|
435
|
+
for w in res["warns"]:
|
|
436
|
+
out.append("WARN " + w)
|
|
437
|
+
out.append(f"{len(res['fails'])} failure(s), {len(res['warns'])} warning(s).")
|
|
438
|
+
return "\n".join(out) + "\n"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def shift_text(r, sh, sid=None, res=None):
|
|
442
|
+
role = res["roles"].get(sh["id"], {}).get(sid) if (res and sid) else None
|
|
443
|
+
return (f"{sh['label']} {r['cal'].label(sh['day'])} {fmt(sh['start'])}–{fmt(sh['end'])}"
|
|
444
|
+
+ (f" as {role}" if role and role != ANY else ""))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def summary(r, res):
|
|
448
|
+
"""What the final rota says, per person and per shift: the facts a reply quotes, so its
|
|
449
|
+
notes can't drift from the schedule."""
|
|
450
|
+
people = []
|
|
451
|
+
for sid in r["order"]:
|
|
452
|
+
p = res["people"][sid]
|
|
453
|
+
people.append({"id": sid, "name": p["name"], "hours": p["hours"], "shifts": p["shifts"],
|
|
454
|
+
"nights": p["night"], "weekend_days": p["weekend_days"],
|
|
455
|
+
"days_in_a_row": p["days_in_a_row"], "contracted": p["contracted"], "max": p["max"],
|
|
456
|
+
"on": [shift_text(r, r["shifts"][h], sid, res) for h in p["worked"]]})
|
|
457
|
+
shifts = []
|
|
458
|
+
for hid in r["sorder"]:
|
|
459
|
+
sh, c = r["shifts"][hid], res["coverage"][hid]
|
|
460
|
+
shifts.append({"id": hid, "shift": shift_text(r, sh),
|
|
461
|
+
"cover": {k: [c["filled"].get(k, 0), n] for k, n in sorted(c["needs"].items())},
|
|
462
|
+
"short": c["short"], "who": [r["staff"][sid]["name"] for sid, _ in sh["assigned"]]})
|
|
463
|
+
return {"people": people, "shifts": shifts}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def summary_text(sm):
|
|
467
|
+
out = ["Per person"]
|
|
468
|
+
for p in sm["people"]:
|
|
469
|
+
bits = [f"{p['hours']:g} h", f"{p['shifts']} shifts", f"{p['nights']} nights",
|
|
470
|
+
f"{p['weekend_days']} weekend days", f"{p['days_in_a_row']} days in a row at most"]
|
|
471
|
+
if p["contracted"] is not None:
|
|
472
|
+
bits.append(f"contracted {p['contracted']:g} h")
|
|
473
|
+
out.append(f" {p['name']}: " + ", ".join(bits))
|
|
474
|
+
for t in p["on"]:
|
|
475
|
+
out.append(f" {t}")
|
|
476
|
+
out.append("")
|
|
477
|
+
out.append("Per shift")
|
|
478
|
+
for s in sm["shifts"]:
|
|
479
|
+
cover = ", ".join(f"{k} {a}/{b}" for k, (a, b) in s["cover"].items()) or "no stated needs"
|
|
480
|
+
out.append(f" {'SHORT' if s['short'] else 'ok '} {s['shift']}: {cover} [{', '.join(s['who']) or 'nobody'}]")
|
|
481
|
+
return "\n".join(out) + "\n"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def compare(r, orig):
|
|
485
|
+
"""Every assignment the final rota adds or removes against the original, by shift."""
|
|
486
|
+
changes = []
|
|
487
|
+
for hid in r["sorder"] + [h for h in orig["sorder"] if h not in r["shifts"]]:
|
|
488
|
+
now = {sid for sid, _ in r["shifts"][hid]["assigned"]} if hid in r["shifts"] else set()
|
|
489
|
+
was = {sid for sid, _ in orig["shifts"][hid]["assigned"]} if hid in orig["shifts"] else set()
|
|
490
|
+
if now == was:
|
|
491
|
+
continue
|
|
492
|
+
sh = r["shifts"].get(hid) or orig["shifts"][hid]
|
|
493
|
+
name = lambda sid: (r["staff"].get(sid) or orig["staff"].get(sid) or {"name": sid})["name"]
|
|
494
|
+
changes.append({"shift": hid, "when": shift_text(r if hid in r["shifts"] else orig, sh),
|
|
495
|
+
"added": sorted(name(s) for s in now - was), "removed": sorted(name(s) for s in was - now)})
|
|
496
|
+
return changes
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def compare_text(changes):
|
|
500
|
+
if not changes:
|
|
501
|
+
return "No assignment changed against the original.\n"
|
|
502
|
+
out = [f"{len(changes)} shift(s) changed against the original"]
|
|
503
|
+
for c in changes:
|
|
504
|
+
bits = ([f"added {', '.join(c['added'])}"] if c["added"] else []) + \
|
|
505
|
+
([f"removed {', '.join(c['removed'])}"] if c["removed"] else [])
|
|
506
|
+
out.append(f" {c['when']}: " + "; ".join(bits))
|
|
507
|
+
return "\n".join(out) + "\n"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def esc(s):
|
|
511
|
+
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def svg(r, res):
|
|
515
|
+
cal, sh, staff = r["cal"], r["shifts"], r["staff"]
|
|
516
|
+
ndays = r["ndays"]
|
|
517
|
+
name_w, col_w, head_h, blk_h, pad = 150, 112, 76, 30, 6
|
|
518
|
+
cells = {}
|
|
519
|
+
for hid in r["sorder"]:
|
|
520
|
+
for sid, _ in sh[hid]["assigned"]:
|
|
521
|
+
cells.setdefault((sid, sh[hid]["day"]), []).append(hid)
|
|
522
|
+
row_h = {sid: max([len(v) for (s, _), v in cells.items() if s == sid] + [1]) * (blk_h + 4) + 2 * pad
|
|
523
|
+
for sid in r["order"]}
|
|
524
|
+
per_day = {}
|
|
525
|
+
for hid in r["sorder"]:
|
|
526
|
+
per_day.setdefault(sh[hid]["day"], []).append(hid)
|
|
527
|
+
cov_h = max([len(v) for v in per_day.values()] + [1]) * 18 + 2 * pad + 18
|
|
528
|
+
width = name_w + ndays * col_w + 20
|
|
529
|
+
height = head_h + sum(row_h.values()) + cov_h + 40
|
|
530
|
+
o = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
|
|
531
|
+
f'font-family="Helvetica, Arial, sans-serif" font-size="11">',
|
|
532
|
+
f'<rect width="{width}" height="{height}" fill="#ffffff"/>',
|
|
533
|
+
f'<text x="10" y="22" font-size="15" font-weight="bold">Rota</text>',
|
|
534
|
+
f'<text x="10" y="40" fill="#555">{len(res["fails"])} failure(s), {len(res["warns"])} warning(s). '
|
|
535
|
+
f'Red marks a shift or a person the check failed.</text>']
|
|
536
|
+
for d in range(ndays):
|
|
537
|
+
x = name_w + d * col_w
|
|
538
|
+
fill = "#f4f4f6" if d % 7 in (5, 6) else "#ffffff"
|
|
539
|
+
o.append(f'<rect x="{x}" y="{head_h - 16}" width="{col_w}" height="{height - head_h - 24}" fill="{fill}" stroke="#ddd"/>')
|
|
540
|
+
o.append(f'<text x="{x + col_w / 2}" y="{head_h - 4}" text-anchor="middle" font-weight="bold">{esc(cal.label(d))}</text>')
|
|
541
|
+
y = head_h
|
|
542
|
+
for sid in r["order"]:
|
|
543
|
+
h = row_h[sid]
|
|
544
|
+
p = res["people"][sid]
|
|
545
|
+
o.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="#ccc"/>')
|
|
546
|
+
o.append(f'<text x="10" y="{y + 18}" font-weight="bold">{esc(p["name"])}</text>')
|
|
547
|
+
o.append(f'<text x="10" y="{y + 32}" fill="#555">{p["hours"]:g} h'
|
|
548
|
+
+ (f' / {p["contracted"]:g} contracted' if p["contracted"] is not None else "") + '</text>')
|
|
549
|
+
for (s2, d), hids in sorted(cells.items()):
|
|
550
|
+
if s2 != sid:
|
|
551
|
+
continue
|
|
552
|
+
for k, hid in enumerate(hids):
|
|
553
|
+
s = sh[hid]
|
|
554
|
+
bx, by = name_w + d * col_w + 4, y + pad + k * (blk_h + 4)
|
|
555
|
+
bad = (sid, hid) in res["bad_cell"]
|
|
556
|
+
role = res["roles"][hid].get(sid)
|
|
557
|
+
o.append(f'<rect x="{bx}" y="{by}" width="{col_w - 8}" height="{blk_h}" rx="4" '
|
|
558
|
+
f'fill="{"#fde2e2" if bad else "#e6f0fb"}" stroke="{"#c62828" if bad else "#4a7fc1"}" '
|
|
559
|
+
f'stroke-width="{2 if bad else 1}"/>')
|
|
560
|
+
o.append(f'<text x="{bx + 5}" y="{by + 12}">{fmt(s["start"])}–{fmt(s["end"])}'
|
|
561
|
+
f'{" +1" if s["end"] - s["day"] * 1440 > 1440 else ""}</text>')
|
|
562
|
+
both = f"{role} · {s['label']}" if role else s["label"]
|
|
563
|
+
line = both if len(both) <= 17 else (role or s["label"])
|
|
564
|
+
o.append(f'<text x="{bx + 5}" y="{by + 25}" fill="#555">{esc(line[:17])}</text>')
|
|
565
|
+
y += h
|
|
566
|
+
o.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="#999"/>')
|
|
567
|
+
o.append(f'<text x="10" y="{y + 18}" font-weight="bold">Coverage</text>')
|
|
568
|
+
for d, hids in per_day.items():
|
|
569
|
+
for k, hid in enumerate(hids):
|
|
570
|
+
c = res["coverage"][hid]
|
|
571
|
+
need, got = sum(c["needs"].values()), sum(c["filled"].values())
|
|
572
|
+
short = bool(c["short"])
|
|
573
|
+
o.append(f'<text x="{name_w + d * col_w + 6}" y="{y + 18 + k * 18}" fill="{"#c62828" if short else "#2e7d32"}" '
|
|
574
|
+
f'font-weight="{"bold" if short else "normal"}">{esc(sh[hid]["label"])[:13]} {got}/{need}</text>')
|
|
575
|
+
o.append("</svg>")
|
|
576
|
+
return "\n".join(o) + "\n"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def jsonable(r, res):
|
|
580
|
+
cal = r["cal"]
|
|
581
|
+
return {"fails": res["fails"], "warns": res["warns"],
|
|
582
|
+
"coverage": {h: dict(res["coverage"][h], day=cal.label(r["shifts"][h]["day"])) for h in r["sorder"]},
|
|
583
|
+
"people": res["people"]}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def main(argv=None):
|
|
587
|
+
ap = argparse.ArgumentParser(description="Check a staff rota against its shifts' needs and the working-time rules.")
|
|
588
|
+
ap.add_argument("roster")
|
|
589
|
+
ap.add_argument("--svg", help="draw the week grid here")
|
|
590
|
+
ap.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
591
|
+
ap.add_argument("--summary", action="store_true",
|
|
592
|
+
help="also print per person (shifts, hours, nights, weekend days, days in a row) and per shift (cover against need)")
|
|
593
|
+
ap.add_argument("--compare", metavar="ORIGINAL", help="also list every assignment added or removed against this original roster")
|
|
594
|
+
args = ap.parse_args(argv)
|
|
595
|
+
paths = [args.roster] + ([args.compare] if args.compare else [])
|
|
596
|
+
loaded = []
|
|
597
|
+
for path in paths:
|
|
598
|
+
try:
|
|
599
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
600
|
+
loaded.append(load(json.load(f)))
|
|
601
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
602
|
+
print(f"FAIL cannot read {path}: {e}")
|
|
603
|
+
return 2
|
|
604
|
+
except InputError as e:
|
|
605
|
+
print(f"FAIL {path}: {e}" if path != args.roster else f"FAIL {e}")
|
|
606
|
+
return 2
|
|
607
|
+
r = loaded[0]
|
|
608
|
+
res = check(r)
|
|
609
|
+
sm = summary(r, res) if args.summary else None
|
|
610
|
+
changes = compare(r, loaded[1]) if args.compare else None
|
|
611
|
+
if args.json:
|
|
612
|
+
out = jsonable(r, res)
|
|
613
|
+
if sm is not None:
|
|
614
|
+
out["summary"] = sm
|
|
615
|
+
if changes is not None:
|
|
616
|
+
out["changes"] = changes
|
|
617
|
+
print(json.dumps(out, indent=1))
|
|
618
|
+
else:
|
|
619
|
+
print(report(r, res), end="")
|
|
620
|
+
if sm is not None:
|
|
621
|
+
print("\n" + summary_text(sm), end="")
|
|
622
|
+
if changes is not None:
|
|
623
|
+
print("\n" + compare_text(changes), end="")
|
|
624
|
+
if args.svg:
|
|
625
|
+
with open(args.svg, "w", encoding="utf-8", newline="\n") as f:
|
|
626
|
+
f.write(svg(r, res))
|
|
627
|
+
if not args.json:
|
|
628
|
+
print(f"\nWrote {args.svg}")
|
|
629
|
+
return 1 if res["fails"] else 0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == "__main__":
|
|
633
|
+
sys.exit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "One week at a small shop. Days are Mon..Sun (add \"week\": 2 for a fortnight), or use \"date\": \"2026-11-09\" everywhere instead. A shift that ends before it starts runs past midnight. needs maps a skill to how many people with it the shift must have; each person fills one need. \"any\" matches everyone. An assignment can be a plain id, or {\"id\": ..., \"as\": skill} to fix the role. rules left out default to 11 h rest, 6 days in a row and 12 h a day.",
|
|
3
|
+
"rules": { "min_rest_hours": 11, "max_consecutive_days": 6, "max_hours_per_day": 12 },
|
|
4
|
+
"staff": [
|
|
5
|
+
{ "id": "amira", "name": "Amira", "skills": ["keyholder", "till"], "max_hours": 40, "contracted_hours": 37.5 },
|
|
6
|
+
{ "id": "ben", "name": "Ben", "skills": ["till"], "max_hours": 30, "contracted_hours": 24,
|
|
7
|
+
"unavailable": [ { "day": "Wed", "reason": "college" } ] },
|
|
8
|
+
{ "id": "chloe", "name": "Chloe", "skills": ["till", "stock"], "max_hours": 40, "contracted_hours": 30,
|
|
9
|
+
"unavailable": [ { "day": "Sat", "start": "16:00", "end": "23:59", "reason": "childcare" } ] },
|
|
10
|
+
{ "id": "dev", "name": "Dev", "skills": ["keyholder", "stock"], "max_hours": 40, "contracted_hours": 37.5 }
|
|
11
|
+
],
|
|
12
|
+
"shifts": [
|
|
13
|
+
{ "id": "mon-early", "day": "Mon", "start": "07:00", "end": "15:00", "needs": { "keyholder": 1, "till": 1 } },
|
|
14
|
+
{ "id": "mon-late", "day": "Mon", "start": "14:00", "end": "22:00", "needs": { "keyholder": 1, "till": 1 } },
|
|
15
|
+
{ "id": "tue-night", "day": "Tue", "start": "22:00", "end": "06:00", "label": "Tue delivery", "needs": { "stock": 2 } }
|
|
16
|
+
],
|
|
17
|
+
"assignments": {
|
|
18
|
+
"mon-early": ["amira", "ben"],
|
|
19
|
+
"mon-late": ["dev", { "id": "chloe", "as": "till" }],
|
|
20
|
+
"tue-night": ["chloe", "dev"]
|
|
21
|
+
}
|
|
22
|
+
}
|