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.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. 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
+