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,723 @@
1
+ """P2F workload adjustment post-pass (rewrite per user direction 2026-05-26).
2
+
3
+ Runs AFTER the INTL post-pass. For each P2F flight, displaces the
4
+ closest normal-domestic flight in the (anchor, anchor + 30 min] window
5
+ from the **host** (whichever P2F handler's shift owns the anchor),
6
+ not from the **owner** (who flies the P2F leg).
7
+
8
+ Anchors per P2F flight:
9
+ * D-3hrs (P2F_STD - 180 min)
10
+ * D-1hr (P2F_STD - 60 min)
11
+ * D+20m (P2F_STD + 20 min)
12
+
13
+ Host selection (2026-05-26 redesign):
14
+ * Anchor → shift via SHIFT_STD_WINDOW_INNER (M / A / N only — those
15
+ are the shifts that nominate P2F handlers per the 2026-05-10
16
+ directive). Role-agnostic: 12:55 → M, 13:00 → A.
17
+ * Host = ``p2f_handlers[host_shift]``.
18
+ * If host is a ZC and the anchor falls inside their ZC start- or
19
+ end-of-shift report buffer (1.5 hr each side), SKIP that anchor —
20
+ the ZC isn't taking flights there anyway, so there's nothing to
21
+ displace and the briefing happens during their report time.
22
+ * If no host shift covers the anchor (e.g. early-morning M-shift
23
+ P2F at 06:00 → D-3hrs = 03:00, before any shift), skip with a
24
+ log line — the W220 warning fires from step3 in parallel.
25
+
26
+ Displacement (2026-05-26 revised — no ±30 cap):
27
+ * Closest displaceable flight strictly AFTER the anchor (any
28
+ distance). The handler needs the time FROM the anchor onward
29
+ to prep; flights before are already done.
30
+ * If the host has NO displaceable flight after the anchor (e.g.
31
+ P2F at 12:55 with D-1 at 11:55 and the host's day ends after a
32
+ last 11:50 leg), fall back to the closest displaceable flight
33
+ BEFORE the anchor. Removal is non-negotiable when any
34
+ displaceable flight exists.
35
+
36
+ Warning surface:
37
+ * Each P2F row's ``warning`` column lists the removed flights as
38
+ ``FLT 7895@10:25, FLT 6118@12:10`` (just no + STD, per the
39
+ 2026-05-26 ask). Pair-based PLANNED_BY / RELIEVED_BY are blanked
40
+ by relabel_pair_columns — operators read the warning column to
41
+ see what prep cost this P2F incurred.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import contextlib
47
+ from collections import defaultdict
48
+ from dataclasses import dataclass, field
49
+ from datetime import date as date_t
50
+
51
+ from ..schemas import (
52
+ AllocationRow,
53
+ FlightInput,
54
+ OpsClass,
55
+ Role,
56
+ ShiftCode,
57
+ StaffMember,
58
+ )
59
+ from .eligibility import sheet_target_for
60
+ from .postpass_intl import _most_eligible
61
+ from .windows import (
62
+ SHIFT_STD_WINDOW_INNER,
63
+ ZC_BUFFER_END_MIN,
64
+ ZC_BUFFER_START_MIN,
65
+ SpacingKey,
66
+ spacing_key,
67
+ std_to_ops_day_minutes,
68
+ )
69
+
70
+ # Anchor offsets relative to P2F_STD, paired with the number of
71
+ # flights to displace at each anchor. Each anchor displaces the K
72
+ # closest flights STRICTLY AFTER the anchor (no fallback to BEFORE,
73
+ # per the 2026-05-28 user direction).
74
+ #
75
+ # 2026-05-28 (user direction): counts changed from {1, 1, 1} to
76
+ # {2, 1, 1}, and the third anchor moved from D+20m to D (= P2F_STD).
77
+ # Removing 2 flights after D-3h gives the host the prep block they
78
+ # need; the +1 after D-1h covers final briefing; the +1 after D
79
+ # covers the immediate post-P2F slot (was D+20m before — now strictly
80
+ # the next flight after the P2F itself).
81
+ _ANCHORS: tuple[tuple[int, str, int], ...] = (
82
+ (-3 * 60, "D-3h", 2),
83
+ (-1 * 60, "D-1h", 1),
84
+ ( 0, "D", 1),
85
+ )
86
+
87
+ # 2026-05-27 (centralization): pulled from schemas — single source
88
+ # of truth for which shifts nominate plain P2F handlers. M1 / A1 don't
89
+ # nominate per the 2026-05-10 directive — anchors in those windows
90
+ # fall through to whichever of M / A / N also covers them.
91
+ from ..schemas import P2F_HANDLER_ELIGIBLE_SHIFTS as _HOST_CANDIDATE_SHIFTS
92
+
93
+
94
+ def _anchor_host_shift(anchor_min: int) -> ShiftCode | None:
95
+ """Map an anchor time (ops-day minutes) to a P2F-handler-bearing
96
+ shift via SHIFT_STD_WINDOW_INNER. Returns None when the anchor
97
+ falls in no candidate shift's window (W220 territory for M; for
98
+ other shifts simply means no displacement happens at that anchor).
99
+
100
+ Role-agnostic by design — 12:55 → M, 13:00 → A. Caller deals with
101
+ ZC report-buffer skip separately.
102
+ """
103
+ for shift in _HOST_CANDIDATE_SHIFTS:
104
+ lo, hi = SHIFT_STD_WINDOW_INNER[shift]
105
+ if lo <= anchor_min <= hi:
106
+ return shift
107
+ return None
108
+
109
+
110
+ def _anchor_in_zc_report_buffer(
111
+ host_shift: ShiftCode, anchor_min: int,
112
+ ) -> bool:
113
+ """True when ``anchor_min`` lands in either of ``host_shift``'s
114
+ 1.5 hr ZC report-buffer windows (start-of-shift or end-of-shift).
115
+
116
+ Used to skip displacement when a ZC hosts an anchor — they have
117
+ no flights to remove during their report time anyway, and the
118
+ briefing slots in there naturally.
119
+ """
120
+ s1, e1 = ZC_BUFFER_START_MIN.get(host_shift, (0, 0))
121
+ s2, e2 = ZC_BUFFER_END_MIN.get(host_shift, (0, 0))
122
+ return (s1 <= anchor_min < e1) or (s2 <= anchor_min < e2)
123
+
124
+
125
+ @dataclass
126
+ class P2FPostPassSummary:
127
+ """Diagnostic counters for the audit harness."""
128
+
129
+ n_p2f_processed: int = 0
130
+ n_removed: int = 0
131
+ n_redistributed: int = 0
132
+ n_redistribute_failed: int = 0
133
+ n_anchor_shortage: int = 0 # window had fewer candidates than required
134
+ workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
135
+ log_lines: list[str] = field(default_factory=list)
136
+
137
+ def note(self, staff_id: str, reason: str) -> None:
138
+ self.workload_notes[staff_id].append(reason)
139
+
140
+ def log(self, msg: str) -> None:
141
+ self.log_lines.append(msg)
142
+
143
+
144
+ def _is_displaceable(flight: FlightInput) -> bool:
145
+ """A flight that the P2F post-pass is allowed to remove from the
146
+ handler's allocation. Excludes:
147
+ - P2F flights themselves (never displaced)
148
+ - INTL DEP flights (Phase 3 / Change 4: same handler start-to-
149
+ finish — INTL DEP allocations are sacred after the solver)
150
+
151
+ Per spec §3: the post-pass removes flights from the handler's
152
+ list AROUND the P2F flight — never the P2F flight itself.
153
+ """
154
+ if flight.ops_class == OpsClass.P2F:
155
+ return False
156
+ # Phase 3 (2026-05-14, INTL overhaul / Change 4): an INTL DEP
157
+ # flight must stay with its assigned handler (self-pair). The P2F
158
+ # post-pass cannot move it without breaking that invariant.
159
+ return not flight.is_international
160
+
161
+
162
+ def apply_p2f_post_pass_v2(
163
+ rows: list[AllocationRow],
164
+ flights: list[FlightInput],
165
+ staff_today: list[StaffMember],
166
+ matrix: dict[str, set[str]],
167
+ p2f_handlers: dict[ShiftCode, str],
168
+ d_day: date_t,
169
+ ) -> tuple[list[AllocationRow], P2FPostPassSummary]:
170
+ """Alternate P2F post-pass (user direction 2026-05-28).
171
+
172
+ Simple union-window rule: for each P2F flight at STD=T, the chain
173
+ ``{owner shift's P2F handler, pre-planner shift's P2F handler}``
174
+ loses ALL flights with STD in ``[T-2h, T+1h]`` from their queues.
175
+ The P2F flight itself stays with the owner. Removed flights enter
176
+ §4 redistribution.
177
+
178
+ Differences from the anchored v1:
179
+ * No D-3h / D-1h / D+20m anchors. One contiguous window per P2F.
180
+ * No ZC report-buffer skip — if a chain member is a ZC, their
181
+ in-window flights still get removed (the buffer is a softer
182
+ concept that the simplified model intentionally ignores).
183
+ * Chain semantics handle the shift-boundary edge case the
184
+ anchored model misses (e.g. P2F at 13:30, owner = A shift
185
+ handler, has a 13:00 flight — that flight is in the chain and
186
+ in [11:30, 14:30] so it gets removed even though 13:00 is in
187
+ the pre-window).
188
+ * planned_by on the P2F row = pre-planner (when different from
189
+ owner). relieved_by stays blank — owner handles their own
190
+ post-window since they're freed by the same window logic.
191
+
192
+ Returns ``(new_rows, summary)`` — same shape as v1. Row count
193
+ preserved (flights are reassigned, not dropped).
194
+ """
195
+ summary = P2FPostPassSummary()
196
+ flight_by_uid = {f.unique_id: f for f in flights}
197
+
198
+ rstate: dict[str, dict] = {}
199
+ rows_input_count = len(rows)
200
+ for r in rows:
201
+ uid = (
202
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
203
+ f"|{r.date.isoformat()}"
204
+ )
205
+ rstate[uid] = {
206
+ "row": r,
207
+ "staff_id": r.staff_employee_id,
208
+ "staff_name": r.staff_name,
209
+ "planned_by_emp": r.planned_by_employee_id,
210
+ "planned_by_name": r.planned_by_name,
211
+ "relieved_by_emp": r.relieved_by_employee_id,
212
+ "relieved_by_name": r.relieved_by_name,
213
+ "warning": r.warning,
214
+ "sheet_target": r.sheet_target,
215
+ "uid": uid,
216
+ }
217
+
218
+ counts: dict[str, int] = defaultdict(int)
219
+ staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
220
+ for uid, st in rstate.items():
221
+ if not st["staff_id"]:
222
+ continue
223
+ f_ref = flight_by_uid.get(uid)
224
+ if f_ref is None:
225
+ continue
226
+ counts[st["staff_id"]] += 1
227
+ staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
228
+ excluded: set[str] = set()
229
+
230
+ p2f_uids = sorted(
231
+ (uid for uid, st in rstate.items()
232
+ if flight_by_uid.get(uid)
233
+ and flight_by_uid[uid].ops_class == OpsClass.P2F
234
+ and st["staff_id"]),
235
+ key=lambda u: std_to_ops_day_minutes(
236
+ flight_by_uid[u].std, flight_by_uid[u].date, d_day,
237
+ ),
238
+ )
239
+ staff_by_id: dict[str, StaffMember] = {
240
+ s.employee_id: s for s in staff_today if s.employee_id
241
+ }
242
+
243
+ # Resolve pre-planner shift from owner shift. Pre-shift mapping
244
+ # mirrors the ops-day flow: N is preceded by A, A by M, M by N of
245
+ # D-1 (which means no D-day chain partner — fall back to owner-only).
246
+ _PRE_SHIFT: dict[ShiftCode, ShiftCode | None] = {
247
+ "M": None, # M's pre-window is D-1's N — no D-day chain partner
248
+ "A": "M",
249
+ "N": "A",
250
+ }
251
+
252
+ for p2f_uid in p2f_uids:
253
+ p2f_f = flight_by_uid[p2f_uid]
254
+ owner_id = rstate[p2f_uid]["staff_id"]
255
+ if not owner_id:
256
+ continue
257
+ summary.n_p2f_processed += 1
258
+ p2f_min = std_to_ops_day_minutes(p2f_f.std, p2f_f.date, d_day)
259
+ window_lo = p2f_min - 120
260
+ window_hi = p2f_min + 60
261
+
262
+ owner_shift = _anchor_host_shift(p2f_min)
263
+ if owner_shift is None:
264
+ summary.n_anchor_shortage += 1
265
+ summary.log(
266
+ f"P2F flt {p2f_f.flt} v2: STD "
267
+ f"{p2f_min // 60:02d}:{p2f_min % 60:02d} falls in no "
268
+ "P2F-handler-bearing shift — chain empty, no removal"
269
+ )
270
+ continue
271
+ pre_shift = _PRE_SHIFT.get(owner_shift)
272
+ pre_planner_id = (
273
+ p2f_handlers.get(pre_shift) if pre_shift else None
274
+ )
275
+ chain_ids = {owner_id}
276
+ if pre_planner_id and pre_planner_id != owner_id:
277
+ chain_ids.add(pre_planner_id)
278
+
279
+ # Sort displaceable candidates chronologically so the
280
+ # redistribution pool sees them in a stable order — matters
281
+ # because _displace_one mutates counts / staff_stds as it goes.
282
+ candidates: list[tuple[int, str]] = [] # (cand_min, uid)
283
+ for u, sx in rstate.items():
284
+ if u == p2f_uid:
285
+ continue
286
+ if sx["staff_id"] not in chain_ids:
287
+ continue
288
+ f_cand = flight_by_uid.get(u)
289
+ if f_cand is None or not _is_displaceable(f_cand):
290
+ continue
291
+ cand_min = std_to_ops_day_minutes(
292
+ f_cand.std, f_cand.date, d_day,
293
+ )
294
+ if window_lo <= cand_min <= window_hi:
295
+ candidates.append((cand_min, u))
296
+ candidates.sort()
297
+
298
+ removed_for_this_p2f: list[tuple[str, str]] = []
299
+ for _cand_min, cand_uid in candidates:
300
+ holder_id = rstate[cand_uid]["staff_id"]
301
+ if holder_id not in chain_ids:
302
+ # Could have been moved by a prior P2F's pass in this loop
303
+ continue
304
+ displaced_f = flight_by_uid[cand_uid]
305
+ displaced_std_iso = displaced_f.std.isoformat(timespec="minutes")
306
+ _displace_one(
307
+ cand_uid, p2f_f, holder_id, "v2-window", rstate,
308
+ flight_by_uid, staff_today, matrix, counts,
309
+ staff_stds, excluded, p2f_handlers,
310
+ d_day, summary,
311
+ )
312
+ if rstate[cand_uid]["staff_id"] != holder_id:
313
+ removed_for_this_p2f.append(
314
+ (str(displaced_f.flt), displaced_std_iso),
315
+ )
316
+ if holder_id == owner_id:
317
+ summary.note(
318
+ holder_id,
319
+ f"Below target: 1 flight removed for "
320
+ f"P2F FLT {p2f_f.flt} (v2 window)",
321
+ )
322
+ else:
323
+ summary.note(
324
+ holder_id,
325
+ f"P2F prep host (v2): 1 flight removed for "
326
+ f"FLT {p2f_f.flt} (owner was "
327
+ f"{rstate[p2f_uid]['staff_name']})",
328
+ )
329
+
330
+ # Warning column on P2F row: list removed flights.
331
+ if removed_for_this_p2f:
332
+ tag = ", ".join(
333
+ f"FLT {flt}@{std}" for flt, std in removed_for_this_p2f
334
+ )
335
+ existing = rstate[p2f_uid].get("warning") or ""
336
+ new_warn = (
337
+ f"{existing}; Removed: {tag}"
338
+ if existing else f"Removed: {tag}"
339
+ )
340
+ rstate[p2f_uid]["warning"] = new_warn
341
+
342
+ # planned_by = pre-planner when different from owner.
343
+ # relieved_by stays blank: owner is freed by the same window
344
+ # so no separate post-pair host exists in v2.
345
+ if pre_planner_id and pre_planner_id != owner_id:
346
+ pre_staff = staff_by_id.get(pre_planner_id)
347
+ if pre_staff is not None:
348
+ rstate[p2f_uid]["planned_by_emp"] = pre_planner_id
349
+ rstate[p2f_uid]["planned_by_name"] = pre_staff.name
350
+
351
+ new_rows: list[AllocationRow] = []
352
+ for uid, st in rstate.items():
353
+ f = flight_by_uid.get(uid)
354
+ if f is None:
355
+ new_rows.append(st["row"])
356
+ continue
357
+ new_rows.append(AllocationRow(
358
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
359
+ pax=f.load,
360
+ staff_employee_id=st["staff_id"] or "",
361
+ staff_name=st["staff_name"] or "",
362
+ planned_by_employee_id=st["planned_by_emp"],
363
+ planned_by_name=st["planned_by_name"],
364
+ relieved_by_employee_id=st["relieved_by_emp"],
365
+ relieved_by_name=st["relieved_by_name"],
366
+ warning=st["warning"],
367
+ sheet_target=st["sheet_target"],
368
+ is_international=f.is_international,
369
+ ))
370
+ if len(new_rows) != rows_input_count:
371
+ summary.log(
372
+ f"!! P2F v2 post-pass row count mismatch: "
373
+ f"input={rows_input_count} output={len(new_rows)}"
374
+ )
375
+ return new_rows, summary
376
+
377
+
378
+ def apply_p2f_post_pass(
379
+ rows: list[AllocationRow],
380
+ flights: list[FlightInput],
381
+ staff_today: list[StaffMember],
382
+ matrix: dict[str, set[str]],
383
+ p2f_handlers: dict[ShiftCode, str],
384
+ d_day: date_t,
385
+ tolerance_minutes: int = 15,
386
+ ) -> tuple[list[AllocationRow], P2FPostPassSummary]:
387
+ """Apply §3 P2F workload adjustment to ``rows``.
388
+
389
+ Returns ``(new_rows, summary)``. Row count is preserved — flights
390
+ are reassigned (or kept on the handler if redistribution fails),
391
+ never dropped.
392
+ """
393
+ summary = P2FPostPassSummary()
394
+ flight_by_uid = {f.unique_id: f for f in flights}
395
+
396
+ # Mutable working state — same shape as INTL post-pass.
397
+ rstate: dict[str, dict] = {}
398
+ rows_input_count = len(rows)
399
+ for r in rows:
400
+ uid = (
401
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
402
+ f"|{r.date.isoformat()}"
403
+ )
404
+ rstate[uid] = {
405
+ "row": r,
406
+ "staff_id": r.staff_employee_id,
407
+ "staff_name": r.staff_name,
408
+ "planned_by_emp": r.planned_by_employee_id,
409
+ "planned_by_name": r.planned_by_name,
410
+ "relieved_by_emp": r.relieved_by_employee_id,
411
+ "relieved_by_name": r.relieved_by_name,
412
+ "warning": r.warning,
413
+ "sheet_target": r.sheet_target,
414
+ "uid": uid,
415
+ }
416
+
417
+ # Per-staff state for §4: counts + STD lists (for H10 spacing).
418
+ counts: dict[str, int] = defaultdict(int)
419
+ staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
420
+ for uid, st in rstate.items():
421
+ if not st["staff_id"]:
422
+ continue
423
+ f_ref = flight_by_uid.get(uid)
424
+ if f_ref is None:
425
+ continue
426
+ counts[st["staff_id"]] += 1
427
+ staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
428
+ excluded: set[str] = set()
429
+
430
+ # Iterate P2F flights chronologically.
431
+ p2f_uids = sorted(
432
+ (uid for uid, st in rstate.items()
433
+ if flight_by_uid.get(uid)
434
+ and flight_by_uid[uid].ops_class == OpsClass.P2F
435
+ and st["staff_id"]),
436
+ key=lambda u: std_to_ops_day_minutes(
437
+ flight_by_uid[u].std, flight_by_uid[u].date, d_day,
438
+ ),
439
+ )
440
+
441
+ # employee_id -> StaffMember for ZC-detection lookups
442
+ staff_by_id: dict[str, StaffMember] = {
443
+ s.employee_id: s for s in staff_today if s.employee_id
444
+ }
445
+
446
+ for p2f_uid in p2f_uids:
447
+ p2f_f = flight_by_uid[p2f_uid]
448
+ owner_id = rstate[p2f_uid]["staff_id"]
449
+ if not owner_id:
450
+ continue
451
+ summary.n_p2f_processed += 1
452
+ p2f_min = std_to_ops_day_minutes(p2f_f.std, p2f_f.date, d_day)
453
+ # Track removed flights for this P2F's warning column.
454
+ removed_for_this_p2f: list[tuple[str, str]] = [] # (flt, std_iso)
455
+ # 2026-05-27 (user direction): record which host did the prep
456
+ # before vs after the P2F STD so we can populate planned_by /
457
+ # relieved_by on the P2F row. D-3 / D-1 → planned_by (host
458
+ # does prep BEFORE the flight). D+20 → relieved_by (host does
459
+ # paperwork AFTER). When the host is the owner themselves
460
+ # (same person flying the leg), skip the annotation — a self-
461
+ # pair label adds no information.
462
+ pre_host_id: str | None = None # D-3 preferred, D-1 fallback
463
+ pre_host_name: str | None = None
464
+ post_host_id: str | None = None # D+20
465
+ post_host_name: str | None = None
466
+
467
+ for offset, label, anchor_count in _ANCHORS:
468
+ anchor_min = p2f_min + offset
469
+ # 2026-05-26 redesign: host = whichever shift's STD window
470
+ # contains the anchor. Role-agnostic — staff or ZC, doesn't
471
+ # matter for the mapping step.
472
+ host_shift = _anchor_host_shift(anchor_min)
473
+ if host_shift is None:
474
+ summary.n_anchor_shortage += 1
475
+ summary.log(
476
+ f"P2F flt {p2f_f.flt} ({label}): anchor "
477
+ f"{anchor_min // 60:02d}:{anchor_min % 60:02d} falls "
478
+ "in no P2F-handler-bearing shift window — no "
479
+ "displacement (W220 may also fire from step3)"
480
+ )
481
+ continue
482
+ host_id = p2f_handlers.get(host_shift)
483
+ if not host_id:
484
+ summary.n_anchor_shortage += 1
485
+ summary.log(
486
+ f"P2F flt {p2f_f.flt} ({label}): no P2F handler "
487
+ f"nominated for host shift {host_shift} — "
488
+ "anchor unhosted"
489
+ )
490
+ continue
491
+ host_staff = staff_by_id.get(host_id)
492
+ # 2026-05-27: record host for the P2F row's planned_by /
493
+ # relieved_by columns. D-3 / D-1 → planned_by (work BEFORE
494
+ # the flight), D → relieved_by (the host taking the
495
+ # immediate post-P2F slot). Skip when host == owner (would
496
+ # be a self-pair label). D-3 takes precedence over D-1; if
497
+ # D-3 host is the owner, D-1's host fills the planned_by
498
+ # slot. 2026-05-28: third anchor renamed D+20m → D.
499
+ if host_id != owner_id:
500
+ host_label = (
501
+ host_staff.name if host_staff is not None else host_id
502
+ )
503
+ if label == "D-3h":
504
+ pre_host_id = host_id
505
+ pre_host_name = host_label
506
+ elif label == "D-1h" and pre_host_id is None:
507
+ pre_host_id = host_id
508
+ pre_host_name = host_label
509
+ elif label == "D":
510
+ post_host_id = host_id
511
+ post_host_name = host_label
512
+ # ZC report-buffer skip: if the host is a ZC and the anchor
513
+ # falls inside their start- or end-of-shift 1.5 hr report
514
+ # buffer, ZC isn't taking flights there anyway — skip
515
+ # displacement (briefing slots into report time). Host
516
+ # annotation is ALREADY recorded above, so the column will
517
+ # still show who did the briefing.
518
+ if (host_staff is not None
519
+ and host_staff.role == Role.ZC
520
+ and _anchor_in_zc_report_buffer(host_shift, anchor_min)):
521
+ summary.log(
522
+ f"P2F flt {p2f_f.flt} ({label}): host "
523
+ f"{host_staff.name} ({host_shift}/ZC) — anchor "
524
+ f"{anchor_min // 60:02d}:{anchor_min % 60:02d} in "
525
+ "ZC report-buffer; briefing absorbed, no flight removed"
526
+ )
527
+ continue
528
+
529
+ # 2026-05-28 (user direction): pick the K closest displaceable
530
+ # flights STRICTLY AFTER the anchor (delta >= 0 — flights at
531
+ # exact anchor time qualify as "after" since they ARE the
532
+ # conflict moment, except the P2F itself which is filtered
533
+ # by ``u != p2f_uid``). NO fallback to BEFORE — if the host
534
+ # has fewer than K candidates after, displace whatever
535
+ # exists and log the shortfall.
536
+ after_candidates: list[tuple[int, str]] = []
537
+ for u, sx in rstate.items():
538
+ if sx["staff_id"] != host_id or u == p2f_uid:
539
+ continue
540
+ f_cand = flight_by_uid.get(u)
541
+ if f_cand is None or not _is_displaceable(f_cand):
542
+ continue
543
+ cand_min = std_to_ops_day_minutes(
544
+ f_cand.std, f_cand.date, d_day,
545
+ )
546
+ delta = cand_min - anchor_min
547
+ if delta < 0:
548
+ continue
549
+ after_candidates.append((delta, u))
550
+ after_candidates.sort() # ascending — closest first
551
+
552
+ if not after_candidates:
553
+ summary.n_anchor_shortage += 1
554
+ summary.log(
555
+ f"P2F flt {p2f_f.flt} ({label}, host "
556
+ f"{host_staff.name if host_staff else host_id}): "
557
+ "host has no displaceable flight AFTER the anchor — "
558
+ "nothing to remove"
559
+ )
560
+ continue
561
+
562
+ n_to_remove = min(anchor_count, len(after_candidates))
563
+ if n_to_remove < anchor_count:
564
+ summary.n_anchor_shortage += 1
565
+ summary.log(
566
+ f"P2F flt {p2f_f.flt} ({label}): asked for "
567
+ f"{anchor_count} removals, only {len(after_candidates)} "
568
+ f"candidate(s) after anchor — taking {n_to_remove}"
569
+ )
570
+
571
+ for _delta, best_uid in after_candidates[:n_to_remove]:
572
+ # State could have changed since we built the list
573
+ # (rare: a prior anchor in the same loop already moved
574
+ # this flight). Re-check the staff_id binding.
575
+ if rstate[best_uid]["staff_id"] != host_id:
576
+ continue
577
+ displaced_f = flight_by_uid[best_uid]
578
+ displaced_std_iso = displaced_f.std.isoformat(timespec="minutes")
579
+ _displace_one(
580
+ best_uid, p2f_f, host_id, label, rstate,
581
+ flight_by_uid, staff_today, matrix, counts,
582
+ staff_stds, excluded, p2f_handlers,
583
+ d_day, summary,
584
+ )
585
+ if rstate[best_uid]["staff_id"] != host_id:
586
+ removed_for_this_p2f.append(
587
+ (str(displaced_f.flt), displaced_std_iso),
588
+ )
589
+ if host_id != owner_id:
590
+ summary.note(
591
+ host_id,
592
+ f"P2F prep host: 1 flight removed for "
593
+ f"FLT {p2f_f.flt} {label} (owner was "
594
+ f"{rstate[p2f_uid]['staff_name']})",
595
+ )
596
+ else:
597
+ summary.note(
598
+ host_id,
599
+ f"Below target: 1 flight removed for "
600
+ f"P2F FLT {p2f_f.flt} {label}",
601
+ )
602
+
603
+ # 2026-05-26 user direction: populate the P2F row's warning
604
+ # column with the list of removed flights — just "FLT <no>@HH:MM".
605
+ # This is only for P2F rows (other rows keep their existing
606
+ # warning content). The warning column is where prep cost
607
+ # surfaces.
608
+ if removed_for_this_p2f:
609
+ tag = ", ".join(
610
+ f"FLT {flt}@{std}" for flt, std in removed_for_this_p2f
611
+ )
612
+ existing = rstate[p2f_uid].get("warning") or ""
613
+ new_warn = (
614
+ f"{existing}; Removed: {tag}"
615
+ if existing else f"Removed: {tag}"
616
+ )
617
+ rstate[p2f_uid]["warning"] = new_warn
618
+
619
+ # 2026-05-27 user direction: P2F rows DO carry pair-style
620
+ # annotations now — but driven by host (not by the pair table).
621
+ # planned_by = D-3 / D-1 host (prep BEFORE flight).
622
+ # relieved_by = D+20 host (paperwork AFTER flight).
623
+ # relabel_pair_columns no longer blanks P2F so these survive
624
+ # to the output sheet.
625
+ if pre_host_id is not None:
626
+ rstate[p2f_uid]["planned_by_emp"] = pre_host_id
627
+ rstate[p2f_uid]["planned_by_name"] = pre_host_name
628
+ if post_host_id is not None:
629
+ rstate[p2f_uid]["relieved_by_emp"] = post_host_id
630
+ rstate[p2f_uid]["relieved_by_name"] = post_host_name
631
+
632
+ # Rebuild rows from rstate — same loop as INTL post-pass.
633
+ new_rows: list[AllocationRow] = []
634
+ for uid, st in rstate.items():
635
+ f = flight_by_uid.get(uid)
636
+ if f is None:
637
+ new_rows.append(st["row"])
638
+ continue
639
+ new_rows.append(AllocationRow(
640
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
641
+ pax=f.load,
642
+ staff_employee_id=st["staff_id"] or "",
643
+ staff_name=st["staff_name"] or "",
644
+ planned_by_employee_id=st["planned_by_emp"],
645
+ planned_by_name=st["planned_by_name"],
646
+ relieved_by_employee_id=st["relieved_by_emp"],
647
+ relieved_by_name=st["relieved_by_name"],
648
+ warning=st["warning"],
649
+ sheet_target=st["sheet_target"],
650
+ is_international=f.is_international,
651
+ ))
652
+ if len(new_rows) != rows_input_count:
653
+ summary.log(
654
+ f"!! P2F post-pass row count mismatch: input={rows_input_count} "
655
+ f"output={len(new_rows)}"
656
+ )
657
+ return new_rows, summary
658
+
659
+
660
+ def _displace_one(
661
+ displaced_uid: str,
662
+ p2f_f: FlightInput,
663
+ handler_id: str,
664
+ anchor_label: str,
665
+ rstate: dict[str, dict],
666
+ flight_by_uid: dict[str, FlightInput],
667
+ staff_today: list[StaffMember],
668
+ matrix: dict[str, set[str]],
669
+ counts: dict[str, int],
670
+ staff_stds: dict[str, list[SpacingKey]],
671
+ excluded: set[str],
672
+ p2f_handlers: dict[ShiftCode, str],
673
+ d_day: date_t,
674
+ summary: P2FPostPassSummary,
675
+ ) -> None:
676
+ """Move one displaceable flight from the handler to a §4
677
+ most-eligible recipient. If no recipient qualifies, leave the
678
+ flight on the handler (no-op) and log the failure."""
679
+ dst = rstate[displaced_uid]
680
+ displaced_f = flight_by_uid[displaced_uid]
681
+ displaced_entry = spacing_key(displaced_f, d_day)
682
+
683
+ # Temporarily decrement the handler's count + remove the STD so
684
+ # §4 sees an accurate post-removal state.
685
+ counts[handler_id] = max(0, counts.get(handler_id, 0) - 1)
686
+ if handler_id in staff_stds:
687
+ with contextlib.suppress(ValueError):
688
+ staff_stds[handler_id].remove(displaced_entry)
689
+
690
+ # Exclude the handler so the same person doesn't take their own
691
+ # flight back.
692
+ excluded_for_pick = excluded | {handler_id}
693
+ new_holder = _most_eligible(
694
+ displaced_f, staff_today, matrix, counts, excluded_for_pick,
695
+ preferred_shift=None, # any qualifying shift
696
+ p2f_handlers=p2f_handlers,
697
+ staff_stds=staff_stds, flight_key=displaced_entry,
698
+ )
699
+ if new_holder is None:
700
+ # Restore — keep the flight on the handler.
701
+ counts[handler_id] += 1
702
+ staff_stds.setdefault(handler_id, []).append(displaced_entry)
703
+ summary.n_redistribute_failed += 1
704
+ summary.log(
705
+ f"P2F flt {p2f_f.flt} ({anchor_label}): could not redistribute "
706
+ f"flt {displaced_f.flt} from {rstate[displaced_uid]['staff_name']}"
707
+ )
708
+ return
709
+
710
+ # Commit the reassignment.
711
+ counts[new_holder.employee_id] = counts.get(new_holder.employee_id, 0) + 1
712
+ staff_stds.setdefault(new_holder.employee_id, []).append(displaced_entry)
713
+ dst["staff_id"] = new_holder.employee_id
714
+ dst["staff_name"] = new_holder.name
715
+ dst["sheet_target"] = sheet_target_for(displaced_f, new_holder)
716
+ excluded.add(new_holder.employee_id)
717
+ summary.n_removed += 1
718
+ summary.n_redistributed += 1
719
+ summary.log(
720
+ f"P2F flt {p2f_f.flt} ({anchor_label}): removed flt "
721
+ f"{displaced_f.flt} from handler -> "
722
+ f"{new_holder.name} ({new_holder.shift_today})"
723
+ )