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,1639 @@
1
+ """Step 4 — flight allocation orchestrator.
2
+
3
+ Reads from state:
4
+ cleaned flights (every ops class except GULF) — what there is to allocate
5
+ availability — who is on shift for D
6
+ overrides — per-day operator intent
7
+ the uploaded AM roster — AM/ZC rows (ZC shift recovery)
8
+
9
+ Builds:
10
+ StaffMember per assignable staff (STAFF + ZC; AM excluded)
11
+ EligibilityContext (P2F handler + flight times)
12
+ Sparse eligibility matrix
13
+ Pair list (deterministic from row order; secondary handover-only when
14
+ A surplus exists; split-1+2 at A→N; N→M cross-role for excess M-ZCs)
15
+
16
+ Solves with CP-SAT (hard constraints H1, H10, H16, H17;
17
+ S1+S3+S4+S5+S6+S7 enabled).
18
+
19
+ Writes back to state:
20
+ allocations (each row tagged with its DayOps / NightOps / P2F lane)
21
+ pairs, workload summary, unallocated list
22
+ warnings (W201/W210/W211 surfaced; appended to the Plan stage's)
23
+ recommendations for whatever could not be placed
24
+ the run-status stamp
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import time as _time
30
+ from collections import defaultdict
31
+ from datetime import date as date_t
32
+ from datetime import time as time_t
33
+ from datetime import timedelta as _td
34
+ from pathlib import Path
35
+
36
+ from .allocator.caps import aggregate_capacity
37
+ from .allocator.eligibility import (
38
+ EligibilityContext,
39
+ build_matrix,
40
+ diagnose_unassignable,
41
+ tag_awkward_window,
42
+ )
43
+ from .allocator.pairings import PairGenerationError, generate_pairs
44
+ from .allocator.postsolve import (
45
+ assemble_allocation_rows,
46
+ build_workload_summary,
47
+ )
48
+ from .allocator.windows import std_to_ops_day_minutes
49
+ from .config import Config, load_config
50
+ from .io.readers import (
51
+ read_p2f_nominations,
52
+ read_per_staff_overrides,
53
+ read_raise_cap_overrides,
54
+ read_role_change_overrides,
55
+ read_sick_overrides,
56
+ read_skip_intl_removal_overrides,
57
+ read_skip_p2f_buffer_overrides,
58
+ read_waive_h10_pairs,
59
+ )
60
+ from .io.roster_library import read_library
61
+ from .state import AppState
62
+ from .schemas import (
63
+ AllocationResult,
64
+ AMRosterRow,
65
+ AvailabilityRow,
66
+ CleanFlightRow,
67
+ CrewStatus,
68
+ FlightInput,
69
+ OpsClass,
70
+ Role,
71
+ Severity,
72
+ ShiftCode,
73
+ StaffMember,
74
+ WarningRow,
75
+ )
76
+ from .solver.allocator_cpsat import AllocationSolverResult, solve_allocation
77
+
78
+ # ---------- ZC shift recovery helpers ----------
79
+ # 2026-05-27 (centralization): these three maps are DERIVED from the
80
+ # central taxonomy in schemas.py — one source of truth. Step3's
81
+ # branching logic depends on them being three DISJOINT subsets of
82
+ # CrewStatus, so they stay as separate local lookups:
83
+ # _ZC_STATUS_TO_SHIFT — ZC-only variants (M/ZC, A/ZC, …)
84
+ # _P2F_HANDLER_STATUS_TO_SHIFT — plain P2F handler variants (no ZC)
85
+ # _ZC_P2F_HANDLER_STATUS_TO_SHIFT — combined ZC + P2F variants
86
+ # Adding a new ZC variant to STATUS_IS_ZC (in schemas.py) auto-flows
87
+ # here. Adding a new P2F-handler status to STATUS_TO_P2F_HANDLER_SHIFT
88
+ # auto-flows here. No more "I forgot to update this map" bugs.
89
+ from .schemas import (
90
+ STATUS_IS_ZC as _CENTRAL_ZC,
91
+ STATUS_TO_P2F_HANDLER_SHIFT as _CENTRAL_P2F_SHIFT,
92
+ STATUS_TO_SHIFT as _CENTRAL_SHIFT,
93
+ )
94
+
95
+ _ZC_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
96
+ s: _CENTRAL_SHIFT[s] # type: ignore[misc]
97
+ for s in _CENTRAL_ZC
98
+ if s not in _CENTRAL_P2F_SHIFT and _CENTRAL_SHIFT.get(s) is not None
99
+ }
100
+
101
+ _P2F_HANDLER_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
102
+ s: sh for s, sh in _CENTRAL_P2F_SHIFT.items()
103
+ if s not in _CENTRAL_ZC
104
+ }
105
+
106
+ _ZC_P2F_HANDLER_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
107
+ s: sh for s, sh in _CENTRAL_P2F_SHIFT.items()
108
+ if s in _CENTRAL_ZC
109
+ }
110
+
111
+
112
+ def _flight_key(flt: str, dep: str, arr: str, std_iso: str, date_iso: str) -> str:
113
+ """The key ``FlightInput.unique_id`` uses, built from loose parts."""
114
+ return f"{flt}|{dep}|{arr}|{std_iso}|{date_iso}"
115
+
116
+
117
+ def _prior_allocations_as_hints(
118
+ state: AppState,
119
+ name_to_id: dict[str, str],
120
+ ) -> dict[str, str]:
121
+ """Build ``{unique_id: employee_id}`` from the PREVIOUS solve's rows,
122
+ which are still on the state until this run overwrites them.
123
+
124
+ CP-SAT consumes this as a warm start, which drops re-solve wall
125
+ clock 30-60 %. Without it every Allocate runs cold even when the
126
+ prior assignments are 99 % still feasible — which is why a sick /
127
+ recommender re-solve used to take as long as the first solve.
128
+
129
+ Staff who have since been removed from the roster are skipped; the
130
+ solver reassigns their flights cold.
131
+ """
132
+ hints: dict[str, str] = {}
133
+ name_lookup = {k.strip().upper(): v for k, v in name_to_id.items()}
134
+ for r in state.allocations:
135
+ if not r.staff_name:
136
+ continue
137
+ eid = name_lookup.get(r.staff_name.strip().upper())
138
+ if not eid:
139
+ continue
140
+ hints[_flight_key(
141
+ r.flt, r.dep, r.arr,
142
+ r.std.isoformat(timespec="minutes"), r.date.isoformat(),
143
+ )] = eid
144
+ return hints
145
+
146
+
147
+ def _snapshot_prev_staff(state: AppState) -> dict[str, str]:
148
+ """``{flight_key: staff_name}`` for the run that is about to be
149
+ replaced. The Allocations tab uses it to flag rows whose staff
150
+ changed, so the assigner can verify a re-solve only touched the
151
+ people it was supposed to."""
152
+ return {
153
+ _flight_key(
154
+ r.flt, r.dep, r.arr,
155
+ r.std.isoformat(timespec="minutes"), r.date.isoformat(),
156
+ ): r.staff_name
157
+ for r in state.allocations if r.staff_name
158
+ }
159
+
160
+
161
+ def _read_auto_p2f_handlers_from_rosters(
162
+ am_roster_rows: list[AMRosterRow],
163
+ availability: list[AvailabilityRow],
164
+ d_day: date_t,
165
+ ) -> dict[ShiftCode, str]:
166
+ """Walk BOTH rosters for cells whose status is P2F/M, P2F/A, or
167
+ P2F/N on D. Returns {shift: employee_id}. First match per shift wins;
168
+ STAFF roster is scanned before AM roster so STAFF nominations take
169
+ precedence on conflict.
170
+
171
+ Per user direction 2026-05-10 (extended 2026-05-11): assigners
172
+ nominate P2F handlers via roster cells like `P2F/M` — works in
173
+ EITHER roster file. An override still wins on conflict
174
+ (manual nominations always trump auto-picks); previously this
175
+ function only scanned IN_AM_Roster, which is why STAFF roster
176
+ nominations were silently ignored.
177
+ """
178
+ auto: dict[ShiftCode, str] = {}
179
+ # 2026-05-26 (followup): walk availability for BOTH STAFF and ZC.
180
+ # A regular staff in IN_Staff_Roster with `N/P2F/ZC` cell becomes
181
+ # role=ZC + license=P2F via Step 2; if we restrict to STAFF here he
182
+ # never gets picked as a P2F handler. Combined ZC+P2F statuses are
183
+ # also checked alongside the legacy P2F/<shift> statuses.
184
+ for av in availability:
185
+ if av.date != d_day:
186
+ continue
187
+ if av.role not in (Role.STAFF, Role.ZC):
188
+ continue
189
+ shift = (
190
+ _P2F_HANDLER_STATUS_TO_SHIFT.get(av.status)
191
+ or _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(av.status)
192
+ )
193
+ if shift and shift not in auto:
194
+ auto[shift] = av.employee_id
195
+ for am in am_roster_rows:
196
+ cell_status = am.status_by_date.get(d_day)
197
+ if cell_status is None:
198
+ continue
199
+ # 2026-05-26: M/P2F/ZC (combined) cells also nominate the staff
200
+ # as the shift's P2F handler. Checked alongside the legacy
201
+ # M/P2F-style cells.
202
+ shift = (
203
+ _P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
204
+ or _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
205
+ )
206
+ if shift and shift not in auto:
207
+ auto[shift] = am.employee_id
208
+ return auto
209
+
210
+
211
+ def _build_staff_today(
212
+ availability: list[AvailabilityRow],
213
+ am_roster_rows: list[AMRosterRow],
214
+ d_day: date_t,
215
+ ) -> list[StaffMember]:
216
+ """Build StaffMember list for today's allocation.
217
+
218
+ STAFF rows: from the availability list (Step 2 output) — the roster as
219
+ submitted; no balance solver in between (per user direction 2026-05-11).
220
+ ZC rows: from IN_AM_Roster — shift recovered from that day's cell.
221
+ AM rows: dropped entirely (H13 — they don't fly).
222
+ """
223
+ out: list[StaffMember] = []
224
+ seen_ids: set[str] = set()
225
+ # 2026-05-26 (followup): admit BOTH STAFF and ZC rows from
226
+ # availability. Step 2 sets `role=ZC` per-day when the cell is
227
+ # `M/ZC`, `M/P2F/ZC`, etc. — even on rows originating in
228
+ # IN_Staff_Roster. Previously this loop filtered `role != STAFF`,
229
+ # so a regular staff put on ZC duty for the day was silently
230
+ # dropped from staff_today. The AM-roster fallback below dedupes
231
+ # via ``seen_ids`` so workbooks that ALSO list the same person in
232
+ # IN_AM_Roster don't double-count.
233
+ for r in availability:
234
+ if r.date != d_day:
235
+ continue
236
+ if r.role not in (Role.STAFF, Role.ZC):
237
+ continue
238
+ if not r.assignable or r.current_shift is None:
239
+ continue
240
+ is_p2f = bool(r.license and "P2F" in r.license.upper())
241
+ out.append(StaffMember(
242
+ employee_id=r.employee_id,
243
+ name=r.name,
244
+ role=r.role,
245
+ date=d_day,
246
+ shift_today=r.current_shift,
247
+ license=r.license,
248
+ is_p2f_licensed=is_p2f,
249
+ ))
250
+ seen_ids.add(r.employee_id)
251
+ # ZC + P2F-handler cells — classify PER-DAY (not whole-month).
252
+ # Cells matching a ZC variant (M/ZC, A/ZC...) put them on that
253
+ # shift as a ZC. Cells matching a P2F handler variant (P2F/M,
254
+ # P2F/A, P2F/N) put them on that shift, role=ZC, with P2F license
255
+ # auto-flagged. Per user direction 2026-05-10.
256
+ for am in am_roster_rows:
257
+ if am.employee_id in seen_ids:
258
+ # Already added via the availability loop above — skip to
259
+ # prevent double-counting when the same employee appears in
260
+ # both rosters (or when availability was rebuilt from
261
+ # IN_AM_Roster).
262
+ continue
263
+ cell_status = am.status_by_date.get(d_day)
264
+ if cell_status is None:
265
+ continue
266
+ recovered_shift = _ZC_STATUS_TO_SHIFT.get(cell_status)
267
+ license_val = am.license
268
+ is_p2f = bool(am.license and "P2F" in am.license.upper())
269
+ if recovered_shift is None:
270
+ # 2026-05-26: combined ZC+P2F cell? Same shift, role=ZC,
271
+ # is_p2f_licensed=True. The "handler" wiring is done in
272
+ # the auto-picker; here we only need to seat them as ZC
273
+ # with the P2F flag so eligibility passes.
274
+ recovered_shift = _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
275
+ if recovered_shift is not None:
276
+ is_p2f = True
277
+ license_val = (
278
+ license_val if license_val and "P2F" in license_val.upper()
279
+ else "P2F (auto from roster cell — combined ZC+P2F)"
280
+ )
281
+ else:
282
+ # Maybe a P2F-handler cell — same shift, with auto-license
283
+ recovered_shift = _P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
284
+ if recovered_shift is None:
285
+ # Today they're AM (M/IGT, A/IGT) or off — H13.
286
+ continue
287
+ # Mark them P2F-licensed for the day even if the master
288
+ # license list didn't say so — the roster cell is explicit.
289
+ is_p2f = True
290
+ license_val = (
291
+ license_val if license_val and "P2F" in license_val.upper()
292
+ else "P2F (auto from roster cell)"
293
+ )
294
+ out.append(StaffMember(
295
+ employee_id=am.employee_id,
296
+ name=am.name,
297
+ role=Role.ZC,
298
+ date=d_day,
299
+ shift_today=recovered_shift,
300
+ license=license_val,
301
+ is_p2f_licensed=is_p2f,
302
+ ))
303
+ return out
304
+
305
+
306
+ def _to_flight_inputs(
307
+ cleaned: list[CleanFlightRow], ops_day: date_t,
308
+ international_airport_codes: list[str] | None = None,
309
+ ) -> list[FlightInput]:
310
+ """Convert CleanFlightRow → FlightInput, tagging awkward windows and
311
+ international flights.
312
+
313
+ Phase 3 (2026-05-14, INTL overhaul):
314
+ - ``is_international`` is recomputed from config (DEP-side only —
315
+ Change 9). Pink ``routing_via_excluded_hub`` is gone.
316
+ - ``is_preplan_deferred`` is recomputed here from (date, std). The
317
+ cleaned-sheet schema does NOT store this flag (no column added,
318
+ per user direction), so when Step 3 reads back from the workbook
319
+ we lose ``c.is_preplan_deferred``. Derivation matches Step 1:
320
+ a flight is deferred iff date == ops_day+1 AND CUTOFF <= STD <=
321
+ DEFERRED_UPPER.
322
+ """
323
+ from datetime import timedelta
324
+ from .step1_clean_flights import CUTOFF, DEFERRED_UPPER
325
+ d_plus_1 = ops_day + timedelta(days=1)
326
+ intl_set = {c.upper() for c in (international_airport_codes or [])}
327
+ out: list[FlightInput] = []
328
+ for c in cleaned:
329
+ dep_u = c.dep.upper()
330
+ # Change 9: DEP-side only.
331
+ is_intl = dep_u in intl_set
332
+ # Change 7 redux: derive deferred from (date, std).
333
+ is_deferred = (c.date == d_plus_1 and CUTOFF <= c.std <= DEFERRED_UPPER)
334
+ f = FlightInput(
335
+ date=c.date, flt=c.flt, type=c.type, ac=c.ac,
336
+ dep=c.dep, arr=c.arr, std=c.std, load=c.load,
337
+ ops_class=c.ops_class,
338
+ is_international=is_intl,
339
+ is_preplan_deferred=is_deferred,
340
+ )
341
+ out.append(tag_awkward_window(f, ops_day))
342
+ return out
343
+
344
+
345
+ def _build_p2f_handler_anchors(
346
+ flights: list[FlightInput],
347
+ p2f_handler_by_shift: dict[ShiftCode, str],
348
+ ops_day: date_t,
349
+ ) -> dict[str, list[int]]:
350
+ """Compute the list of P2F flight STDs (in ops-day minutes) per
351
+ handler — drives the H4 buffer (F6 in eligibility: no normal
352
+ flights from STD-2hrs to STD+1hr)."""
353
+ out: dict[str, list[int]] = defaultdict(list)
354
+ # Group P2F flights by their shift's handler.
355
+ for f in flights:
356
+ if f.ops_class != OpsClass.P2F:
357
+ continue
358
+ # The handler is whichever shift owns this STD — but we don't
359
+ # know shift here without a staff member. Best heuristic: use
360
+ # the shift whose nominal window contains the STD.
361
+ from .allocator.windows import SHIFT_NOMINAL_MIN
362
+ std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
363
+ for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
364
+ if start <= std_min <= end:
365
+ handler = p2f_handler_by_shift.get(shift)
366
+ if handler:
367
+ out[handler].append(std_min)
368
+ break
369
+ return dict(out)
370
+
371
+
372
+ def run(
373
+ state: AppState,
374
+ d_day: date_t,
375
+ config_path: Path | str = "configs/config.yml",
376
+ *,
377
+ append_warnings: bool = True,
378
+ mode_label: str = "Step 4",
379
+ solution_hints: dict[str, str] | None = None,
380
+ ) -> dict[str, int]:
381
+ """Step 4 orchestrator. Reads state, runs the solver, writes back.
382
+
383
+ Returns a count dict with FLIGHTS, ASSIGNED, UNALLOCATED, PAIRS,
384
+ WARNINGS, and FEASIBLE for the caller to display.
385
+
386
+ ``solution_hints`` is the previous run's ``{unique_id: employee_id}``
387
+ map, used as a CP-SAT warm start. When None it is auto-built from
388
+ the allocations still on the state. Hints are silently ignored if no
389
+ longer feasible.
390
+ """
391
+ config: Config = load_config(config_path)
392
+ started = _time.monotonic()
393
+ warnings: list[WarningRow] = []
394
+ counts: dict[str, int] = {
395
+ "FLIGHTS": 0, "ASSIGNED": 0, "UNALLOCATED": 0,
396
+ "PAIRS": 0, "WARNINGS": 0, "FEASIBLE": 0,
397
+ }
398
+
399
+ # --- Read inputs ----------
400
+ cleaned = state.allocatable_cleaned()
401
+ availability = list(state.availability)
402
+ overrides = list(state.overrides)
403
+ prev_staff_by_key = _snapshot_prev_staff(state)
404
+
405
+ am_rows = read_library(state, "am_roster", config, [d_day])
406
+
407
+ # Build a name -> employee_id map for resolving override rows. Names
408
+ # come from BOTH availability (STAFF) AND the AM roster (AM/ZC).
409
+ name_to_id: dict[str, str] = {}
410
+ for av in availability:
411
+ if av.date == d_day and av.name:
412
+ name_to_id[av.name.strip().upper()] = av.employee_id
413
+ for amr in am_rows:
414
+ if amr.name:
415
+ name_to_id[amr.name.strip().upper()] = amr.employee_id
416
+
417
+ if solution_hints is None:
418
+ solution_hints = _prior_allocations_as_hints(state, name_to_id)
419
+ if solution_hints:
420
+ print(
421
+ f" solver: auto-loaded {len(solution_hints)} warm-start "
422
+ "hints from the previous allocation"
423
+ )
424
+
425
+ # Step-4-specific overrides (date is implicit from d_day).
426
+ p2f_nominations = read_p2f_nominations(overrides, d_day, name_to_id)
427
+ per_staff = read_per_staff_overrides(overrides, d_day, name_to_id)
428
+ # Per-day "sick" pulls a staff out of today's allocation. Applied
429
+ # below once staff_today is built — we zero their shift_today so
430
+ # eligibility treats them as off, and the solver redistributes
431
+ # their flights automatically. Logged so "I marked X sick but they
432
+ # still got flights" has a diagnostic trail.
433
+ sick_employee_ids = read_sick_overrides(overrides, d_day, name_to_id)
434
+ if sick_employee_ids:
435
+ print(
436
+ f" sick overrides: {len(sick_employee_ids)} row(s) — "
437
+ f"{sick_employee_ids[:5]}"
438
+ + (f" ... (+{len(sick_employee_ids) - 5} more)"
439
+ if len(sick_employee_ids) > 5 else "")
440
+ )
441
+ # Per-day role change: {employee_id: "STAFF"|"ZC"|"AM"}. Applied
442
+ # right after the sick filter; STAFF -> ZC switches the H18 bucket
443
+ # so they sit at 14-15, AM makes them ineligible (everything they
444
+ # were carrying redistributes via the pin re-solve).
445
+ role_changes = read_role_change_overrides(overrides, d_day, name_to_id)
446
+ # Phase R per-flight overrides — converted into constraint-side sets
447
+ # below once FlightInput unique_ids are resolved.
448
+ waive_h10_rows = read_waive_h10_pairs(overrides, d_day, name_to_id)
449
+ raise_cap_rows = read_raise_cap_overrides(overrides, d_day, name_to_id)
450
+ skip_p2f_buffer_rows = read_skip_p2f_buffer_overrides(
451
+ overrides, d_day, name_to_id,
452
+ )
453
+ skip_intl_removal_rows = read_skip_intl_removal_overrides(overrides, d_day)
454
+
455
+ flights = _to_flight_inputs(
456
+ cleaned, d_day,
457
+ international_airport_codes=config.io.sv_portal.international_airport_codes,
458
+ )
459
+ counts["FLIGHTS"] = len(flights)
460
+
461
+ staff_today = _build_staff_today(availability, am_rows, d_day)
462
+
463
+ # 2026-05-27 diagnostic: surface staff_today shape early so a
464
+ # mis-applied override (sick / change_role / etc. flipping
465
+ # assignable=False for everyone) is visible in the console
466
+ # instead of producing "2263 unallocated" with no explanation.
467
+ from collections import Counter as _Counter
468
+ _shift_counter = _Counter(
469
+ (s.shift_today or "OFF", s.role.value) for s in staff_today
470
+ )
471
+ _assignable = sum(
472
+ 1 for s in staff_today
473
+ if s.shift_today is not None
474
+ )
475
+ print(
476
+ f" staff_today: total={len(staff_today)} on-shift={_assignable} "
477
+ f" flights to allocate: {len(flights)}"
478
+ )
479
+ for (sh, role), n in sorted(_shift_counter.items()):
480
+ print(f" {sh:>4} / {role:<6}: {n}")
481
+ if _assignable == 0:
482
+ print(
483
+ " !! WARNING: ZERO staff on shift today. Every flight will "
484
+ "be UNALLOCATED. Likely cause: a `sick` / `remove_staff` row "
485
+ "in the override list matched everyone, or every roster row has "
486
+ "assignable=False. Check the Override drawer and the "
487
+ "uploaded rosters."
488
+ )
489
+
490
+ # 2026-05-18 (user direction): apply "sick" override — flip
491
+ # shift_today to None for every employee in the sick list. The
492
+ # rest of the pipeline (eligibility, pair generation, solver)
493
+ # treats them as off today, so their flights redistribute to
494
+ # remaining staff on the same shift on the next solve. Done BEFORE
495
+ # the per-staff overrides loop so a sick staff with a max_flights
496
+ # override still ends up off.
497
+ if sick_employee_ids:
498
+ sick_set = set(sick_employee_ids)
499
+ # Resolve names that didn't match an employee_id by scanning
500
+ # roster names (case-insensitive, internal-whitespace-collapsed).
501
+ # 2026-05-28: switched to the centralized _normalize_name helper
502
+ # so 'AKSHAT CHATURVEDI' (double-space) and 'akshat chaturvedi'
503
+ # both resolve to the same key — previously a sick override with
504
+ # mismatched whitespace silently failed to remove the staff.
505
+ from .staged_overrides import _normalize_name
506
+ name_lookup = {_normalize_name(s.name): s.employee_id for s in staff_today}
507
+ resolved = set()
508
+ unresolved: list[str] = []
509
+ for sid in sick_set:
510
+ if any(s.employee_id == sid for s in staff_today):
511
+ resolved.add(sid)
512
+ elif _normalize_name(sid) in name_lookup:
513
+ resolved.add(name_lookup[_normalize_name(sid)])
514
+ else:
515
+ unresolved.append(sid)
516
+ if resolved:
517
+ sick_names = [s.name for s in staff_today if s.employee_id in resolved]
518
+ staff_today = [
519
+ s.model_copy(update={"shift_today": None})
520
+ if s.employee_id in resolved else s
521
+ for s in staff_today
522
+ ]
523
+ print(
524
+ f" Sick override: removed {len(resolved)} staff from today's "
525
+ f"allocation: {', '.join(sick_names)}"
526
+ )
527
+ if unresolved:
528
+ # Loud failure so the user knows their sick override didn't
529
+ # take effect. Common causes: name typo, staff not on shift
530
+ # today (already off), or no matching id in roster.
531
+ print(
532
+ f" !! Sick override WARNING: {len(unresolved)} sick "
533
+ f"row(s) could NOT be resolved to staff_today rows: "
534
+ f"{unresolved}. These staff will still be allocated "
535
+ "flights — fix the name/id in the Override drawer and re-run."
536
+ )
537
+
538
+ # 2026-05-18 (user direction): apply role-change override. Maps
539
+ # employee_id -> new Role enum. Switching to ZC reroutes them
540
+ # into the ZC bucket (H18 target 14-15 day, 11 N); switching to
541
+ # AM zeros shift_today (AMs don't fly — same effect as sick);
542
+ # promoting AM back to STAFF/ZC needs the AM roster row to have
543
+ # carried a shift, so they were already in staff_today (the
544
+ # _build_staff_today loop includes AMs as off-pool entries).
545
+ if role_changes:
546
+ name_lookup = {s.name.strip().upper(): s.employee_id for s in staff_today}
547
+ resolved_changes: dict[str, str] = {}
548
+ for raw_eid, new_role_str in role_changes.items():
549
+ if any(s.employee_id == raw_eid for s in staff_today):
550
+ resolved_changes[raw_eid] = new_role_str
551
+ elif raw_eid.upper() in name_lookup:
552
+ resolved_changes[name_lookup[raw_eid.upper()]] = new_role_str
553
+ if resolved_changes:
554
+ new_staff_today: list[StaffMember] = []
555
+ change_log: list[str] = []
556
+ for s in staff_today:
557
+ new_role_str = resolved_changes.get(s.employee_id)
558
+ if new_role_str is None:
559
+ new_staff_today.append(s)
560
+ continue
561
+ new_role = Role(new_role_str)
562
+ # Promoting to AM == off the live pool. STAFF<->ZC is
563
+ # just a role swap; shift stays the same so the H18
564
+ # bucket auto-routes them based on the new role.
565
+ update_dict: dict = {"role": new_role}
566
+ if new_role == Role.AM:
567
+ update_dict["shift_today"] = None
568
+ new_staff_today.append(s.model_copy(update=update_dict))
569
+ change_log.append(f"{s.name} ({s.role.value}->{new_role.value})")
570
+ staff_today = new_staff_today
571
+ print(
572
+ f" Role-change override: {len(resolved_changes)} staff "
573
+ f"re-roled: {', '.join(change_log)}"
574
+ )
575
+
576
+ # Phase 3 / Change 7 (2026-05-14, INTL overhaul): D+1 flights with
577
+ # STD ∈ [05:05, 05:30] are flagged ``is_preplan_deferred=True`` by
578
+ # Step 1. They enter the pool but are split out HERE before the
579
+ # solver runs — re-appended at the end as warning="PREPLAN_DEFERRED"
580
+ # rows with empty staff column. Solver never sees them.
581
+ preplan_only_flights: list[FlightInput] = [
582
+ f for f in flights if f.is_preplan_deferred
583
+ ]
584
+ if preplan_only_flights:
585
+ pre_ids = {f.unique_id for f in preplan_only_flights}
586
+ flights = [f for f in flights if f.unique_id not in pre_ids]
587
+ counts["FLIGHTS"] -= len(preplan_only_flights)
588
+ print(
589
+ f" Pre-plan-deferred flights split out: "
590
+ f"{len(preplan_only_flights)} (D+1 STD 05:05-05:30; "
591
+ "re-appended with warning=PREPLAN_DEFERRED)"
592
+ )
593
+
594
+ # Apply per-staff override rows (newbie flag, max_flights,
595
+ # std_start, std_cutoff). The cutoff_time row carries BOTH bounds
596
+ # since 2026-05-27 — std_start is the lower bound (custom window
597
+ # start), std_cutoff is the upper bound (custom window end).
598
+ per_staff_today = {p.employee_id: p for p in per_staff if p.date == d_day}
599
+ if per_staff_today:
600
+ staff_today = [
601
+ s.model_copy(update={
602
+ "is_newbie": per_staff_today[s.employee_id].is_newbie or s.is_newbie,
603
+ "max_flights_cap": per_staff_today[s.employee_id].max_flights or s.max_flights_cap,
604
+ "std_cutoff": per_staff_today[s.employee_id].std_cutoff or s.std_cutoff,
605
+ "std_start": per_staff_today[s.employee_id].std_start or s.std_start,
606
+ }) if s.employee_id in per_staff_today else s
607
+ for s in staff_today
608
+ ]
609
+
610
+ # --- Pre-solve capacity check (W210) ----------
611
+ # Soft behavior (post-2026-05-10): if aggregate capacity falls short
612
+ # of flight count, log W210 as ERROR so the assigner sees the gap,
613
+ # but DON'T abort. The solver places what it can; the rest land in
614
+ # UNALLOCATED. Previously this aborted entirely, which masked
615
+ # otherwise-good runs that just had a small capacity shortfall.
616
+ cap = aggregate_capacity(staff_today)
617
+ if cap < len(flights):
618
+ gap = len(flights) - cap
619
+ warnings.append(WarningRow(
620
+ severity=Severity.ERROR, code="W210",
621
+ message=(
622
+ f"aggregate capacity ({cap}) < flight count ({len(flights)}); "
623
+ f"~{gap} flights will be UNALLOCATED. Add staff, raise H16 caps, "
624
+ "or reduce flights."
625
+ ),
626
+ ))
627
+
628
+ # --- Build eligibility context ----------
629
+ # P2F handler resolution order (per user direction 2026-05-10):
630
+ # 1. AUTO from roster cells P2F/M, P2F/A, P2F/N — first match per
631
+ # shift wins.
632
+ # 2. override `p2f` rows — overwrite the auto-pick if the
633
+ # assigner explicitly nominates a different person.
634
+ staff_by_id = {s.employee_id: s for s in staff_today}
635
+ auto_p2f = _read_auto_p2f_handlers_from_rosters(am_rows, availability, d_day)
636
+ p2f_handler_by_shift: dict[ShiftCode, str] = dict(auto_p2f)
637
+ if auto_p2f:
638
+ print(
639
+ f" auto-picked P2F handlers from roster cells: "
640
+ f"{ {k: v for k, v in auto_p2f.items()} }"
641
+ )
642
+ # Compute which shifts actually have P2F flights in their window —
643
+ # those are the shifts where a missing nomination is a real problem.
644
+ from .allocator.windows import SHIFT_NOMINAL_MIN
645
+ # Per user direction 2026-05-10: P2F handlers are M/A/N only.
646
+ # M1/A1 windows are intentionally NOT counted — those P2F flights
647
+ # have no handler and will land in OUT_Unallocated if their STD
648
+ # doesn't also fall in an M/A/N coverage window.
649
+ p2f_shifts_needing_handler: set[ShiftCode] = set()
650
+ # Per-shift P2F flight count for W215 (8-flight ceiling per handler).
651
+ p2f_count_by_shift: dict[ShiftCode, int] = defaultdict(int)
652
+ # 2026-05-27 (centralization): pulled from schemas — single source
653
+ # of truth for which shifts nominate plain P2F handlers.
654
+ from .schemas import P2F_HANDLER_ELIGIBLE_SHIFTS
655
+ p2f_relevant_shifts = P2F_HANDLER_ELIGIBLE_SHIFTS
656
+ for f in flights:
657
+ if f.ops_class != OpsClass.P2F:
658
+ continue
659
+ std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
660
+ for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
661
+ if shift not in p2f_relevant_shifts:
662
+ continue
663
+ if start <= std_min <= end:
664
+ p2f_shifts_needing_handler.add(shift)
665
+ p2f_count_by_shift[shift] += 1
666
+ break
667
+
668
+ # W215: shifts with > 8 P2F flights need an additional nominee.
669
+ P2F_HANDLER_CAP = 8 # noqa: N806
670
+ for shift, count in sorted(p2f_count_by_shift.items()):
671
+ if count > P2F_HANDLER_CAP:
672
+ extra_needed = -(-count // P2F_HANDLER_CAP) - 1 # ceil-1
673
+ warnings.append(WarningRow(
674
+ severity=Severity.WARN, code="W215", date=d_day,
675
+ message=(
676
+ f"Shift {shift} has {count} P2F flights; one handler "
677
+ f"can do max {P2F_HANDLER_CAP}. Nominate "
678
+ f"{extra_needed} additional P2F handler(s) for {shift} "
679
+ f"in the Override drawer (type=p2f, shift={shift}, employee=<name>)."
680
+ ),
681
+ ))
682
+ for nom in p2f_nominations:
683
+ if nom.date != d_day:
684
+ continue
685
+ s = staff_by_id.get(nom.employee_id)
686
+ if s is None:
687
+ warnings.append(WarningRow(
688
+ severity=Severity.ERROR, code="W212", date=d_day,
689
+ message=(
690
+ f"P2F nomination for shift {nom.shift}: "
691
+ f"employee_id {nom.employee_id} not on the roster today. "
692
+ "Edit the overrides or check the staff roster."
693
+ ),
694
+ ))
695
+ continue
696
+ if s.shift_today != nom.shift:
697
+ warnings.append(WarningRow(
698
+ severity=Severity.ERROR, code="W212", date=d_day,
699
+ name=s.name,
700
+ message=(
701
+ f"P2F nomination says {nom.employee_id} ({s.name}) "
702
+ f"is on {nom.shift} today, but the roster has them on "
703
+ f"{s.shift_today or 'OFF'}. "
704
+ "Edit the overrides or fix the roster."
705
+ ),
706
+ ))
707
+ continue
708
+ if not s.is_p2f_licensed:
709
+ # 2026-05-27 (user direction): a manual P2F nomination is
710
+ # the assigner saying "this person handles P2F today" —
711
+ # don't reject for a missing license cell on the roster.
712
+ # Auto-flip is_p2f_licensed for the day and emit a WARN
713
+ # (not ERROR) so the assigner still sees the nomination
714
+ # was made for someone the roster doesn't tag as P2F.
715
+ print(
716
+ f" P2F nomination: {s.name} ({nom.shift}) — auto-granting "
717
+ "P2F license for today (roster license cell didn't say P2F)"
718
+ )
719
+ staff_today = [
720
+ _s.model_copy(update={
721
+ "is_p2f_licensed": True,
722
+ "license": (
723
+ _s.license if _s.license and "P2F" in _s.license.upper()
724
+ else "P2F (auto from override nomination)"
725
+ ),
726
+ }) if _s.employee_id == nom.employee_id else _s
727
+ for _s in staff_today
728
+ ]
729
+ # Refresh the local references so subsequent loop iterations
730
+ # see the patched staff.
731
+ staff_by_id = {_s.employee_id: _s for _s in staff_today}
732
+ warnings.append(WarningRow(
733
+ severity=Severity.WARN, code="W212", date=d_day,
734
+ name=s.name,
735
+ message=(
736
+ f"P2F nomination for {s.name}: roster license didn't "
737
+ "include 'P2F', auto-granted for today via override. "
738
+ "Update the roster's license cell to silence this warning."
739
+ ),
740
+ ))
741
+ p2f_handler_by_shift[nom.shift] = nom.employee_id
742
+
743
+ # Warn loudly for any shift that has P2F flights but no valid nomination.
744
+ for shift in sorted(p2f_shifts_needing_handler):
745
+ if shift not in p2f_handler_by_shift:
746
+ warnings.append(WarningRow(
747
+ severity=Severity.ERROR, code="W212", date=d_day,
748
+ message=(
749
+ f"P2F flights exist for shift {shift} but no valid "
750
+ "P2F handler nomination found in the overrides. "
751
+ "Add a row: type=p2f_handler, date={D}, shift={shift}, "
752
+ "employee_id=<P2F-licensed staff on shift>."
753
+ ),
754
+ ))
755
+
756
+ p2f_anchors = _build_p2f_handler_anchors(flights, p2f_handler_by_shift, d_day)
757
+
758
+ # ---- Phase R per-flight override sets ----
759
+ # Build a (flt, std_iso) → unique_id resolver so override rows
760
+ # (which key by flt+std) map onto the solver's unique_id keying.
761
+ uid_by_flt_std: dict[tuple[str, str], str] = {
762
+ (f.flt, f.std.isoformat(timespec="minutes")): f.unique_id
763
+ for f in flights
764
+ }
765
+
766
+ def _resolve_uid(flt: str, std_iso) -> str | None:
767
+ key = (flt, std_iso if isinstance(std_iso, str)
768
+ else std_iso.isoformat(timespec="minutes"))
769
+ return uid_by_flt_std.get(key)
770
+
771
+ # SkipP2FBuffer → set of (flight_uid, emp_id) tuples for elig F6
772
+ skip_p2f_buffer_pairs: set[tuple[str, str]] = set()
773
+ for r in skip_p2f_buffer_rows:
774
+ flight_key = f"{r.flight}|{r.std.isoformat(timespec='minutes')}"
775
+ skip_p2f_buffer_pairs.add((flight_key, r.employee_id))
776
+
777
+ # SkipINTLRemoval → set of flight_keys (no employee dimension)
778
+ skip_intl_removal_keys: set[str] = set()
779
+ for r in skip_intl_removal_rows:
780
+ skip_intl_removal_keys.add(
781
+ f"{r.flight}|{r.std.isoformat(timespec='minutes')}"
782
+ )
783
+
784
+ # WaiveH10Pair → set of (uid_a, uid_b, emp_id). We need to resolve
785
+ # both flights' unique_ids. ``other_std`` is on the SAME staff so
786
+ # we scan their existing flights to find it.
787
+ waive_h10_triples: set[tuple[str, str, str]] = set()
788
+ for r in waive_h10_rows:
789
+ uid_a = _resolve_uid(r.flight, r.std)
790
+ if uid_a is None:
791
+ continue
792
+ # other flight: same staff, std = other_std; resolve by scanning
793
+ # flights for matching std (any FLT acceptable — the OTHER side
794
+ # is identified by STD on the same staff at the constraint
795
+ # level, not by FLT)
796
+ other_iso = r.other_std.isoformat(timespec="minutes")
797
+ # Find ANY flight at that std — if the staff has multiple at
798
+ # the same std, all pairs get the waiver.
799
+ for f in flights:
800
+ if f.std.isoformat(timespec="minutes") != other_iso:
801
+ continue
802
+ waive_h10_triples.add(
803
+ (uid_a, f.unique_id, r.employee_id),
804
+ )
805
+ waive_h10_triples.add(
806
+ (f.unique_id, uid_a, r.employee_id), # symmetric
807
+ )
808
+
809
+ # RaiseCapForFlight → set of (flight_uid, emp_id)
810
+ raise_cap_uids: set[tuple[str, str]] = set()
811
+ for r in raise_cap_rows:
812
+ uid = _resolve_uid(r.flight, r.std)
813
+ if uid is None:
814
+ continue
815
+ raise_cap_uids.add((uid, r.employee_id))
816
+
817
+ if any([
818
+ skip_p2f_buffer_pairs, skip_intl_removal_keys,
819
+ waive_h10_triples, raise_cap_uids,
820
+ ]):
821
+ print(
822
+ " Phase R overrides loaded: "
823
+ f"waive_h10={len(waive_h10_rows)}, raise_cap={len(raise_cap_rows)}, "
824
+ f"skip_p2f_buffer={len(skip_p2f_buffer_rows)}, "
825
+ f"skip_intl_removal={len(skip_intl_removal_rows)}"
826
+ )
827
+
828
+ elig_ctx = EligibilityContext(
829
+ ops_day=d_day,
830
+ p2f_handler_by_shift=p2f_handler_by_shift,
831
+ p2f_flight_minutes_by_handler=p2f_anchors,
832
+ skip_p2f_buffer_pairs=frozenset(skip_p2f_buffer_pairs),
833
+ )
834
+
835
+ # --- Eligibility matrix + zero-eligibility check (W201) ----------
836
+ # Soft behavior (post-2026-05-10): flights with zero eligibility are
837
+ # logged as W201 and EXCLUDED from the solver's input. The remaining
838
+ # flights still proceed. Previously this aborted the entire run.
839
+ matrix = build_matrix(flights, staff_today, elig_ctx)
840
+ # Keyed by FlightInput.unique_id (matches eligibility matrix output).
841
+ zero_elig_flights = [f for f in flights if not matrix.get(f.unique_id)]
842
+ zero_elig_reasons: dict[str, str] = {}
843
+ for f in zero_elig_flights:
844
+ diags = diagnose_unassignable(f, staff_today, elig_ctx)
845
+ # If EVERY staff failed F5 (OUT_OF_SHIFT) the flight's STD lies
846
+ # outside every shift's distribution window — surface the exact
847
+ # message the user asked for in OUT_Unallocated (2026-05-12 §1).
848
+ if diags and all(r.value == "STD outside shift's distribution window (user 2026-05-12)"
849
+ for _eid, r in diags):
850
+ top_reason = "STD outside all shift windows"
851
+ else:
852
+ top_reason = diags[0][1].value if diags else "no eligible staff"
853
+ zero_elig_reasons[f.unique_id] = top_reason
854
+ # Phase 3 / Change 4 (2026-05-14, INTL overhaul): when an INTL DEP
855
+ # flight is unassignable specifically because no on-shift handler
856
+ # covers D-75 → STD, emit W216 INTL_UNASSIGNABLE (in addition to
857
+ # the generic W201). Gives the assigner a sharper signal.
858
+ is_intl_coverage_failure = (
859
+ f.is_international
860
+ and diags
861
+ and all(
862
+ r.name == "INTL_SHIFT_COVERAGE"
863
+ for _eid, r in diags
864
+ )
865
+ )
866
+ if is_intl_coverage_failure:
867
+ warnings.append(WarningRow(
868
+ severity=Severity.ERROR, code="W216", date=f.date,
869
+ message=(
870
+ f"INTL flight {f.flt} (STD "
871
+ f"{f.std.isoformat(timespec='minutes')}, DEP {f.dep}) "
872
+ "is unassignable — no on-shift handler covers D-75 "
873
+ "through STD. Add an override or reshape the roster."
874
+ ),
875
+ ))
876
+ warnings.append(WarningRow(
877
+ severity=Severity.ERROR, code="W201", date=f.date,
878
+ message=(
879
+ f"flight {f.flt} (STD {f.std.isoformat(timespec='minutes')}) "
880
+ f"has zero eligible staff — left UNALLOCATED. Top diagnoses: "
881
+ + "; ".join(f"{eid}: {r.value}" for eid, r in diags[:3])
882
+ ),
883
+ ))
884
+ if zero_elig_flights:
885
+ zero_ids = {f.unique_id for f in zero_elig_flights}
886
+ flights = [f for f in flights if f.unique_id not in zero_ids]
887
+ # Drop their entries from the matrix too — solver only sees viable.
888
+ matrix = {fid: s for fid, s in matrix.items() if fid not in zero_ids}
889
+
890
+ # --- Pair generation (W211 on PairGenerationError) ----------
891
+ # 2026-05-28 (Phase 2 default): generate_pairs hoists today's P2F
892
+ # nominees to the front of M / A / N STAFF lists so they pair
893
+ # across boundaries (Change 2), and folds orphan ZCs into the
894
+ # STAFF pool when ZC counts mismatch (Change 3 — A↔A1 ZC pair +
895
+ # STAFF spillover). The behavior is unconditional now — empty
896
+ # p2f_handlers / balanced ZCs degrade gracefully.
897
+ try:
898
+ pairs = generate_pairs(
899
+ staff_today, overrides=None,
900
+ p2f_handlers=elig_ctx.p2f_handler_by_shift,
901
+ )
902
+ # Task 3 (2026-05-12): scan pairs for staff with multiple
903
+ # partners at the same boundary — flag everything except the
904
+ # sanctioned A→N split-1+2 pattern. W215 warnings flow into
905
+ # OUT_Warnings + the dashboard panel.
906
+ from .allocator.pair_validation import validate_pair_discrepancies
907
+ warnings.extend(validate_pair_discrepancies(pairs, d_day))
908
+ except PairGenerationError as e:
909
+ warnings.append(WarningRow(
910
+ severity=Severity.ERROR, code="W211", message=str(e),
911
+ ))
912
+ _store_warnings(state, warnings, append=append_warnings)
913
+ state.run_date = d_day
914
+ state.stamp_run(
915
+ mode=mode_label, solver_status="INFEASIBLE",
916
+ duration_s=_time.monotonic() - started,
917
+ )
918
+ counts["WARNINGS"] = len(warnings)
919
+ return counts
920
+ counts["PAIRS"] = len(pairs)
921
+
922
+ # --- Day-level workload target (Finding 1, 2026-05-15) ------------
923
+ # The manual allocator picks ONE workload level per day and pins
924
+ # almost every non-handler staff to it (everyone-at-22 on light
925
+ # days, everyone-at-24 on heavy ones). Solver matches this by
926
+ # computing the level from the day's flight-to-staff ratio and
927
+ # using it as the target in S1. See MANUAL_PATTERNS_VS_ENGINE.md
928
+ # Finding 1 for evidence.
929
+ #
930
+ # Per (shift, role): level = round(total_flights / non_handler_staff_count),
931
+ # clipped to [band.min, band.max] from configs/shift_limits.json.
932
+ # P2F handlers are excluded from the bucket —
933
+ # they're already shape-shifted by their handler-specific rules.
934
+ #
935
+ # 2026-09-23 fix: ``total_flights`` above must be the flights that
936
+ # fall in THAT shift's own window, not the whole day's flight
937
+ # count. The previous version divided every bucket by the SAME
938
+ # global ratio (len(flights) / all-shifts' staff), so a shift with
939
+ # a much lighter night-time flight volume (e.g. N: ~169 flights /
940
+ # 18 staff =~ 9-10 each) still got handed the day-wide ratio
941
+ # (~21, driven by the much busier M/A shifts) before band-clipping
942
+ # ever saw it. Clipped up to band.min, that level was unreachable
943
+ # for anyone in the bucket, weakening S1's ability to pull the
944
+ # bucket together (2026-09-23: N/STAFF spread of 5-11 survived a
945
+ # 700s solve). Bucketing flights by their own shift window (same
946
+ # windowing the solver enforces via eligibility) gives each shift
947
+ # its own realistic ratio instead.
948
+ from .allocator.windows import SHIFT_NOMINAL_MIN as _SHIFT_NOMINAL_MIN
949
+ flights_per_shift_min: dict[ShiftCode, int] = defaultdict(int)
950
+ for f in flights:
951
+ f_std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
952
+ for shift_code_iter, (s_start, s_end) in _SHIFT_NOMINAL_MIN.items():
953
+ if s_start <= f_std_min <= s_end:
954
+ flights_per_shift_min[shift_code_iter] += 1
955
+ break
956
+ handler_set = set(p2f_handler_by_shift.values())
957
+ bucket_sizes: dict[tuple[ShiftCode, Role], int] = defaultdict(int)
958
+ staff_per_shift: dict[ShiftCode, int] = defaultdict(int)
959
+ for s in staff_today:
960
+ if (s.shift_today is None or s.role == Role.AM
961
+ or s.employee_id in handler_set):
962
+ continue
963
+ bucket_sizes[(s.shift_today, s.role)] += 1
964
+ staff_per_shift[s.shift_today] += 1
965
+ from .allocator.caps import get_band as _get_band
966
+ day_level_by_bucket: dict[tuple[ShiftCode, Role], int] = {}
967
+ for (shift_code, role), size in bucket_sizes.items():
968
+ shift_staff_count = staff_per_shift.get(shift_code) or 1
969
+ raw_level = round(
970
+ flights_per_shift_min.get(shift_code, 0) / shift_staff_count
971
+ )
972
+ band = _get_band(shift_code, role)
973
+ if band is None:
974
+ day_level_by_bucket[(shift_code, role)] = raw_level
975
+ continue
976
+ lo, hi = band["min"], band["max"]
977
+ day_level_by_bucket[(shift_code, role)] = max(lo, min(raw_level, hi))
978
+ if day_level_by_bucket:
979
+ print(
980
+ " Day-level targeting (per-shift ratio, 2026-09-23 fix): "
981
+ + ", ".join(
982
+ f"{k[0]}/{k[1].value}={v} "
983
+ f"(flights_in_shift={flights_per_shift_min.get(k[0], 0)}, "
984
+ f"staff_in_shift={staff_per_shift.get(k[0], 0)})"
985
+ for k, v in sorted(day_level_by_bucket.items())
986
+ )
987
+ )
988
+
989
+ # --- Sick-call iteration: pin prior allocation, redistribute only
990
+ # the sick person's flights (2026-05-18, user direction) ---------
991
+ # When `sick_employee_ids` is non-empty AND the console workbook
992
+ # already has allocation rows from a prior solve, build a
993
+ # PIN SET: every (flight_uid, employee_id) that was previously
994
+ # assigned to a NON-sick staff. Pass to the solver — those pairs
995
+ # are forced to x=1 (hard constraint), so only the sick person's
996
+ # flights have free decision vars. Solver redistributes ONLY them
997
+ # to remaining staff. Minimum disruption to the existing plan.
998
+ pinned_assignments: dict[str, str] = {}
999
+ # 2026-05-18: collect every employee whose prior assignments should
1000
+ # be freed for redistribution. Includes sick AND role-changed
1001
+ # staff (especially STAFF->ZC: their workload needs to drop from
1002
+ # 22 to 14-15, so extra flights MUST redistribute; and
1003
+ # STAFF/ZC->AM: all their flights become unallocated, then redist).
1004
+ free_for_redist: set[str] = set()
1005
+ if sick_employee_ids or role_changes:
1006
+ name_lookup = {s.name.strip().upper(): s.employee_id for s in staff_today}
1007
+ for raw in (sick_employee_ids or []):
1008
+ if any(s.employee_id == raw for s in staff_today):
1009
+ free_for_redist.add(raw)
1010
+ elif raw.upper() in name_lookup:
1011
+ free_for_redist.add(name_lookup[raw.upper()])
1012
+ for raw_eid in (role_changes or {}):
1013
+ if any(s.employee_id == raw_eid for s in staff_today):
1014
+ free_for_redist.add(raw_eid)
1015
+ elif raw_eid.upper() in name_lookup:
1016
+ free_for_redist.add(name_lookup[raw_eid.upper()])
1017
+ for uid, staff_name in prev_staff_by_key.items():
1018
+ eid = name_lookup.get(staff_name.strip().upper())
1019
+ if eid is None or eid in free_for_redist:
1020
+ continue
1021
+ # Only pin if this staff is still on shift — don't lock a
1022
+ # prior allocation to someone who's now off for some other
1023
+ # reason (eligibility would reject it anyway).
1024
+ if any(s_.employee_id == eid and s_.shift_today is not None
1025
+ for s_ in staff_today):
1026
+ pinned_assignments[uid] = eid
1027
+ if pinned_assignments:
1028
+ print(
1029
+ f" Sick re-solve: pinned {len(pinned_assignments)} prior "
1030
+ f"assignment(s); only sick staff's flights free to move"
1031
+ )
1032
+
1033
+ # --- Solve ----------
1034
+ # 2026-09-23: try a HARD same-bucket spread ceiling of 2 first
1035
+ # (user direction — "give that person a break" once they're more
1036
+ # than 2 behind their least-loaded peer). If the day's real H10
1037
+ # spacing / eligibility shape makes that impossible somewhere,
1038
+ # CP-SAT reports the WHOLE day INFEASIBLE (one combined model —
1039
+ # see hard_bucket_spread_max docstring), so we retry without the
1040
+ # hard cap rather than falling all the way through to the greedy
1041
+ # fallback, which would drop every soft objective for the entire
1042
+ # day just because one bucket couldn't hit spread<=2.
1043
+ _solve_kwargs = dict(
1044
+ ops_day=d_day,
1045
+ max_seconds=config.solver.max_seconds,
1046
+ num_workers=config.solver.num_workers,
1047
+ elig_ctx=elig_ctx,
1048
+ enable_s1_count_balance=True,
1049
+ # S3 (heavy-flight spread) used pax to identify "heavy" flights;
1050
+ # disabled per user direction 2026-05-10 — pax is reference-only.
1051
+ enable_s3_heavy_spread=False,
1052
+ enable_s4_pair_stability=True,
1053
+ enable_s5_handover_preference=True,
1054
+ enable_s6_awkward_preference=True,
1055
+ enable_s7_zc_buffer_avoidance=True,
1056
+ # Phase 4 (2026-05-14, INTL overhaul, Changes 5 + 6): soft INTL
1057
+ # spacing penalty (15-30 min same-handler band) + fair INTL
1058
+ # distribution across handlers within each shift.
1059
+ enable_s_intl_spacing=True,
1060
+ enable_s_intl_fair=True,
1061
+ # Patch 2026-05-15: within the rush bands, prefer staff
1062
+ # whose shift isn't transitioning (±30 min of nominal start/end).
1063
+ enable_s_band_shift_stability=True,
1064
+ # Phase R per-flight overrides — empty by default, populated
1065
+ # by operator approvals via the agent chat layer.
1066
+ waive_h10_triples=frozenset(waive_h10_triples),
1067
+ raise_cap_uids=frozenset(raise_cap_uids),
1068
+ # Phase R re-solve: pass previous-run assignments as warm-start
1069
+ # hints when the caller supplies them.
1070
+ solution_hints=solution_hints,
1071
+ # Finding 1 (2026-05-15): day-level workload targeting.
1072
+ day_level_by_bucket=day_level_by_bucket,
1073
+ # 2026-05-18 sick-call iteration: pin prior allocation in place.
1074
+ pinned_assignments=pinned_assignments or None,
1075
+ )
1076
+ result = solve_allocation(
1077
+ flights, staff_today, matrix,
1078
+ hard_bucket_spread_max=2,
1079
+ **{
1080
+ **_solve_kwargs,
1081
+ # 2026-09-23: give the hard-capped attempt a SHORT probe
1082
+ # budget rather than the full config, so a bucket that
1083
+ # can't hit spread<=2 fails fast instead of burning the
1084
+ # whole time budget twice (once to fail here, again to
1085
+ # solve properly below). If it's achievable, CP-SAT
1086
+ # usually finds it well inside this window; if not, we
1087
+ # want to find that out quickly and move on.
1088
+ "max_seconds": max(60, min(config.solver.max_seconds // 2, 300)),
1089
+ },
1090
+ )
1091
+ # Any non-usable status here (INFEASIBLE, TIMEOUT/UNKNOWN,
1092
+ # MODEL_INVALID) means we do NOT have a real assignment set —
1093
+ # only OPTIMAL/FEASIBLE do. Retry without the hard cap, with the
1094
+ # FULL configured time budget, rather than falling through to the
1095
+ # greedy fallback (which would drop every soft objective for the
1096
+ # whole day just because one bucket couldn't hit spread<=2).
1097
+ if result.status not in ("OPTIMAL", "FEASIBLE"):
1098
+ print(
1099
+ f" Hard spread<=2 probe returned {result.status} — "
1100
+ "retrying without the hard cap (soft spread penalty only, "
1101
+ "full time budget)."
1102
+ )
1103
+ result = solve_allocation(
1104
+ flights, staff_today, matrix,
1105
+ hard_bucket_spread_max=None,
1106
+ **_solve_kwargs,
1107
+ )
1108
+
1109
+ # --- Greedy fallback (2026-09-22) ----------
1110
+ # CP-SAT is an EXACT solver: if any combination of hard constraints
1111
+ # is mutually unsatisfiable, it reports INFEASIBLE and hands back
1112
+ # ZERO assignments for the WHOLE day — even for flights that had
1113
+ # nothing to do with the conflict (see the 2026-09-22 incident:
1114
+ # a static N/ZC workload floor with zero actual night-ops flights
1115
+ # took down all 2117 flights, not just the 3 N/ZC staff). The
1116
+ # solver-side fix (soft ZC floor) addresses THAT specific cause,
1117
+ # but as a safety net against any future/unknown infeasibility, if
1118
+ # CP-SAT comes back without a usable solution we fall back to a
1119
+ # greedy pass that only enforces the non-negotiable physical rules
1120
+ # (H1 one-staff-per-flight, H10 spacing, H16 caps) and ignores
1121
+ # every soft objective. Degraded (unbalanced) but never empty.
1122
+ if result.status not in ("OPTIMAL", "FEASIBLE") or not result.assignments:
1123
+ print(
1124
+ f" !! CP-SAT returned status={result.status} with "
1125
+ f"{len(result.assignments)} assignment(s) — falling back to "
1126
+ "the greedy allocator (H1/H10/H16 only, no workload "
1127
+ "balancing) so flights still get placed. Review the "
1128
+ "Workload tab manually after this run."
1129
+ )
1130
+ from .allocator.greedy_fallback import greedy_allocate
1131
+ fallback_assignments = greedy_allocate(
1132
+ flights, staff_today, matrix, ops_day=d_day,
1133
+ )
1134
+ warnings.append(WarningRow(
1135
+ severity=Severity.ERROR, code="W299", date=d_day,
1136
+ message=(
1137
+ f"CP-SAT could not produce a solution (status="
1138
+ f"{result.status}). Used the greedy fallback instead: "
1139
+ f"{len(fallback_assignments)}/{len(flights)} flights "
1140
+ "placed WITHOUT workload balancing, handover, INTL "
1141
+ "spacing, or P2F-cap logic. Review the Workload and "
1142
+ "Warnings tabs before sending this out, and report this "
1143
+ "run so the underlying CP-SAT infeasibility gets fixed."
1144
+ ),
1145
+ ))
1146
+ result = AllocationSolverResult(
1147
+ status=f"{result.status}_GREEDY_FALLBACK",
1148
+ assignments=fallback_assignments,
1149
+ wall_clock_seconds=result.wall_clock_seconds,
1150
+ )
1151
+
1152
+ feasible = result.status in ("OPTIMAL", "FEASIBLE") or "GREEDY_FALLBACK" in result.status
1153
+ counts["FEASIBLE"] = int(feasible)
1154
+ counts["ASSIGNED"] = len(result.assignments)
1155
+ # UNALLOCATED includes both flights the solver couldn't place AND the
1156
+ # zero-eligibility flights that were dropped pre-solve (W201).
1157
+ counts["UNALLOCATED"] = (
1158
+ len(flights) - len(result.assignments) + len(zero_elig_flights)
1159
+ )
1160
+
1161
+ # --- Post-solve assembly ----------
1162
+ rows = assemble_allocation_rows(
1163
+ flights, staff_today, result.assignments, pairs, d_day,
1164
+ )
1165
+
1166
+ # 2026-05-28 (user direction): P2F post-pass ALWAYS runs BEFORE the
1167
+ # INTL post-pass — regardless of which P2F variant is selected via
1168
+ # ``p2f_adjustment.logic_v2``. Rationale: if INTL D-75 removal goes
1169
+ # first and removes a preceding domestic flight, then P2F removal
1170
+ # might also remove the INTL itself (or vice-versa), leading to
1171
+ # double-displacements. By doing P2F first, the slots it frees are
1172
+ # accounted for when INTL D-75 evaluates its own preceding-removal
1173
+ # need.
1174
+ _p2f_logic_v2 = bool(getattr(config.p2f_adjustment, "logic_v2", False))
1175
+
1176
+ def _run_intl_postpass(in_rows):
1177
+ from .allocator.postpass_intl import apply_intl_post_pass
1178
+ out_rows, summary = apply_intl_post_pass(
1179
+ in_rows, flights, staff_today, matrix,
1180
+ elig_ctx_p2f_handlers=elig_ctx.p2f_handler_by_shift,
1181
+ d_day=d_day,
1182
+ # Phase R: per-INTL-flight opt-out from the preceding-removal.
1183
+ skip_intl_removal_keys=frozenset(skip_intl_removal_keys),
1184
+ )
1185
+ if summary.n_intl_processed:
1186
+ print(
1187
+ f" INTL post-pass: processed={summary.n_intl_processed} "
1188
+ f"removed={summary.n_case_2b} "
1189
+ f"redistributed={summary.n_redistributed} "
1190
+ f"redist_failed={summary.n_redistribute_failed}"
1191
+ )
1192
+ for line in summary.log_lines[:20]:
1193
+ print(f" intl: {line}")
1194
+ if len(summary.log_lines) > 20:
1195
+ print(f" intl: ... + {len(summary.log_lines) - 20} more lines")
1196
+ return out_rows, summary
1197
+
1198
+ def _run_p2f_postpass(in_rows):
1199
+ if _p2f_logic_v2:
1200
+ from .allocator.postpass_p2f import apply_p2f_post_pass_v2
1201
+ out_rows, summary = apply_p2f_post_pass_v2(
1202
+ in_rows, flights, staff_today, matrix,
1203
+ p2f_handlers=elig_ctx.p2f_handler_by_shift,
1204
+ d_day=d_day,
1205
+ )
1206
+ tag = "P2F post-pass (v2 union window)"
1207
+ else:
1208
+ from .allocator.postpass_p2f import apply_p2f_post_pass
1209
+ out_rows, summary = apply_p2f_post_pass(
1210
+ in_rows, flights, staff_today, matrix,
1211
+ p2f_handlers=elig_ctx.p2f_handler_by_shift,
1212
+ d_day=d_day,
1213
+ tolerance_minutes=config.p2f_adjustment.tolerance_minutes,
1214
+ )
1215
+ tag = "P2F post-pass"
1216
+ if summary.n_p2f_processed:
1217
+ print(
1218
+ f" {tag}: processed={summary.n_p2f_processed} "
1219
+ f"removed={summary.n_removed} "
1220
+ f"redistributed={summary.n_redistributed} "
1221
+ f"redist_failed={summary.n_redistribute_failed} "
1222
+ f"anchor_shortages={summary.n_anchor_shortage}"
1223
+ )
1224
+ for line in summary.log_lines[:20]:
1225
+ print(f" p2f: {line}")
1226
+ if len(summary.log_lines) > 20:
1227
+ print(f" p2f: ... + {len(summary.log_lines) - 20} more lines")
1228
+ return out_rows, summary
1229
+
1230
+ # 2026-05-28 (user direction): always P2F first, then INTL —
1231
+ # regardless of which P2F variant is active. Avoids the
1232
+ # double-displacement edge case described above.
1233
+ print(
1234
+ " post-pass order: P2F"
1235
+ + (" (v2 union window)" if _p2f_logic_v2 else "")
1236
+ + " -> INTL",
1237
+ )
1238
+ rows, p2f_summary = _run_p2f_postpass(rows)
1239
+ rows, intl_summary = _run_intl_postpass(rows)
1240
+
1241
+ # 2026-09-23 fix: the P2F/INTL post-passes above remove flights
1242
+ # from whoever happens to sit next to a P2F/INTL flight, not from
1243
+ # whoever currently has the most flights — so they can re-open the
1244
+ # spread the solver's H18 constraint just closed (see
1245
+ # docs/REVIEW_NOTES.md finding #4). Run a final leveling pass:
1246
+ # while a (shift, role) bucket's most- and least-loaded staff
1247
+ # differ by more than 1, move one eligible flight from the former
1248
+ # to the latter. Every move still respects eligibility, H10
1249
+ # spacing, and hard caps — it only re-picks who among already-
1250
+ # eligible people does which flight.
1251
+ from .allocator.postpass_rebalance import apply_rebalance_pass
1252
+ rows, rebalance_summary = apply_rebalance_pass(
1253
+ rows, flights, staff_today, matrix,
1254
+ p2f_handlers=elig_ctx.p2f_handler_by_shift,
1255
+ d_day=d_day,
1256
+ )
1257
+ if rebalance_summary.n_transfers:
1258
+ print(
1259
+ f" Rebalance pass: buckets_checked="
1260
+ f"{rebalance_summary.n_buckets_checked} "
1261
+ f"buckets_improved={rebalance_summary.n_buckets_improved} "
1262
+ f"buckets_still_stuck={rebalance_summary.n_buckets_stuck} "
1263
+ f"transfers={rebalance_summary.n_transfers}"
1264
+ )
1265
+ for line in rebalance_summary.log_lines[:20]:
1266
+ print(f" rebalance: {line}")
1267
+ if len(rebalance_summary.log_lines) > 20:
1268
+ print(f" rebalance: ... + {len(rebalance_summary.log_lines) - 20} more lines")
1269
+
1270
+ # 2026-09-23 (user direction): floating mid-shift break per on-shift
1271
+ # staff (length from config.yml, set in the Setup sidebar),
1272
+ # soft/best-effort, via 1-for-1 swaps only — never changes
1273
+ # anyone's flight count, so it can't disturb the H18/rebalance
1274
+ # workload spread above. Runs LAST, against the final schedule.
1275
+ from .allocator.postpass_break import apply_break_pass
1276
+ rows, break_summary = apply_break_pass(
1277
+ rows, flights, staff_today, matrix, d_day,
1278
+ break_len=config.break_pass.length_minutes,
1279
+ )
1280
+ print(
1281
+ f" Break pass ({config.break_pass.length_minutes} min): "
1282
+ f"considered={break_summary.n_staff_considered} "
1283
+ f"already_clear={break_summary.n_clean} "
1284
+ f"cleared_via_swap={break_summary.n_created_via_swap} "
1285
+ f"partial={break_summary.n_partial} "
1286
+ f"not_found={break_summary.n_not_found} "
1287
+ f"swaps={break_summary.n_swaps}"
1288
+ )
1289
+ for line in break_summary.log_lines[:20]:
1290
+ print(f" break: {line}")
1291
+ if len(break_summary.log_lines) > 20:
1292
+ print(f" break: ... + {len(break_summary.log_lines) - 20} more lines")
1293
+
1294
+ # Merge all post-pass workload-note dicts (host -> list).
1295
+ merged_workload_notes: dict[str, list[str]] = defaultdict(list)
1296
+ for sid, msgs in intl_summary.workload_notes.items():
1297
+ merged_workload_notes[sid].extend(msgs)
1298
+ for sid, msgs in p2f_summary.workload_notes.items():
1299
+ merged_workload_notes[sid].extend(msgs)
1300
+ for sid, msgs in rebalance_summary.workload_notes.items():
1301
+ merged_workload_notes[sid].extend(msgs)
1302
+ for sid, msgs in break_summary.workload_notes.items():
1303
+ merged_workload_notes[sid].extend(msgs)
1304
+
1305
+ # 2026-05-26 fix: post-passes displace flights between staff via
1306
+ # ``_set_assignee``, which updates the row's staff fields but
1307
+ # never recomputes planned_by / relieved_by. So a flight moved
1308
+ # from Ravi (A→N partner = Kunal) to Priya (A→N partner = Suraj)
1309
+ # showed staff=Priya AND planned_by=Kunal — even though Kunal-
1310
+ # Priya isn't a pair. Re-derive the labels against each row's
1311
+ # CURRENT staff so the allocations match the pair map.
1312
+ from .allocator.postsolve import relabel_pair_columns
1313
+ rows = relabel_pair_columns(rows, flights, staff_today, pairs, d_day)
1314
+
1315
+ # W220 (2026-05-26 user direction): M-shift P2F handlers whose P2F
1316
+ # STD falls before 07:30 have their D-3hrs pre-brief window land
1317
+ # before M shift starts (04:00) — meaning no on-shift handler can
1318
+ # actually do the briefing. The previous-day N shift would
1319
+ # normally cover it via handover, but today's engine has no
1320
+ # visibility into yesterday's roster. We can't prevent the
1321
+ # assignment, but we surface it loudly so the assigner can
1322
+ # arrange a manual handover.
1323
+ #
1324
+ # Other shifts (A / N) are NOT warned: today's M can handover to
1325
+ # today's A (M→A pair), and today's A handovers to today's N
1326
+ # (A→N pair). Only M is "first of the day" with no precursor.
1327
+ from datetime import time as _time_t
1328
+ _M_P2F_WARN_LOWER = _time_t(4, 0)
1329
+ _M_P2F_WARN_UPPER = _time_t(7, 30)
1330
+ _flight_by_uid_warn = {f.unique_id: f for f in flights}
1331
+ _warned_uids: dict[str, str] = {}
1332
+ for r in rows:
1333
+ if not r.staff_employee_id:
1334
+ continue
1335
+ _uid = (
1336
+ f"{r.flt}|{r.dep}|{r.arr}|"
1337
+ f"{r.std.isoformat(timespec='minutes')}|"
1338
+ f"{r.date.isoformat()}"
1339
+ )
1340
+ _f = _flight_by_uid_warn.get(_uid)
1341
+ if _f is None or _f.ops_class != OpsClass.P2F:
1342
+ continue
1343
+ _handler = staff_by_id.get(r.staff_employee_id)
1344
+ if _handler is None or _handler.shift_today != "M":
1345
+ continue
1346
+ if not (_M_P2F_WARN_LOWER <= _f.std <= _M_P2F_WARN_UPPER):
1347
+ continue
1348
+ _short_msg = (
1349
+ f"M-shift P2F before 07:30 — D-3hrs pre-brief is before "
1350
+ "shift start (04:00); arrange manual handover from prev "
1351
+ "day N shift"
1352
+ )
1353
+ warnings.append(WarningRow(
1354
+ severity=Severity.WARN, code="W220", date=d_day,
1355
+ name=_handler.name,
1356
+ message=(
1357
+ f"P2F flight {_f.flt} STD "
1358
+ f"{_f.std.isoformat(timespec='minutes')} assigned to "
1359
+ f"M-shift handler {_handler.name}: D-3hrs pre-brief "
1360
+ f"window ({_f.std.replace(hour=max(0, _f.std.hour - 3)).isoformat(timespec='minutes')} "
1361
+ "approx) falls before M shift starts (04:00). The "
1362
+ "engine cannot place a pre-planner — manual handover "
1363
+ "from the previous day's N shift is required."
1364
+ ),
1365
+ ))
1366
+ _warned_uids[_uid] = _short_msg
1367
+ if _warned_uids:
1368
+ rows = [
1369
+ (
1370
+ r.model_copy(update={
1371
+ "warning": (
1372
+ f"{r.warning}; {_warned_uids[_k]}"
1373
+ if r.warning else _warned_uids[_k]
1374
+ ),
1375
+ })
1376
+ if (_k := (
1377
+ f"{r.flt}|{r.dep}|{r.arr}|"
1378
+ f"{r.std.isoformat(timespec='minutes')}|"
1379
+ f"{r.date.isoformat()}"
1380
+ )) in _warned_uids else r
1381
+ )
1382
+ for r in rows
1383
+ ]
1384
+ print(
1385
+ f" W220: flagged {len(_warned_uids)} early-morning M-shift "
1386
+ "P2F flight(s) — manual handover required"
1387
+ )
1388
+
1389
+ # Reflect post-pass row mutations back into result.assignments so
1390
+ # the workload summary + OUT_Unallocated below see the new state.
1391
+ # Build a fresh assignments dict from the (possibly mutated) rows.
1392
+ post_assignments: dict[str, str] = {}
1393
+ for r in rows:
1394
+ if r.staff_employee_id:
1395
+ uid = (
1396
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
1397
+ f"|{r.date.isoformat()}"
1398
+ )
1399
+ post_assignments[uid] = r.staff_employee_id
1400
+ result_assignments = post_assignments
1401
+
1402
+ # 2026-05-16 (user direction): pre-plan-deferred flights (D+1 STD
1403
+ # 05:05-05:30) are now ASSIGNED to an N-shift handler for pre-
1404
+ # planning. The N staff doesn't fly the leg — they just prepare
1405
+ # it during their night. Write their name in planned_by; staff
1406
+ # column stays empty (no one's flying it today). Drop the
1407
+ # PREPLAN_DEFERRED warning text now that the row carries a
1408
+ # responsible person.
1409
+ #
1410
+ # Selection: round-robin across N-shift STAFF (excludes ZC, AM,
1411
+ # P2F handler) — fair load on the prep work and
1412
+ # avoids picking the same person every night.
1413
+ if preplan_only_flights:
1414
+ from .schemas import AllocationRow, AllocationSheet
1415
+ handler_set = set(elig_ctx.p2f_handler_by_shift.values())
1416
+ n_pool = [
1417
+ s for s in staff_today
1418
+ if s.shift_today == "N"
1419
+ and s.role == Role.STAFF
1420
+ and s.employee_id not in handler_set
1421
+ ]
1422
+ n_pool.sort(key=lambda s: s.employee_id) # deterministic order
1423
+ for i, f in enumerate(preplan_only_flights):
1424
+ preplanner = n_pool[i % len(n_pool)] if n_pool else None
1425
+ rows.append(AllocationRow(
1426
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr,
1427
+ std=f.std, pax=f.load,
1428
+ staff_employee_id="", staff_name="",
1429
+ planned_by_employee_id=(preplanner.employee_id
1430
+ if preplanner else None),
1431
+ planned_by_name=(preplanner.name if preplanner else None),
1432
+ warning="",
1433
+ sheet_target=AllocationSheet.NIGHT_OPS,
1434
+ is_international=f.is_international,
1435
+ ))
1436
+ # Pass the combined post-pass reason notes (INTL §2 + P2F §3) so
1437
+ # they appear in the violations column.
1438
+ summary = build_workload_summary(
1439
+ staff_today, result_assignments,
1440
+ extra_reasons=dict(merged_workload_notes),
1441
+ # Neetu fix (2026-05-12): pass rows so counts come from the
1442
+ # exact list the writer will emit, not the reconstructed dict.
1443
+ rows=rows,
1444
+ )
1445
+
1446
+ # --- Build OUT_Unallocated rows (red-tinted "needs attention" sheet)
1447
+ # Combines:
1448
+ # (a) zero-eligibility flights dropped pre-solve (W201 — reason
1449
+ # comes from diagnose_unassignable: e.g., 'INTL_SHIFT_EDGE')
1450
+ # (b) flights the solver chose to leave unallocated post-solve
1451
+ # (capacity / spacing / cap-hit — reason 'capacity or spacing')
1452
+ unallocated_rows: list[dict[str, object]] = []
1453
+ # All flights from the original list — re-fetch since we dropped some
1454
+ all_flights_orig = (
1455
+ list(flights) + zero_elig_flights
1456
+ )
1457
+ # 2026-05-18: track redistribution leftovers (sick OR role-change)
1458
+ # separately so we can emit a W219 WARN naming the affected staff
1459
+ # + reason. A flight is a "leftover" iff it was previously assigned
1460
+ # to someone whose prior assignment is no longer pinned (sick,
1461
+ # demoted to AM, or downgraded STAFF->ZC and capped) AND the
1462
+ # solver couldn't re-place it.
1463
+ sick_leftover_flights: list[tuple[str, str, str]] = [] # (flt, name, why)
1464
+ name_lookup_for_leftover = {s.employee_id: s.name for s in staff_today}
1465
+ why_by_eid: dict[str, str] = {}
1466
+ for raw in (sick_employee_ids or []):
1467
+ # resolve to canonical eid
1468
+ eid = raw
1469
+ if not any(s.employee_id == raw for s in staff_today):
1470
+ up_match = {s.name.strip().upper(): s.employee_id for s in staff_today}
1471
+ eid = up_match.get(raw.upper(), raw)
1472
+ why_by_eid[eid] = "sick"
1473
+ for raw_eid, new_role in (role_changes or {}).items():
1474
+ eid = raw_eid
1475
+ if not any(s.employee_id == raw_eid for s in staff_today):
1476
+ up_match = {s.name.strip().upper(): s.employee_id for s in staff_today}
1477
+ eid = up_match.get(raw_eid.upper(), raw_eid)
1478
+ # Use new_role to phrase the reason (STAFF->ZC is a workload
1479
+ # cap, AM is a full removal; both surface as "role-change").
1480
+ why_by_eid.setdefault(eid, f"role-change to {new_role}")
1481
+ # Build a flt->prior-staff map from the original allocations
1482
+ # sheets so we can attribute leftovers.
1483
+ prior_flt_to_staff: dict[str, str] = dict(prev_staff_by_key) if why_by_eid else {}
1484
+
1485
+ for f in all_flights_orig:
1486
+ if f.unique_id in zero_elig_reasons:
1487
+ reason = zero_elig_reasons[f.unique_id]
1488
+ elif f.unique_id not in result_assignments:
1489
+ reason = "no slot — capacity or spacing constraint"
1490
+ # Was this flight previously held by a freed-for-redist
1491
+ # staff (sick or role-changed)? Re-label + queue W219.
1492
+ prev_staff = prior_flt_to_staff.get(f.unique_id, "")
1493
+ if prev_staff:
1494
+ prev_name_up = prev_staff.upper()
1495
+ matched_eid = next(
1496
+ (eid for eid in why_by_eid
1497
+ if name_lookup_for_leftover.get(eid, "").upper() == prev_name_up),
1498
+ None,
1499
+ )
1500
+ if matched_eid:
1501
+ why = why_by_eid[matched_eid]
1502
+ reason = (
1503
+ f"{why} redistribution failed — no eligible "
1504
+ f"candidate within shift (was {prev_staff})"
1505
+ )
1506
+ sick_leftover_flights.append((f.flt, prev_staff, why))
1507
+ else:
1508
+ continue # allocated, skip
1509
+ unallocated_rows.append({
1510
+ "date": f.date.isoformat(),
1511
+ "flt": f.flt,
1512
+ "dep": f.dep,
1513
+ "arr": f.arr,
1514
+ "std": f.std.isoformat(timespec="minutes"),
1515
+ "pax": f.load,
1516
+ "ops_class": f.ops_class.value,
1517
+ "is_international": f.is_international,
1518
+ "reason": reason,
1519
+ })
1520
+ # W219: surface the leftover count (sick OR role-change) so the
1521
+ # operator sees it on the Warnings tab without scanning
1522
+ # OUT_Unallocated. Keys = (name, why) so a single person split
1523
+ # across reasons (unlikely but possible) gets two distinct rows.
1524
+ if sick_leftover_flights:
1525
+ # defaultdict already imported at module top — re-importing
1526
+ # inside the function would shadow it as a local for the whole
1527
+ # scope (Python scoping quirk) and break earlier usages.
1528
+ by_who = defaultdict(list)
1529
+ for flt, who, why in sick_leftover_flights:
1530
+ by_who[(who, why)].append(flt)
1531
+ for (who_name, why), flts in by_who.items():
1532
+ warnings.append(WarningRow(
1533
+ severity=Severity.WARN, code="W219", date=d_day,
1534
+ name=who_name,
1535
+ message=(
1536
+ f"{why}: {len(flts)} flight(s) from {who_name} "
1537
+ f"could not be redistributed within their shift — "
1538
+ f"left in OUT_Unallocated for manual handling. "
1539
+ f"Flights: {', '.join(str(x) for x in flts[:10])}"
1540
+ + (f" +{len(flts) - 10} more" if len(flts) > 10 else "")
1541
+ ),
1542
+ ))
1543
+
1544
+ bundle = AllocationResult(
1545
+ rows=tuple(rows),
1546
+ pairs=tuple(pairs),
1547
+ summary=tuple(summary),
1548
+ warnings=tuple(warnings),
1549
+ wall_clock_seconds=result.wall_clock_seconds,
1550
+ solver_status=result.status,
1551
+ )
1552
+
1553
+ # --- Phase R: write recommender sidecar JSON ----------
1554
+ # For each unallocated flight, compute up to 3 ranked override
1555
+ # suggestions and stash them in a JSON sidecar next to the console
1556
+ # workbook. The web UI reads this via GET /api/recommendations and
1557
+ # surfaces inline Approve buttons under each Unallocated row.
1558
+ #
1559
+ # Include the zero-eligibility flights (stripped from `flights` /
1560
+ # `matrix` before solve) so the recommender's Path-B / Path-C
1561
+ # branches can suggest skip_p2f_buffer / skip_intl_removal for
1562
+ # them. Each gets an empty matrix entry so Path A is a no-op.
1563
+ from .allocator.recommender import recommend_for_unallocated
1564
+ try:
1565
+ reco_flights = list(flights) + list(zero_elig_flights)
1566
+ reco_matrix = dict(matrix)
1567
+ for f in zero_elig_flights:
1568
+ reco_matrix.setdefault(f.unique_id, set())
1569
+ recs = recommend_for_unallocated(
1570
+ flights=reco_flights,
1571
+ staff_today=staff_today,
1572
+ matrix=reco_matrix,
1573
+ assignments=result_assignments,
1574
+ elig_ctx=elig_ctx,
1575
+ ops_day=d_day,
1576
+ )
1577
+ rec_payload = {
1578
+ "ops_day": d_day.isoformat(),
1579
+ "n_unallocated": len(recs),
1580
+ "recommendations": [
1581
+ {
1582
+ "flight_id": r.flight_id,
1583
+ "flt": r.flt,
1584
+ "dep": r.dep,
1585
+ "arr": r.arr,
1586
+ "std": r.std_iso,
1587
+ "is_international": r.is_international,
1588
+ "suggestions": [
1589
+ {
1590
+ "intrusiveness": s.intrusiveness,
1591
+ "type": s.override_type,
1592
+ "rationale": s.rationale,
1593
+ "payload": s.payload,
1594
+ "target_staff": s.target_staff_name,
1595
+ }
1596
+ for s in r.suggestions
1597
+ ],
1598
+ }
1599
+ for r in recs
1600
+ ],
1601
+ }
1602
+ with state.lock:
1603
+ state.recommendations = rec_payload
1604
+ if recs:
1605
+ print(f" Recommender: {len(recs)} unallocated flight(s)")
1606
+ except Exception as e: # noqa: BLE001
1607
+ # Recommender is advisory — never block the main pipeline.
1608
+ print(f" Recommender skipped: {type(e).__name__}: {e}")
1609
+
1610
+ # --- Write outputs ----------
1611
+ with state.lock:
1612
+ state.run_date = d_day
1613
+ state.allocations = list(bundle.rows)
1614
+ state.pairs = list(bundle.pairs)
1615
+ state.workload = list(bundle.summary)
1616
+ state.unallocated = unallocated_rows
1617
+ state.prev_staff_by_key = prev_staff_by_key
1618
+ if warnings:
1619
+ _store_warnings(state, warnings, append=append_warnings)
1620
+ state.stamp_run(
1621
+ mode=mode_label, solver_status=result.status,
1622
+ duration_s=_time.monotonic() - started,
1623
+ )
1624
+
1625
+ counts["WARNINGS"] = len(warnings)
1626
+ return counts
1627
+
1628
+
1629
+ def _store_warnings(
1630
+ state: AppState, warnings: list[WarningRow], *, append: bool,
1631
+ ) -> None:
1632
+ """Put this stage's warnings on the state, either appending to what
1633
+ Plan left behind or replacing it."""
1634
+ with state.lock:
1635
+ state.warnings = (
1636
+ list(state.warnings) + warnings if append else list(warnings)
1637
+ )
1638
+
1639
+