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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Planning-stage readbacks: the Plan summary, nominated handlers, the
|
|
2
|
+
staffing recommendation, the roster name list and the shift limit bands.
|
|
3
|
+
|
|
4
|
+
These describe the state *before* a solve — what Plan produced and what
|
|
5
|
+
the solver is about to be asked to do.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ...schemas import STATUS_TO_P2F_HANDLER_SHIFT, OpsClass, Role
|
|
14
|
+
from ...state import AppState
|
|
15
|
+
from .common import SHIFTS, canonical_status, is_live, run_date_iso
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_plan_summary(state: AppState) -> dict[str, Any]:
|
|
20
|
+
"""Structured plan payload so the UI can draw one coverage bar per
|
|
21
|
+
shift plus the handler bars.
|
|
22
|
+
|
|
23
|
+
``needed`` is derived live as
|
|
24
|
+
``ceil(flights_in_that_shift / preferred_flights_per_staff)`` using
|
|
25
|
+
the current (possibly drawer-overridden) preferred targets. Flights
|
|
26
|
+
are bucketed into shift windows by STD with the same windowing the
|
|
27
|
+
engine uses, so the bar reflects the constraints the solver will
|
|
28
|
+
actually enforce.
|
|
29
|
+
"""
|
|
30
|
+
from ...allocator.caps import preferred_target_for_shift_role
|
|
31
|
+
from ...allocator.windows import SHIFT_NOMINAL_MIN
|
|
32
|
+
|
|
33
|
+
def _shift_for(std_min: int) -> str:
|
|
34
|
+
for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
|
|
35
|
+
if start <= std_min <= end:
|
|
36
|
+
return shift
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
def _ceil_div(a: int, b: int) -> int:
|
|
40
|
+
return -(-a // b) if b else 0
|
|
41
|
+
|
|
42
|
+
with state.lock:
|
|
43
|
+
flights_by_class = {o.value: 0 for o in OpsClass}
|
|
44
|
+
for ops, rows in state.cleaned.items():
|
|
45
|
+
flights_by_class[ops.value] = len(rows)
|
|
46
|
+
|
|
47
|
+
# Workload-bearing flights only: GULF is never allocated, so it
|
|
48
|
+
# must not inflate per-shift need.
|
|
49
|
+
flights_per_shift: dict[str, int] = {s: 0 for s in SHIFTS}
|
|
50
|
+
p2f_per_shift: dict[str, int] = {"M": 0, "A": 0, "N": 0}
|
|
51
|
+
for ops, rows in state.cleaned.items():
|
|
52
|
+
if ops is OpsClass.GULF:
|
|
53
|
+
continue
|
|
54
|
+
for r in rows:
|
|
55
|
+
std_min = r.std.hour * 60 + r.std.minute
|
|
56
|
+
sh = _shift_for(std_min)
|
|
57
|
+
if sh:
|
|
58
|
+
flights_per_shift[sh] += 1
|
|
59
|
+
if ops is OpsClass.P2F and sh in p2f_per_shift:
|
|
60
|
+
p2f_per_shift[sh] += 1
|
|
61
|
+
|
|
62
|
+
staff_by_shift: dict[str, int] = {s: 0 for s in SHIFTS}
|
|
63
|
+
nominated_p2f: dict[str, bool] = {s: False for s in SHIFTS}
|
|
64
|
+
for av in state.availability:
|
|
65
|
+
if not is_live(state, av):
|
|
66
|
+
continue
|
|
67
|
+
if av.current_shift in staff_by_shift:
|
|
68
|
+
staff_by_shift[av.current_shift] += 1
|
|
69
|
+
status = canonical_status(av.status.value)
|
|
70
|
+
handler_shift = (
|
|
71
|
+
STATUS_TO_P2F_HANDLER_SHIFT.get(status) if status else None
|
|
72
|
+
)
|
|
73
|
+
if handler_shift:
|
|
74
|
+
nominated_p2f[handler_shift] = True
|
|
75
|
+
|
|
76
|
+
plan_text = state.plan_text
|
|
77
|
+
run_date = run_date_iso(state)
|
|
78
|
+
|
|
79
|
+
coverage_by_shift: dict[str, dict[str, Any]] = {}
|
|
80
|
+
for shift in SHIFTS:
|
|
81
|
+
flights_in_shift = flights_per_shift[shift]
|
|
82
|
+
# STAFF preferred, not ZC — ZCs are a small slice of the pool and
|
|
83
|
+
# the bar flags gross under-staffing, not role mix.
|
|
84
|
+
pref = preferred_target_for_shift_role(shift, Role.STAFF)
|
|
85
|
+
coverage_by_shift[shift] = {
|
|
86
|
+
"present": staff_by_shift[shift],
|
|
87
|
+
"needed": _ceil_div(flights_in_shift, pref) if pref else 0,
|
|
88
|
+
"flights_in_shift": flights_in_shift,
|
|
89
|
+
"preferred_per_staff": pref,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
p2f_bars = {
|
|
93
|
+
shift: {
|
|
94
|
+
"flights": p2f_per_shift[shift],
|
|
95
|
+
"needed": _ceil_div(p2f_per_shift[shift], 8),
|
|
96
|
+
"nominated": 1 if nominated_p2f.get(shift) else 0,
|
|
97
|
+
}
|
|
98
|
+
for shift in ("M", "A", "N")
|
|
99
|
+
}
|
|
100
|
+
day_staff = sum(staff_by_shift[s] for s in ("M", "A", "M1", "A1"))
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"text": plan_text,
|
|
104
|
+
"run_date": run_date,
|
|
105
|
+
"flights": flights_by_class,
|
|
106
|
+
"total_flights": sum(flights_by_class.values()),
|
|
107
|
+
"staff_by_shift": staff_by_shift,
|
|
108
|
+
"day_staff_total": day_staff,
|
|
109
|
+
"night_staff_total": staff_by_shift["N"],
|
|
110
|
+
"coverage_by_shift": coverage_by_shift,
|
|
111
|
+
"p2f_bars": p2f_bars,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_handlers(state: AppState) -> dict[str, Any]:
|
|
116
|
+
"""The P2F handler picture for the dashboard — AND whether it's
|
|
117
|
+
actually enough to allocate on.
|
|
118
|
+
|
|
119
|
+
P2F handlers come from the roster first (a status like ``M/P2F`` or
|
|
120
|
+
``M/P2F/ZC``), with ``type=p2f`` override rows as the manual
|
|
121
|
+
fallback / addition. An override nomination only counts if the
|
|
122
|
+
employee is actually on that shift TODAY per the roster — a stale
|
|
123
|
+
or mistyped shift on the override row (the same mismatch the
|
|
124
|
+
solver's W212 check catches) is surfaced here as an ``issue``
|
|
125
|
+
instead of silently looking fine, and does NOT count toward
|
|
126
|
+
``valid_count``.
|
|
127
|
+
|
|
128
|
+
``required_by_shift[shift]`` is ``ceil(p2f_flight_count / 8)`` — one
|
|
129
|
+
handler can only be trusted with 8 P2F flights (W215) — computed
|
|
130
|
+
from the cleaned schedule, so it's known right after Plan and
|
|
131
|
+
before Allocate has to run. ``ready`` is True once every shift with
|
|
132
|
+
P2F flights has at least that many *valid* nominations; the UI (and
|
|
133
|
+
the run.js Allocate gate) uses this to decide whether Allocate
|
|
134
|
+
should be clickable.
|
|
135
|
+
"""
|
|
136
|
+
from ...allocator.windows import SHIFT_NOMINAL_MIN
|
|
137
|
+
from ...schemas import OpsClass, P2F_HANDLER_ELIGIBLE_SHIFTS
|
|
138
|
+
|
|
139
|
+
P2F_HANDLER_CAP = 8
|
|
140
|
+
|
|
141
|
+
with state.lock:
|
|
142
|
+
d_day = state.run_date
|
|
143
|
+
|
|
144
|
+
shift_today_by_id: dict[str, str] = {}
|
|
145
|
+
name_to_id: dict[str, str] = {}
|
|
146
|
+
p2f_nominees: dict[str, list[dict[str, Any]]] = {}
|
|
147
|
+
for av in state.availability:
|
|
148
|
+
if av.name:
|
|
149
|
+
name_to_id[av.name.strip().upper()] = av.employee_id
|
|
150
|
+
if not is_live(state, av):
|
|
151
|
+
continue
|
|
152
|
+
shift_today_by_id[av.employee_id] = av.current_shift or ""
|
|
153
|
+
status = canonical_status(av.status.value)
|
|
154
|
+
shift = STATUS_TO_P2F_HANDLER_SHIFT.get(status) if status else None
|
|
155
|
+
if shift:
|
|
156
|
+
p2f_nominees.setdefault(shift, []).append({
|
|
157
|
+
"name": av.name, "employee_id": av.employee_id,
|
|
158
|
+
"shift": shift, "role": av.role.value,
|
|
159
|
+
"source": "roster",
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
issues: list[str] = []
|
|
163
|
+
for row in state.overrides:
|
|
164
|
+
rtype = (row.get("type") or "").lower()
|
|
165
|
+
if rtype != "p2f":
|
|
166
|
+
continue
|
|
167
|
+
name = (row.get("employee") or "").strip()
|
|
168
|
+
shift = (row.get("shift") or "").strip().upper()
|
|
169
|
+
if not name or not shift:
|
|
170
|
+
continue
|
|
171
|
+
eid = name_to_id.get(name.upper())
|
|
172
|
+
actual_shift = shift_today_by_id.get(eid) if eid else None
|
|
173
|
+
if eid is None or actual_shift is None:
|
|
174
|
+
issues.append(
|
|
175
|
+
f'P2F nomination "{name}" ({shift}): not an assignable '
|
|
176
|
+
"staff member on today's roster."
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
if actual_shift != shift:
|
|
180
|
+
issues.append(
|
|
181
|
+
f'P2F nomination "{name}" is listed for shift {shift}, '
|
|
182
|
+
f"but today's roster has them on {actual_shift or 'OFF'}. "
|
|
183
|
+
"Fix the override's shift or nominate someone else."
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
already = any(
|
|
187
|
+
n["employee_id"] == eid for n in p2f_nominees.get(shift, [])
|
|
188
|
+
)
|
|
189
|
+
if not already:
|
|
190
|
+
p2f_nominees.setdefault(shift, []).append({
|
|
191
|
+
"name": name, "employee_id": eid, "shift": shift,
|
|
192
|
+
"role": "", "source": "override",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# How many P2F flights actually fall in each shift's window —
|
|
196
|
+
# known from Plan (state.cleaned), no Allocate needed.
|
|
197
|
+
p2f_count_by_shift: dict[str, int] = {
|
|
198
|
+
s: 0 for s in P2F_HANDLER_ELIGIBLE_SHIFTS
|
|
199
|
+
}
|
|
200
|
+
for row in state.cleaned.get(OpsClass.P2F, []):
|
|
201
|
+
std_min = row.std.hour * 60 + row.std.minute
|
|
202
|
+
if row.date and d_day and row.date == d_day + timedelta(days=1):
|
|
203
|
+
std_min += 24 * 60
|
|
204
|
+
for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
|
|
205
|
+
if shift not in P2F_HANDLER_ELIGIBLE_SHIFTS:
|
|
206
|
+
continue
|
|
207
|
+
if start <= std_min <= end:
|
|
208
|
+
p2f_count_by_shift[shift] += 1
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
run_date = run_date_iso(state)
|
|
212
|
+
|
|
213
|
+
required_by_shift: dict[str, int] = {}
|
|
214
|
+
valid_count_by_shift: dict[str, int] = {}
|
|
215
|
+
shift_status: dict[str, str] = {}
|
|
216
|
+
for shift in P2F_HANDLER_ELIGIBLE_SHIFTS:
|
|
217
|
+
count = p2f_count_by_shift.get(shift, 0)
|
|
218
|
+
needed = -(-count // P2F_HANDLER_CAP) if count else 0 # ceil
|
|
219
|
+
have = len(p2f_nominees.get(shift, []))
|
|
220
|
+
required_by_shift[shift] = needed
|
|
221
|
+
valid_count_by_shift[shift] = have
|
|
222
|
+
if needed == 0:
|
|
223
|
+
shift_status[shift] = "not_needed"
|
|
224
|
+
elif have >= needed:
|
|
225
|
+
shift_status[shift] = "ok"
|
|
226
|
+
else:
|
|
227
|
+
shift_status[shift] = "missing"
|
|
228
|
+
|
|
229
|
+
plan_has_run = bool(d_day) and bool(state.cleaned)
|
|
230
|
+
ready = (not plan_has_run) or all(
|
|
231
|
+
shift_status[s] != "missing" for s in P2F_HANDLER_ELIGIBLE_SHIFTS
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
all_nominees = [n for shift in p2f_nominees for n in p2f_nominees[shift]]
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"run_date": run_date,
|
|
238
|
+
"p2f": all_nominees,
|
|
239
|
+
"missing_p2f_shifts": sorted(
|
|
240
|
+
s for s, st in shift_status.items() if st == "missing"
|
|
241
|
+
),
|
|
242
|
+
"required_by_shift": required_by_shift,
|
|
243
|
+
"valid_count_by_shift": valid_count_by_shift,
|
|
244
|
+
"p2f_flight_count_by_shift": p2f_count_by_shift,
|
|
245
|
+
"shift_status": shift_status,
|
|
246
|
+
"issues": issues,
|
|
247
|
+
"plan_has_run": plan_has_run,
|
|
248
|
+
"ready": ready,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_staff_names(state: AppState) -> dict[str, Any]:
|
|
253
|
+
"""Every assignable staff + AM/ZC name for the run date, with the
|
|
254
|
+
shift each is rostered on. Drives the drawer's employee dropdown and
|
|
255
|
+
the dashboard's roster-by-shift block, so ``remove_staff`` / ``sick``
|
|
256
|
+
take effect here without waiting for a re-Plan."""
|
|
257
|
+
names: list[dict[str, str]] = []
|
|
258
|
+
seen: set[str] = set()
|
|
259
|
+
with state.lock:
|
|
260
|
+
for av in state.availability:
|
|
261
|
+
if not is_live(state, av):
|
|
262
|
+
continue
|
|
263
|
+
name = av.name.strip()
|
|
264
|
+
if not name or name in seen:
|
|
265
|
+
continue
|
|
266
|
+
seen.add(name)
|
|
267
|
+
status = av.status.value
|
|
268
|
+
names.append({
|
|
269
|
+
"name": name,
|
|
270
|
+
"role": av.role.value,
|
|
271
|
+
"shift": av.current_shift or "",
|
|
272
|
+
# ZCs arrive via the AM roster with role='AM' but a
|
|
273
|
+
# status like 'M/ZC' — the UI needs that to tag them.
|
|
274
|
+
"status": status,
|
|
275
|
+
"is_zc": "ZC" in status.upper(),
|
|
276
|
+
})
|
|
277
|
+
run_date = run_date_iso(state)
|
|
278
|
+
names.sort(key=lambda x: x["name"])
|
|
279
|
+
return {"run_date": run_date, "names": names}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def read_roster_full(state: AppState) -> dict[str, Any]:
|
|
283
|
+
"""Every staff/AM/ZC row for the run date, INCLUDING anyone currently
|
|
284
|
+
off / on leave / sick (``read_staff_names`` drops those, since it
|
|
285
|
+
drives dropdowns that only make sense for someone on a shift today).
|
|
286
|
+
|
|
287
|
+
Drives the Roster Change pane's search: an assigner who marked
|
|
288
|
+
someone off needs to be able to find that person again to put them
|
|
289
|
+
back on a shift, so the search list can't shrink when a person is
|
|
290
|
+
taken off it.
|
|
291
|
+
"""
|
|
292
|
+
names: list[dict[str, Any]] = []
|
|
293
|
+
seen: set[str] = set()
|
|
294
|
+
with state.lock:
|
|
295
|
+
d_iso = run_date_iso(state)
|
|
296
|
+
for av in state.availability:
|
|
297
|
+
if d_iso and av.date.isoformat() != d_iso:
|
|
298
|
+
continue
|
|
299
|
+
name = av.name.strip()
|
|
300
|
+
if not name or name in seen:
|
|
301
|
+
continue
|
|
302
|
+
seen.add(name)
|
|
303
|
+
names.append({
|
|
304
|
+
"name": name,
|
|
305
|
+
"role": av.role.value,
|
|
306
|
+
"shift": av.current_shift or "",
|
|
307
|
+
"status": av.status.value,
|
|
308
|
+
"assignable": av.assignable,
|
|
309
|
+
})
|
|
310
|
+
names.sort(key=lambda x: x["name"])
|
|
311
|
+
return {"run_date": d_iso, "names": names}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def read_staffing(state: AppState) -> dict[str, Any]:
|
|
315
|
+
with state.lock:
|
|
316
|
+
return {"run_date": run_date_iso(state), "rows": list(state.staffing)}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def read_shift_limits() -> dict[str, Any]:
|
|
320
|
+
"""Current (shift, role) bands so the drawer's "Edit shift limits"
|
|
321
|
+
form can pre-fill. Merges the JSON defaults with any iteration
|
|
322
|
+
override — exactly what the engine will see on its next run."""
|
|
323
|
+
from ...allocator.caps import get_band, snapshot_iteration_overrides
|
|
324
|
+
|
|
325
|
+
bands: dict[str, dict[str, Any]] = {}
|
|
326
|
+
for shift in SHIFTS:
|
|
327
|
+
bands[shift] = {}
|
|
328
|
+
for role_name, role in (("STAFF", Role.STAFF), ("ZC", Role.ZC)):
|
|
329
|
+
bands[shift][role_name] = get_band(shift, role) or {}
|
|
330
|
+
return {
|
|
331
|
+
"bands": bands,
|
|
332
|
+
"iteration_overrides": snapshot_iteration_overrides(),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Session-scoped readbacks: the uploaded input files, the override
|
|
2
|
+
rows, the SV-portal column probe and the per-shift roster counts.
|
|
3
|
+
|
|
4
|
+
"Session" because all of it lives in the server's memory for as long as
|
|
5
|
+
the process runs — nothing here is derived from a solve.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from datetime import date as date_t
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ...state import (
|
|
16
|
+
DAILY_INPUT_KINDS,
|
|
17
|
+
OVERRIDE_HEADERS,
|
|
18
|
+
OVERRIDE_HEADERS_VISIBLE,
|
|
19
|
+
SETUP_INPUT_KINDS,
|
|
20
|
+
AppState,
|
|
21
|
+
)
|
|
22
|
+
from .common import SHIFTS, is_live, run_date_iso
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_inputs(state: AppState) -> dict[str, Any]:
|
|
27
|
+
"""Upload slots: what's loaded, what's missing, split by cadence.
|
|
28
|
+
|
|
29
|
+
``daily`` is the flight schedule the dashboard asks for every
|
|
30
|
+
morning; ``setup`` is the two rosters the Setup sidebar owns. The UI
|
|
31
|
+
reports each group separately because they're fixed in different
|
|
32
|
+
places, and ``ready`` still means "all three are in".
|
|
33
|
+
"""
|
|
34
|
+
with state.lock:
|
|
35
|
+
missing = state.missing_inputs()
|
|
36
|
+
missing_daily = state.missing_inputs("daily")
|
|
37
|
+
missing_setup = state.missing_inputs("setup")
|
|
38
|
+
return {
|
|
39
|
+
"inputs": state.inputs_as_json(),
|
|
40
|
+
"missing": missing,
|
|
41
|
+
"ready": not missing,
|
|
42
|
+
"groups": {
|
|
43
|
+
"daily": {
|
|
44
|
+
"kinds": list(DAILY_INPUT_KINDS),
|
|
45
|
+
"missing": missing_daily,
|
|
46
|
+
"ready": not missing_daily,
|
|
47
|
+
},
|
|
48
|
+
"setup": {
|
|
49
|
+
"kinds": list(SETUP_INPUT_KINDS),
|
|
50
|
+
"missing": missing_setup,
|
|
51
|
+
"ready": not missing_setup,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"run_date": run_date_iso(state),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_server_date(state: AppState) -> dict[str, Any]:
|
|
59
|
+
"""The operating date, decided by the server rather than the browser.
|
|
60
|
+
|
|
61
|
+
The console is driven from whatever machine is free, and a laptop
|
|
62
|
+
with a stale clock or a different timezone used to silently plan the
|
|
63
|
+
wrong day. ``date`` is the server's today; ``run_date`` is the date
|
|
64
|
+
the current session is already working on, which wins when set so a
|
|
65
|
+
reload doesn't throw away a day the operator deliberately chose.
|
|
66
|
+
"""
|
|
67
|
+
today = date_t.today()
|
|
68
|
+
with state.lock:
|
|
69
|
+
run_date = run_date_iso(state)
|
|
70
|
+
return {
|
|
71
|
+
"date": today.isoformat(),
|
|
72
|
+
"run_date": run_date,
|
|
73
|
+
# What the date input should show: the session's date if there is
|
|
74
|
+
# one, else the server's today.
|
|
75
|
+
"effective": run_date or today.isoformat(),
|
|
76
|
+
"server_time": datetime.now().isoformat(timespec="seconds"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_sv_portal_columns(state: AppState) -> dict[str, Any]:
|
|
81
|
+
"""Header list plus a sample of distinct values per column from the
|
|
82
|
+
uploaded flight schedule. Feeds the drawer's extraction-filter form,
|
|
83
|
+
where the custom-header field becomes a dropdown of real headers
|
|
84
|
+
with a hint of what's in them.
|
|
85
|
+
|
|
86
|
+
Sample capped at 20 distinct values per column and 5000 scanned rows
|
|
87
|
+
to keep the payload light. Empty lists on any failure — the form
|
|
88
|
+
degrades to a free-text input.
|
|
89
|
+
"""
|
|
90
|
+
from ...io.readers import workbook_from_bytes
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data = state.input_bytes("sv_portal")
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return {"headers": [], "samples": {}}
|
|
96
|
+
try:
|
|
97
|
+
wb = workbook_from_bytes(data)
|
|
98
|
+
except Exception as exc: # noqa: BLE001 — degrade to free-text input
|
|
99
|
+
print(f" sv_portal headers unreadable: {type(exc).__name__}: {exc}")
|
|
100
|
+
return {"headers": [], "samples": {}}
|
|
101
|
+
try:
|
|
102
|
+
if not wb.sheetnames:
|
|
103
|
+
return {"headers": [], "samples": {}}
|
|
104
|
+
ws = wb[wb.sheetnames[0]]
|
|
105
|
+
first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
|
106
|
+
if first_row is None:
|
|
107
|
+
return {"headers": [], "samples": {}}
|
|
108
|
+
headers = [
|
|
109
|
+
str(h).strip() for h in first_row
|
|
110
|
+
if h is not None and str(h).strip()
|
|
111
|
+
]
|
|
112
|
+
samples: dict[str, list[str]] = {h: [] for h in headers}
|
|
113
|
+
seen: dict[str, set[str]] = {h: set() for h in headers}
|
|
114
|
+
for n, row in enumerate(ws.iter_rows(min_row=2, values_only=True)):
|
|
115
|
+
if n >= 5000:
|
|
116
|
+
break
|
|
117
|
+
for i, header in enumerate(headers):
|
|
118
|
+
if i >= len(row) or row[i] is None:
|
|
119
|
+
continue
|
|
120
|
+
v = str(row[i]).strip()
|
|
121
|
+
if not v or v in seen[header] or len(samples[header]) >= 20:
|
|
122
|
+
continue
|
|
123
|
+
seen[header].add(v)
|
|
124
|
+
samples[header].append(v)
|
|
125
|
+
return {"headers": headers, "samples": samples}
|
|
126
|
+
finally:
|
|
127
|
+
wb.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_overrides(state: AppState) -> dict[str, Any]:
|
|
131
|
+
"""Override rows for the drawer table. ``headers`` are the editable
|
|
132
|
+
columns; each row also carries its full payload so a type-specific
|
|
133
|
+
field (a Phase-R ``flight`` / ``std``) survives an edit."""
|
|
134
|
+
with state.lock:
|
|
135
|
+
rows = [
|
|
136
|
+
{
|
|
137
|
+
"index": i,
|
|
138
|
+
"values": [row.get(h, "") for h in OVERRIDE_HEADERS_VISIBLE],
|
|
139
|
+
"payload": dict(row),
|
|
140
|
+
}
|
|
141
|
+
for i, row in enumerate(state.overrides)
|
|
142
|
+
]
|
|
143
|
+
return {
|
|
144
|
+
"headers": list(OVERRIDE_HEADERS_VISIBLE),
|
|
145
|
+
"all_headers": list(OVERRIDE_HEADERS),
|
|
146
|
+
"rows": rows,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_roster_counts(state: AppState) -> dict[str, Any]:
|
|
151
|
+
"""Per-shift staff counts — small helper for the dashboard header."""
|
|
152
|
+
with state.lock:
|
|
153
|
+
counts: Counter[str] = Counter()
|
|
154
|
+
for av in state.availability:
|
|
155
|
+
if is_live(state, av):
|
|
156
|
+
counts[av.current_shift] += 1
|
|
157
|
+
return {"counts": {s: counts.get(s, 0) for s in SHIFTS}}
|
|
158
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Per-tab table readbacks: allocations, workload, pairs, warnings,
|
|
2
|
+
unallocated and the recommender suggestions that hang off it.
|
|
3
|
+
|
|
4
|
+
Each returns ``{"rows": [...]}`` (plus a count where the UI shows one),
|
|
5
|
+
so a new tab is a new function here and a new route in ``api``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ...schemas import OpsClass
|
|
13
|
+
from ...state import AppState
|
|
14
|
+
from .common import intl_codes, run_date_iso
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_allocations(state: AppState) -> dict[str, Any]:
|
|
19
|
+
"""Every allocation row as one flat list, with the ops_class and
|
|
20
|
+
the international flags the UI colours by.
|
|
21
|
+
|
|
22
|
+
P2F precedence: a P2F flight whose DEP happens to be in the INTL
|
|
23
|
+
list (DAC, KMG…) is still a P2F flight — it never gets the INTL
|
|
24
|
+
tint, which has its own meaning in the P2F lane.
|
|
25
|
+
"""
|
|
26
|
+
intl_set = intl_codes()
|
|
27
|
+
with state.lock:
|
|
28
|
+
# (flt, dep, arr, std) -> ops_class, from the cleaned flights.
|
|
29
|
+
ops_by_key: dict[tuple[str, str, str, str], str] = {}
|
|
30
|
+
for ops, rows in state.cleaned.items():
|
|
31
|
+
for r in rows:
|
|
32
|
+
ops_by_key[(
|
|
33
|
+
r.flt, r.dep.upper(), r.arr.upper(),
|
|
34
|
+
r.std.isoformat(timespec="minutes"),
|
|
35
|
+
)] = ops.value
|
|
36
|
+
|
|
37
|
+
rows_out: list[dict[str, Any]] = []
|
|
38
|
+
for r in state.allocations:
|
|
39
|
+
dep = r.dep.upper()
|
|
40
|
+
arr = r.arr.upper()
|
|
41
|
+
std = r.std.isoformat(timespec="minutes")
|
|
42
|
+
ops_class = ops_by_key.get((r.flt, dep, arr, std), "")
|
|
43
|
+
is_p2f = ops_class == "p2f"
|
|
44
|
+
key = f"{r.flt}|{dep}|{arr}|{std}|{r.date.isoformat()}"
|
|
45
|
+
prev_staff = state.prev_staff_by_key.get(key, "")
|
|
46
|
+
rows_out.append({
|
|
47
|
+
"sheet": r.sheet_target.value,
|
|
48
|
+
"date": r.date.isoformat(),
|
|
49
|
+
"flt": r.flt,
|
|
50
|
+
"dep": r.dep,
|
|
51
|
+
"arr": r.arr,
|
|
52
|
+
"std": std,
|
|
53
|
+
"pax": r.pax,
|
|
54
|
+
"staff": r.staff_name,
|
|
55
|
+
"planned_by": r.planned_by_name or "",
|
|
56
|
+
"relieved_by": r.relieved_by_name or "",
|
|
57
|
+
"warning": r.warning or "",
|
|
58
|
+
"is_international": dep in intl_set and not is_p2f,
|
|
59
|
+
"is_arr_international": arr in intl_set and not is_p2f,
|
|
60
|
+
"ops_class": ops_class,
|
|
61
|
+
# Only "redistributed" when both sides had a staff and
|
|
62
|
+
# they differ — a newly-filled slot is not a move.
|
|
63
|
+
"redistributed_from": (
|
|
64
|
+
prev_staff
|
|
65
|
+
if prev_staff and r.staff_name and prev_staff != r.staff_name
|
|
66
|
+
else ""
|
|
67
|
+
),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Gulf flights are extracted, never allocated. They show up
|
|
71
|
+
# under sheet="Removed" so the Allocations filter can reach them.
|
|
72
|
+
for r in state.cleaned.get(OpsClass.GULF, []):
|
|
73
|
+
rows_out.append({
|
|
74
|
+
"sheet": "Removed",
|
|
75
|
+
"date": r.date.isoformat(),
|
|
76
|
+
"flt": r.flt,
|
|
77
|
+
"dep": r.dep,
|
|
78
|
+
"arr": r.arr,
|
|
79
|
+
"std": r.std.isoformat(timespec="minutes"),
|
|
80
|
+
"pax": r.load,
|
|
81
|
+
"staff": "",
|
|
82
|
+
"planned_by": "",
|
|
83
|
+
"relieved_by": "",
|
|
84
|
+
"warning": "extracted — not allocated",
|
|
85
|
+
"is_international": False,
|
|
86
|
+
"is_arr_international": False,
|
|
87
|
+
"ops_class": "gulf",
|
|
88
|
+
"redistributed_from": "",
|
|
89
|
+
})
|
|
90
|
+
return {"rows": rows_out}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_workload(state: AppState) -> dict[str, Any]:
|
|
94
|
+
with state.lock:
|
|
95
|
+
return {"rows": [
|
|
96
|
+
{
|
|
97
|
+
"employee_id": w.employee_id,
|
|
98
|
+
"name": w.name,
|
|
99
|
+
"shift": w.shift or "",
|
|
100
|
+
"role": w.role.value,
|
|
101
|
+
"target_preferred": w.target_preferred,
|
|
102
|
+
"target_acceptable_max": w.target_acceptable_max,
|
|
103
|
+
"hard_cap": w.hard_cap,
|
|
104
|
+
"actual": w.actual,
|
|
105
|
+
"deviation_below_preferred": w.deviation_below_preferred,
|
|
106
|
+
"deviation_above_preferred": w.deviation_above_preferred,
|
|
107
|
+
"violations": "; ".join(w.violations),
|
|
108
|
+
}
|
|
109
|
+
for w in state.workload
|
|
110
|
+
]}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_pairs(state: AppState) -> dict[str, Any]:
|
|
114
|
+
with state.lock:
|
|
115
|
+
return {"rows": [
|
|
116
|
+
{
|
|
117
|
+
"boundary": p.boundary.value,
|
|
118
|
+
"pair_role": p.pair_role.value,
|
|
119
|
+
"preplan_count": p.preplan_count,
|
|
120
|
+
"prev_employee_id": p.prev_employee_id or "",
|
|
121
|
+
"prev_name": p.prev_name or "",
|
|
122
|
+
"prev_shift": p.prev_shift or "",
|
|
123
|
+
"next_employee_id": p.next_employee_id or "",
|
|
124
|
+
"next_name": p.next_name or "",
|
|
125
|
+
"next_shift": p.next_shift or "",
|
|
126
|
+
}
|
|
127
|
+
for p in state.pairs
|
|
128
|
+
]}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_warnings(state: AppState) -> dict[str, Any]:
|
|
132
|
+
with state.lock:
|
|
133
|
+
return {"rows": [
|
|
134
|
+
{
|
|
135
|
+
"severity": w.severity.value,
|
|
136
|
+
"code": w.code,
|
|
137
|
+
"name": w.name or "",
|
|
138
|
+
"date": w.date.isoformat() if w.date else "",
|
|
139
|
+
"message": w.message,
|
|
140
|
+
}
|
|
141
|
+
for w in state.warnings
|
|
142
|
+
]}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_unallocated(state: AppState) -> dict[str, Any]:
|
|
146
|
+
"""Every unallocated flight. ``count`` drives the prominent card;
|
|
147
|
+
an empty list means the UI shows the green all-allocated message."""
|
|
148
|
+
with state.lock:
|
|
149
|
+
rows = [dict(r) for r in state.unallocated]
|
|
150
|
+
return {"count": len(rows), "rows": rows}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_recommendations(state: AppState) -> dict[str, Any]:
|
|
154
|
+
"""Phase-R suggestions per unallocated flight. Stable empty shape
|
|
155
|
+
when the solver hasn't run, so the panel renders without erroring."""
|
|
156
|
+
with state.lock:
|
|
157
|
+
data = dict(state.recommendations)
|
|
158
|
+
data.setdefault("recommendations", [])
|
|
159
|
+
data.setdefault("n_unallocated", len(data["recommendations"]))
|
|
160
|
+
data.setdefault("ops_day", run_date_iso(state) or None)
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|