flight-alloc 0.0.1__py3-none-any.whl
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.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
src/staged_overrides.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Apply add_flight / remove_flight / add_staff / remove_staff /
|
|
2
|
+
change_role / sick overrides directly to the in-memory data, so the
|
|
3
|
+
dashboard reflects them BEFORE Allocate is clicked.
|
|
4
|
+
|
|
5
|
+
"Save (no re-run)" lets the assigner stage a change — add a person, add
|
|
6
|
+
a flight, pull someone out — and see the projected state on the
|
|
7
|
+
dashboard, confirming intent before triggering the solver. This module
|
|
8
|
+
is the bridge.
|
|
9
|
+
|
|
10
|
+
Apply rules:
|
|
11
|
+
add_flight → append a CleanFlightRow under the named ops_class.
|
|
12
|
+
remove_flight → drop the matching (flt, std) row from every ops_class.
|
|
13
|
+
add_staff → append a synthetic AvailabilityRow with id ``ADD_NNN``.
|
|
14
|
+
remove_staff → flip the matching staff's ``assignable`` to False
|
|
15
|
+
(same semantics as type=sick).
|
|
16
|
+
change_role → rewrite the staff's role, and their status when the
|
|
17
|
+
new role is ZC.
|
|
18
|
+
change_shift → rewrite an EXISTING staff's shift/status for the day
|
|
19
|
+
(M/A/N/M1/A1, or an off/leave code) — the Roster
|
|
20
|
+
Change dashboard pane. Unlike add_staff/remove_staff
|
|
21
|
+
this doesn't touch role or add a synthetic row.
|
|
22
|
+
ZC list → everyone on the saved Zone Controller list for D
|
|
23
|
+
(``zc_store``, picked on the dashboard) is promoted to
|
|
24
|
+
ZC, and anyone removed there is taken off ZC — even if
|
|
25
|
+
the roster cell still says ``/ZC``.
|
|
26
|
+
|
|
27
|
+
Every call re-applies ALL staged rows from scratch, which is why each
|
|
28
|
+
applier is written to be a no-op when its effect is already present.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from datetime import date as date_t
|
|
35
|
+
from datetime import time as time_t
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from . import zc_store
|
|
39
|
+
from .schemas import (
|
|
40
|
+
NON_ASSIGNABLE,
|
|
41
|
+
STATUS_IS_ZC,
|
|
42
|
+
STATUS_TO_SHIFT,
|
|
43
|
+
AvailabilityRow,
|
|
44
|
+
CleanFlightRow,
|
|
45
|
+
CrewStatus,
|
|
46
|
+
OpsClass,
|
|
47
|
+
OverrideType,
|
|
48
|
+
Role,
|
|
49
|
+
)
|
|
50
|
+
from .state import AppState
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cell(row: Mapping[str, Any], *names: str) -> str:
|
|
54
|
+
"""First non-blank value among ``names``, stripped. '' when none."""
|
|
55
|
+
for n in names:
|
|
56
|
+
v = row.get(n)
|
|
57
|
+
if v is None:
|
|
58
|
+
continue
|
|
59
|
+
s = str(v).strip()
|
|
60
|
+
if s:
|
|
61
|
+
return s
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_time(s: str) -> time_t | None:
|
|
66
|
+
"""Best-effort HH:MM parser. Accepts ``8:30`` / ``08:30`` /
|
|
67
|
+
``08:30:00``. None on failure (caller skips the row)."""
|
|
68
|
+
if not s:
|
|
69
|
+
return None
|
|
70
|
+
parts = s.split(":")
|
|
71
|
+
try:
|
|
72
|
+
h = int(parts[0])
|
|
73
|
+
m = int(parts[1]) if len(parts) > 1 else 0
|
|
74
|
+
return time_t(h, m)
|
|
75
|
+
except (ValueError, IndexError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_date(s: str, fallback: date_t) -> date_t:
|
|
80
|
+
"""Accept ISO (2026-05-28) or MM/DD (05/28, year from fallback)."""
|
|
81
|
+
if not s:
|
|
82
|
+
return fallback
|
|
83
|
+
try:
|
|
84
|
+
return date_t.fromisoformat(s)
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
mm, dd = s.split("/")[:2]
|
|
89
|
+
return date_t(fallback.year, int(mm), int(dd))
|
|
90
|
+
except (ValueError, IndexError):
|
|
91
|
+
return fallback
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _normalize_name(s: Any) -> str:
|
|
95
|
+
"""Collapse internal whitespace + uppercase, so 'AKSHAT CHATURVEDI'
|
|
96
|
+
and 'akshat chaturvedi' resolve to the same key."""
|
|
97
|
+
import re as _re
|
|
98
|
+
return _re.sub(r"\s+", " ", str(s or "")).strip().upper()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def apply_staged_overrides(state: AppState, d_day: date_t) -> dict[str, int]:
|
|
102
|
+
"""Apply every staged override type to the cleaned flights and the
|
|
103
|
+
availability list. Returns {override_type: rows_applied}."""
|
|
104
|
+
counts: dict[str, int] = {
|
|
105
|
+
OverrideType.ADD_FLIGHT.value: 0,
|
|
106
|
+
OverrideType.REMOVE_FLIGHT.value: 0,
|
|
107
|
+
OverrideType.ADD_STAFF.value: 0,
|
|
108
|
+
OverrideType.REMOVE_STAFF.value: 0,
|
|
109
|
+
OverrideType.CHANGE_ROLE.value: 0,
|
|
110
|
+
OverrideType.CHANGE_SHIFT.value: 0,
|
|
111
|
+
"zc_list": 0,
|
|
112
|
+
}
|
|
113
|
+
with state.lock:
|
|
114
|
+
for row in list(state.overrides):
|
|
115
|
+
tp = _cell(row, "type").lower()
|
|
116
|
+
if tp == OverrideType.ADD_FLIGHT.value:
|
|
117
|
+
if _apply_add_flight(state, row, d_day):
|
|
118
|
+
counts[tp] += 1
|
|
119
|
+
elif tp == OverrideType.REMOVE_FLIGHT.value:
|
|
120
|
+
if _apply_remove_flight(state, row, d_day):
|
|
121
|
+
counts[tp] += 1
|
|
122
|
+
elif tp == OverrideType.ADD_STAFF.value:
|
|
123
|
+
if _apply_add_staff(state, row, d_day):
|
|
124
|
+
counts[tp] += 1
|
|
125
|
+
elif tp in (OverrideType.REMOVE_STAFF.value, OverrideType.SICK.value):
|
|
126
|
+
# sick is handled again by step3, but doing it here too
|
|
127
|
+
# lets the dashboard reflect it pre-Allocate.
|
|
128
|
+
if _apply_remove_staff(state, row, d_day):
|
|
129
|
+
counts[OverrideType.REMOVE_STAFF.value] += 1
|
|
130
|
+
elif tp == OverrideType.CHANGE_ROLE.value:
|
|
131
|
+
if _apply_change_role(state, row, d_day):
|
|
132
|
+
counts[tp] += 1
|
|
133
|
+
elif tp == OverrideType.CHANGE_SHIFT.value:
|
|
134
|
+
if _apply_change_shift(state, row, d_day):
|
|
135
|
+
counts[tp] += 1
|
|
136
|
+
# The dashboard's ZC list goes last, so an explicit change_role
|
|
137
|
+
# row (e.g. ZC -> AM for someone who is unwell) is applied first
|
|
138
|
+
# and the list only promotes people still in the live pool.
|
|
139
|
+
counts["zc_list"] = _apply_zc_list(state, d_day)
|
|
140
|
+
return counts
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------- flights ----------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _apply_add_flight(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
147
|
+
flt = _cell(row, "flight", "flt", "employee")
|
|
148
|
+
dep = _cell(row, "dep").upper()[:3]
|
|
149
|
+
arr = _cell(row, "arr").upper()[:3]
|
|
150
|
+
std = _parse_time(_cell(row, "std", "shift"))
|
|
151
|
+
if not (flt and dep and arr and std):
|
|
152
|
+
return False
|
|
153
|
+
try:
|
|
154
|
+
ops_class = OpsClass(_cell(row, "ops_class").lower() or "day")
|
|
155
|
+
except ValueError:
|
|
156
|
+
return False
|
|
157
|
+
flight_date = _parse_date(_cell(row, "date"), d_day)
|
|
158
|
+
try:
|
|
159
|
+
pax = int(float(_cell(row, "pax", "limit") or 0))
|
|
160
|
+
except (TypeError, ValueError):
|
|
161
|
+
pax = 0
|
|
162
|
+
|
|
163
|
+
bucket = state.cleaned.setdefault(ops_class, [])
|
|
164
|
+
# Idempotent: same date + flt + std on the same class is a no-op.
|
|
165
|
+
for existing in bucket:
|
|
166
|
+
if (existing.date == flight_date and existing.flt == flt
|
|
167
|
+
and existing.std == std):
|
|
168
|
+
return False
|
|
169
|
+
try:
|
|
170
|
+
new_row = CleanFlightRow(
|
|
171
|
+
date=flight_date,
|
|
172
|
+
flt=flt,
|
|
173
|
+
type=_cell(row, "ac_type"),
|
|
174
|
+
ac=_cell(row, "ac"),
|
|
175
|
+
dep=dep,
|
|
176
|
+
arr=arr,
|
|
177
|
+
std=std,
|
|
178
|
+
load=max(0, min(600, pax)),
|
|
179
|
+
ops_class=ops_class,
|
|
180
|
+
)
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
print(f" [add_flight] skipped {flt!r}: {exc}")
|
|
183
|
+
return False
|
|
184
|
+
bucket.append(new_row)
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _apply_remove_flight(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
189
|
+
flt = _cell(row, "flight", "flt", "employee")
|
|
190
|
+
std = _parse_time(_cell(row, "std", "shift"))
|
|
191
|
+
if not (flt and std):
|
|
192
|
+
return False
|
|
193
|
+
# Optional matchers — narrow the match when the form supplied them.
|
|
194
|
+
want_dep = _cell(row, "dep").upper()[:3]
|
|
195
|
+
want_arr = _cell(row, "arr").upper()[:3]
|
|
196
|
+
raw_date = _cell(row, "date")
|
|
197
|
+
want_date = _parse_date(raw_date, d_day) if raw_date else None
|
|
198
|
+
# An MM/DD date has no year, so match on month+day only.
|
|
199
|
+
day_only = bool(raw_date and "/" in raw_date)
|
|
200
|
+
|
|
201
|
+
removed = False
|
|
202
|
+
for ops, rows in state.cleaned.items():
|
|
203
|
+
keep: list[CleanFlightRow] = []
|
|
204
|
+
for r in rows:
|
|
205
|
+
match = (
|
|
206
|
+
r.flt == flt
|
|
207
|
+
and r.std == std
|
|
208
|
+
and (not want_dep or r.dep == want_dep)
|
|
209
|
+
and (not want_arr or r.arr == want_arr)
|
|
210
|
+
)
|
|
211
|
+
if match and want_date is not None:
|
|
212
|
+
if day_only:
|
|
213
|
+
match = (r.date.month, r.date.day) == (want_date.month, want_date.day)
|
|
214
|
+
else:
|
|
215
|
+
match = r.date == want_date
|
|
216
|
+
if match:
|
|
217
|
+
removed = True
|
|
218
|
+
else:
|
|
219
|
+
keep.append(r)
|
|
220
|
+
state.cleaned[ops] = keep
|
|
221
|
+
return removed
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------- staff ----------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _apply_add_staff(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
228
|
+
name = _cell(row, "employee")
|
|
229
|
+
shift = _cell(row, "shift").upper()
|
|
230
|
+
role_raw = _cell(row, "role").upper() or "STAFF"
|
|
231
|
+
if not (name and shift):
|
|
232
|
+
print(f" [add_staff] skipped: missing field — name={name!r}, shift={shift!r}")
|
|
233
|
+
return False
|
|
234
|
+
if shift not in ("M", "A", "N", "M1", "A1"):
|
|
235
|
+
print(
|
|
236
|
+
f" [add_staff] skipped: shift {shift!r} not in "
|
|
237
|
+
"(M, A, N, M1, A1) — use a plain shift code"
|
|
238
|
+
)
|
|
239
|
+
return False
|
|
240
|
+
try:
|
|
241
|
+
role = Role(role_raw)
|
|
242
|
+
except ValueError:
|
|
243
|
+
print(f" [add_staff] skipped: role {role_raw!r} not in (STAFF, ZC, AM)")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
target = _normalize_name(name)
|
|
247
|
+
d_iso = d_day.isoformat()
|
|
248
|
+
for av in state.availability:
|
|
249
|
+
if _normalize_name(av.name) == target and av.date.isoformat() == d_iso:
|
|
250
|
+
return False # already present — nothing to add
|
|
251
|
+
|
|
252
|
+
n_added = sum(1 for av in state.availability if av.employee_id.startswith("ADD_"))
|
|
253
|
+
state.availability.append(AvailabilityRow(
|
|
254
|
+
employee_id=f"ADD_{n_added + 1:03d}",
|
|
255
|
+
name=name,
|
|
256
|
+
role=role,
|
|
257
|
+
date=d_day,
|
|
258
|
+
status=CrewStatus(shift),
|
|
259
|
+
raw_status=shift,
|
|
260
|
+
assignable=True,
|
|
261
|
+
current_shift=shift,
|
|
262
|
+
license=None,
|
|
263
|
+
))
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _apply_remove_staff(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
268
|
+
name = _cell(row, "employee")
|
|
269
|
+
if not name:
|
|
270
|
+
print(" [remove_staff] skipped: blank employee field")
|
|
271
|
+
return False
|
|
272
|
+
target = _normalize_name(name)
|
|
273
|
+
d_iso = d_day.isoformat()
|
|
274
|
+
found = False
|
|
275
|
+
candidates: list[str] = []
|
|
276
|
+
for i, av in enumerate(state.availability):
|
|
277
|
+
if av.date.isoformat() != d_iso:
|
|
278
|
+
continue
|
|
279
|
+
nm = _normalize_name(av.name)
|
|
280
|
+
candidates.append(nm)
|
|
281
|
+
if nm == target:
|
|
282
|
+
state.availability[i] = av.model_copy(update={"assignable": False})
|
|
283
|
+
found = True
|
|
284
|
+
if not found:
|
|
285
|
+
# Loud diagnostic — common causes are a name typo, the staff not
|
|
286
|
+
# being on shift today, or the wrong date.
|
|
287
|
+
nearby = (
|
|
288
|
+
[n for n in candidates if target.split()[0] in n]
|
|
289
|
+
if " " in target else []
|
|
290
|
+
)
|
|
291
|
+
hint = f"; similar names on {d_iso}: {nearby[:3]}" if nearby else ""
|
|
292
|
+
print(
|
|
293
|
+
f" [remove_staff] WARNING: no roster row matches "
|
|
294
|
+
f"employee={name!r} on {d_iso} ({len(candidates)} staff rows "
|
|
295
|
+
f"for that date){hint}"
|
|
296
|
+
)
|
|
297
|
+
return found
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _apply_change_role(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
301
|
+
"""Flip a staff's role so the dashboard roster reflects the change
|
|
302
|
+
without a re-solve.
|
|
303
|
+
|
|
304
|
+
The ``shift`` column carries the NEW role (STAFF / ZC / AM). For ZC
|
|
305
|
+
we also rewrite status / raw_status to the ``<shift>/ZC`` variant so
|
|
306
|
+
downstream readers see a ZC-flagged row; AM drops them out of the
|
|
307
|
+
live pool, matching the step3 semantics.
|
|
308
|
+
"""
|
|
309
|
+
name = _cell(row, "employee")
|
|
310
|
+
new_role_raw = _cell(row, "shift", "role").upper()
|
|
311
|
+
if not (name and new_role_raw):
|
|
312
|
+
return False
|
|
313
|
+
try:
|
|
314
|
+
new_role = Role(new_role_raw)
|
|
315
|
+
except ValueError:
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
target = _normalize_name(name)
|
|
319
|
+
d_iso = d_day.isoformat()
|
|
320
|
+
found = False
|
|
321
|
+
for i, av in enumerate(state.availability):
|
|
322
|
+
if _normalize_name(av.name) != target or av.date.isoformat() != d_iso:
|
|
323
|
+
continue
|
|
324
|
+
update: dict[str, Any] = {"role": new_role}
|
|
325
|
+
if new_role is Role.ZC:
|
|
326
|
+
update = _zc_update(av)
|
|
327
|
+
elif new_role is Role.AM:
|
|
328
|
+
update["assignable"] = False
|
|
329
|
+
state.availability[i] = av.model_copy(update=update)
|
|
330
|
+
found = True
|
|
331
|
+
return found
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _apply_change_shift(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
|
|
335
|
+
"""Move an EXISTING staff member to a new shift, or off/on leave, for
|
|
336
|
+
the day — the Roster Change dashboard pane (search a name, pick M /
|
|
337
|
+
A / N / M1 / A1, or Off / On Leave).
|
|
338
|
+
|
|
339
|
+
The ``shift`` column carries the new status as the roster file's own
|
|
340
|
+
literal: a plain shift code, or an off/leave code (``F`` day-off,
|
|
341
|
+
``P/L`` paid leave, ``C/L`` casual leave, ``C/OFF`` custom off).
|
|
342
|
+
Unlike ``remove_staff``/``sick`` this doesn't just flip a flag — it
|
|
343
|
+
rewrites status/current_shift so the person reappears correctly if
|
|
344
|
+
later moved back onto a shift. Unlike ``add_staff`` it never creates
|
|
345
|
+
a row: an unrecognized employee is a no-op, same as remove_staff.
|
|
346
|
+
"""
|
|
347
|
+
name = _cell(row, "employee")
|
|
348
|
+
raw = _cell(row, "shift").upper()
|
|
349
|
+
if not (name and raw):
|
|
350
|
+
return False
|
|
351
|
+
try:
|
|
352
|
+
new_status = CrewStatus(raw)
|
|
353
|
+
except ValueError:
|
|
354
|
+
print(f" [change_shift] skipped: unrecognized shift/status {raw!r}")
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
target = _normalize_name(name)
|
|
358
|
+
d_iso = d_day.isoformat()
|
|
359
|
+
found = False
|
|
360
|
+
for i, av in enumerate(state.availability):
|
|
361
|
+
if _normalize_name(av.name) != target or av.date.isoformat() != d_iso:
|
|
362
|
+
continue
|
|
363
|
+
state.availability[i] = av.model_copy(update={
|
|
364
|
+
"status": new_status,
|
|
365
|
+
"raw_status": new_status.value,
|
|
366
|
+
"current_shift": STATUS_TO_SHIFT.get(new_status),
|
|
367
|
+
"assignable": new_status not in NON_ASSIGNABLE,
|
|
368
|
+
})
|
|
369
|
+
found = True
|
|
370
|
+
if not found:
|
|
371
|
+
print(
|
|
372
|
+
f" [change_shift] WARNING: no roster row matches "
|
|
373
|
+
f"employee={name!r} on {d_iso}"
|
|
374
|
+
)
|
|
375
|
+
return found
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------- Zone Controller list ----------------
|
|
379
|
+
|
|
380
|
+
# A P2F handler who is made ZC keeps the handler duty: M/P2F -> M/P2F/ZC.
|
|
381
|
+
# (Rewriting it to plain M/ZC would drop them from the P2F auto-pick.)
|
|
382
|
+
_P2F_TO_ZC_P2F: dict[CrewStatus, CrewStatus] = {
|
|
383
|
+
CrewStatus.P2F_M: CrewStatus.ZC_P2F_M,
|
|
384
|
+
CrewStatus.P2F_A: CrewStatus.ZC_P2F_A,
|
|
385
|
+
CrewStatus.P2F_N: CrewStatus.ZC_P2F_N,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _zc_update(av: AvailabilityRow) -> dict[str, Any]:
|
|
390
|
+
"""The field changes that make ``av`` a Zone Controller for the day:
|
|
391
|
+
role ZC plus the matching ``<shift>/ZC`` status, so every downstream
|
|
392
|
+
reader (dashboard, step3, auto-P2F picker) sees a ZC-flagged row."""
|
|
393
|
+
update: dict[str, Any] = {"role": Role.ZC}
|
|
394
|
+
if av.status in STATUS_IS_ZC:
|
|
395
|
+
return update
|
|
396
|
+
target = _P2F_TO_ZC_P2F.get(av.status)
|
|
397
|
+
if target is None and av.current_shift in ("M", "A", "N", "M1", "A1"):
|
|
398
|
+
try:
|
|
399
|
+
target = CrewStatus(f"{av.current_shift}/ZC")
|
|
400
|
+
except ValueError:
|
|
401
|
+
target = None
|
|
402
|
+
if target is not None:
|
|
403
|
+
update["status"] = target
|
|
404
|
+
update["raw_status"] = target.value
|
|
405
|
+
return update
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _demote_update(av: AvailabilityRow) -> dict[str, Any]:
|
|
409
|
+
"""Undo a ZC promotion: back to the roster the person came from
|
|
410
|
+
(STAFF / AM) and to the plain shift or P2F-handler status without
|
|
411
|
+
the ``/ZC`` (M/ZC -> M, M/P2F/ZC -> M/P2F)."""
|
|
412
|
+
update: dict[str, Any] = {"role": av.origin_role or Role.STAFF}
|
|
413
|
+
lit = av.status.value
|
|
414
|
+
if lit.endswith("/ZC"):
|
|
415
|
+
lit = lit[: -len("/ZC")]
|
|
416
|
+
target: CrewStatus | None = None
|
|
417
|
+
try:
|
|
418
|
+
target = CrewStatus(lit)
|
|
419
|
+
except ValueError:
|
|
420
|
+
# e.g. M1/P2F has no status of its own: keep just the shift.
|
|
421
|
+
if lit.endswith("/P2F"):
|
|
422
|
+
try:
|
|
423
|
+
target = CrewStatus(lit[: -len("/P2F")])
|
|
424
|
+
except ValueError:
|
|
425
|
+
target = None
|
|
426
|
+
if target is not None:
|
|
427
|
+
update["status"] = target
|
|
428
|
+
update["raw_status"] = target.value
|
|
429
|
+
return update
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _apply_zc_list(state: AppState, d_day: date_t) -> int:
|
|
433
|
+
"""Make D's Zone Controllers match the dashboard's list. Returns how
|
|
434
|
+
many rows on D are ZC because of the list.
|
|
435
|
+
|
|
436
|
+
* Everyone on the saved list who is rostered on a shift and
|
|
437
|
+
assignable on D is promoted. A name carried over from yesterday who
|
|
438
|
+
is off today is simply skipped, not an error.
|
|
439
|
+
* Everyone recorded as *removed* for D is taken back off ZC — this is
|
|
440
|
+
how a ``/ZC`` that is still in the roster file gets overridden.
|
|
441
|
+
|
|
442
|
+
Idempotent, like every applier here.
|
|
443
|
+
"""
|
|
444
|
+
names, _source, _ = zc_store.effective(d_day)
|
|
445
|
+
removed = {_normalize_name(n) for n in zc_store.removed(d_day)}
|
|
446
|
+
d_iso = d_day.isoformat()
|
|
447
|
+
if not any(av.date.isoformat() == d_iso for av in state.availability):
|
|
448
|
+
return 0
|
|
449
|
+
# The list this plan actually runs with becomes that day's own list,
|
|
450
|
+
# so tomorrow's carry-over starts from it.
|
|
451
|
+
zc_store.freeze(d_day)
|
|
452
|
+
wanted = {_normalize_name(n) for n in names} - removed
|
|
453
|
+
applied = 0
|
|
454
|
+
for i, av in enumerate(state.availability):
|
|
455
|
+
if av.date.isoformat() != d_iso:
|
|
456
|
+
continue
|
|
457
|
+
key = _normalize_name(av.name)
|
|
458
|
+
if key in removed:
|
|
459
|
+
if av.role is Role.ZC or av.status in STATUS_IS_ZC:
|
|
460
|
+
state.availability[i] = av.model_copy(update=_demote_update(av))
|
|
461
|
+
continue
|
|
462
|
+
if key not in wanted:
|
|
463
|
+
continue
|
|
464
|
+
if not av.assignable or not av.current_shift:
|
|
465
|
+
continue
|
|
466
|
+
state.availability[i] = av.model_copy(update=_zc_update(av))
|
|
467
|
+
applied += 1
|
|
468
|
+
return applied
|