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,483 @@
1
+ """INTL post-pass — Phase 3 (2026-05-14, INTL overhaul).
2
+
3
+ The post-pass runs AFTER the CP-SAT solver returns and AFTER
4
+ ``assemble_allocation_rows`` builds the initial AllocationRow list.
5
+
6
+ For each INTL DEP flight (``is_international=True`` under the Phase 3
7
+ DEP-only redefinition — Change 9), the post-pass:
8
+
9
+ * Removes **1 normal-domestic preceding flight** from the same
10
+ handler's chronological list, sending it to the §4 redistribution
11
+ pool. (Change 2/3 narrowed mechanics — was "any INTL", is now
12
+ "INTL DEP".)
13
+ * **Escalates to 2 preceding flights** if the same handler already
14
+ has a prior INTL DEP within 15-20 min (Change 3 — extra buffer for
15
+ tight INTL-INTL clusters).
16
+ * P2F preceding flights are **skipped over** — only
17
+ normal-domestic flights are eligible for removal.
18
+
19
+ §2.A (the 5-case cross-shift preplan table) was DELETED in Phase 3
20
+ because Change 4 (Phase 2-landed) made cross-shift INTL planning
21
+ impossible (INTL flights now self-pair: PLANNED_BY = RELIEVED_BY =
22
+ staff). The case-table matched nothing, so it's dead code.
23
+
24
+ The hard `<15 min` same-handler INTL DEP→INTL DEP constraint lives in
25
+ the solver (Change 5, Phase 4 pending). This post-pass takes whatever
26
+ the solver produced and applies the buffer rule.
27
+
28
+ Design notes lived in ``docs/intl_overhaul_2026-05-14.md`` (not included in
29
+ this repository snapshot).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextlib
35
+ from collections import defaultdict
36
+ from dataclasses import dataclass, field
37
+ from datetime import date as date_t
38
+
39
+ from ..schemas import (
40
+ AllocationRow,
41
+ FlightInput,
42
+ OpsClass,
43
+ Role,
44
+ ShiftCode,
45
+ StaffMember,
46
+ )
47
+ from .caps import hard_cap_for
48
+ from .eligibility import sheet_target_for
49
+ from .windows import SpacingKey, spacing_clear, spacing_key, std_to_ops_day_minutes
50
+
51
+ # Aviation pre-plan offset: planning happens at D-75 (75 min before STD).
52
+ # Kept here for re-export — eligibility.py owns the canonical constant
53
+ # and the coverage helper; postpass_intl re-imports them where needed.
54
+ PREPLAN_OFFSET_MIN = 75
55
+
56
+ # Phase 3 / Change 3 (2026-05-14): preceding-flight removal escalates
57
+ # from 1 to 2 when the same handler's prior INTL DEP is within this
58
+ # gap band (inclusive endpoints, in minutes).
59
+ INTL_TIGHT_GAP_MIN_MIN = 15
60
+ INTL_TIGHT_GAP_MIN_MAX = 20
61
+
62
+
63
+ def compute_removal_count(prev_intl_gap_min: int | None) -> int:
64
+ """Phase 3 / Change 3 — preceding-flight removal count.
65
+
66
+ Returns 2 when the same handler's previous INTL DEP is in the
67
+ "tight" band ``[15, 20]`` minutes earlier (extra buffer needed);
68
+ returns 1 otherwise (default — including the case where the
69
+ current INTL DEP is the handler's first of the day).
70
+
71
+ Note: same-handler INTL DEP pairs with gap < 15 min are rejected
72
+ by the solver as a hard constraint (Change 5, Phase 4). The post-
73
+ pass treats them as the default 1-removal case if any slip through.
74
+ """
75
+ if prev_intl_gap_min is None:
76
+ return 1
77
+ if INTL_TIGHT_GAP_MIN_MIN <= prev_intl_gap_min <= INTL_TIGHT_GAP_MIN_MAX:
78
+ return 2
79
+ return 1
80
+
81
+
82
+ def _eligible_for_removal(flight: FlightInput | None) -> bool:
83
+ """Phase 3 / Change 2 — a flight is eligible to be displaced by the
84
+ INTL DEP preceding-flight rule iff it's a plain-domestic flight
85
+ (not INTL, not P2F).
86
+
87
+ Renamed from ``_is_plain_domestic`` for clarity per the Change 2
88
+ amendment that scopes the removable-prior pool to normal domestic
89
+ flights only (skips P2F).
90
+ """
91
+ if flight is None:
92
+ return False
93
+ if flight.is_international:
94
+ return False
95
+ return flight.ops_class != OpsClass.P2F
96
+
97
+
98
+ @dataclass
99
+ class PostPassSummary:
100
+ """Diagnostic counts + workload reason notes for the audit harness.
101
+
102
+ Phase 3 cleanup (2026-05-14): §2.A counters (``n_case_reassign``,
103
+ ``n_case_migrate``, ``n_fallback_to_2b``) were dropped — the
104
+ case-table is gone, the counters can't ever fire. ``n_case_2b``
105
+ is kept as the canonical preceding-flight-removal counter.
106
+ """
107
+
108
+ n_intl_processed: int = 0
109
+ n_case_2b: int = 0 # preceding-flight removals applied
110
+ n_redistributed: int = 0 # pool flights successfully re-homed
111
+ n_redistribute_failed: int = 0
112
+ workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
113
+ log_lines: list[str] = field(default_factory=list)
114
+
115
+ def note(self, staff_id: str, reason: str) -> None:
116
+ self.workload_notes[staff_id].append(reason)
117
+
118
+ def log(self, msg: str) -> None:
119
+ self.log_lines.append(msg)
120
+
121
+
122
+ # ---------- §4 most-eligible helper (still used by postpass_p2f) ----------
123
+
124
+
125
+ def _most_eligible(
126
+ flight: FlightInput,
127
+ staff_today: list[StaffMember],
128
+ matrix: dict[str, set[str]],
129
+ current_counts: dict[str, int],
130
+ excluded_recipients: set[str],
131
+ preferred_shift: ShiftCode | None,
132
+ p2f_handlers: dict[ShiftCode, str],
133
+ staff_stds: dict[str, list[SpacingKey]],
134
+ flight_key: SpacingKey,
135
+ target_max: int = 22,
136
+ ) -> StaffMember | None:
137
+ """Pick the most-eligible staff for ``flight`` (user §4 + H10 guard).
138
+
139
+ Filters in priority order:
140
+ 1. Shift's STD window covers this flight (matrix encodes this)
141
+ 2. ``current_counts[s] < target_max`` (has room below target)
142
+ 3. P2F → P2F-licensed only
143
+ 4. Not in ``excluded_recipients`` (no cascading post-pass)
144
+ 5. Hard cap holds
145
+ 6. Eligibility matrix includes them for this flight
146
+ 7. **H10 spacing**: every existing flight keeps its gap to this
147
+ STD (15 min, 30 when a domestic flight precedes an INTL one)
148
+
149
+ Tie-break: lowest current count, then alphabetical by name.
150
+
151
+ When ``preferred_shift`` is set, restrict to that shift first; if
152
+ no one in that shift qualifies, expand to all shifts whose window
153
+ covers the STD.
154
+ """
155
+ eligible_set = matrix.get(flight.unique_id, set())
156
+ p2f_required = flight.ops_class == OpsClass.P2F
157
+
158
+ def _candidate(s: StaffMember) -> bool:
159
+ if s.employee_id in excluded_recipients:
160
+ return False
161
+ if s.shift_today is None or s.role == Role.AM:
162
+ return False
163
+ if s.employee_id not in eligible_set:
164
+ return False
165
+ staff_cap = hard_cap_for(s)
166
+ if current_counts.get(s.employee_id, 0) >= staff_cap:
167
+ return False
168
+ if p2f_required and not s.is_p2f_licensed:
169
+ return False
170
+ return spacing_clear(flight_key, staff_stds.get(s.employee_id, ()))
171
+
172
+ def _pick_from(pool: list[StaffMember]) -> StaffMember | None:
173
+ under = [s for s in pool if current_counts.get(s.employee_id, 0) < target_max]
174
+ if under:
175
+ under.sort(key=lambda s: (current_counts.get(s.employee_id, 0), s.name))
176
+ return under[0]
177
+ if pool:
178
+ pool.sort(key=lambda s: (current_counts.get(s.employee_id, 0), s.name))
179
+ return pool[0]
180
+ return None
181
+
182
+ candidates = [s for s in staff_today if _candidate(s)]
183
+ if preferred_shift is not None:
184
+ preferred_pool = [s for s in candidates if s.shift_today == preferred_shift]
185
+ pick = _pick_from(preferred_pool)
186
+ if pick is not None:
187
+ return pick
188
+ return _pick_from(candidates)
189
+
190
+
191
+ # ---------- main entry point ----------
192
+
193
+ def apply_intl_post_pass(
194
+ rows: list[AllocationRow],
195
+ flights: list[FlightInput],
196
+ staff_today: list[StaffMember],
197
+ matrix: dict[str, set[str]],
198
+ elig_ctx_p2f_handlers: dict[ShiftCode, str],
199
+ d_day: date_t,
200
+ skip_intl_removal_keys: frozenset[str] = frozenset(),
201
+ ) -> tuple[list[AllocationRow], PostPassSummary]:
202
+ """Apply Phase 3 INTL post-pass (§2.B with band escalation) to
203
+ ``rows``. Returns updated rows and a summary the orchestrator can
204
+ fold into OUT_Workload_Summary.
205
+
206
+ For each INTL DEP flight (sorted by STD ascending):
207
+ - Compute the same handler's previous INTL DEP gap (if any).
208
+ - ``compute_removal_count(gap)`` decides 1 or 2 removals.
209
+ - Remove that many of the host's chronologically PREVIOUS
210
+ normal-domestic flights (skip P2F); each removed
211
+ flight goes to §4 redistribution.
212
+ """
213
+ summary = PostPassSummary()
214
+ staff_by_id = {s.employee_id: s for s in staff_today}
215
+ flight_by_uid = {f.unique_id: f for f in flights}
216
+
217
+ # Mutable working state. We key by the AllocationRow's reconstructed
218
+ # uid (matches FlightInput.unique_id) so we can swap fields without
219
+ # rebuilding immutable AllocationRow each iteration.
220
+ rstate: dict[str, dict] = {}
221
+ rows_input_count = len(rows)
222
+ rows_with_no_flight_lookup: list[str] = []
223
+ for r in rows:
224
+ uid = (
225
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
226
+ f"|{r.date.isoformat()}"
227
+ )
228
+ if uid not in flight_by_uid:
229
+ rows_with_no_flight_lookup.append(uid)
230
+ rstate[uid] = {
231
+ "row": r,
232
+ "staff_id": r.staff_employee_id,
233
+ "staff_name": r.staff_name,
234
+ "planned_by_emp": r.planned_by_employee_id,
235
+ "planned_by_name": r.planned_by_name,
236
+ "relieved_by_emp": r.relieved_by_employee_id,
237
+ "relieved_by_name": r.relieved_by_name,
238
+ "warning": r.warning,
239
+ "sheet_target": r.sheet_target,
240
+ "uid": uid,
241
+ }
242
+ if len(rstate) != rows_input_count:
243
+ summary.log(
244
+ f"WARNING: rstate has {len(rstate)} entries from "
245
+ f"{rows_input_count} input rows — duplicate uids detected"
246
+ )
247
+ if rows_with_no_flight_lookup:
248
+ summary.log(
249
+ f"WARNING: {len(rows_with_no_flight_lookup)} rows have no "
250
+ f"matching FlightInput (will rebuild from row alone). "
251
+ f"sample uids: {rows_with_no_flight_lookup[:3]}"
252
+ )
253
+
254
+ # Current per-staff count (uid set) — used by §4 cap check.
255
+ counts: dict[str, set[str]] = defaultdict(set)
256
+ for uid, st in rstate.items():
257
+ if st["staff_id"]:
258
+ counts[st["staff_id"]].add(uid)
259
+ current_counts = {sid: len(uids) for sid, uids in counts.items()}
260
+
261
+ # Per-staff SpacingKey list for H10 enforcement in §4 most-eligible.
262
+ # Refreshed every time we move a flight between staff so each new
263
+ # redistribution sees the up-to-date schedule.
264
+ staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
265
+ for uid, st in rstate.items():
266
+ if not st["staff_id"]:
267
+ continue
268
+ f_ref = flight_by_uid.get(uid)
269
+ if f_ref is None:
270
+ continue
271
+ staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
272
+
273
+ # Recipients-already-touched: §4 forbids cascading.
274
+ excluded: set[str] = set()
275
+
276
+ # Iterate INTL flights deterministically (STD asc).
277
+ intl_uids = sorted(
278
+ (uid for uid, st in rstate.items()
279
+ if flight_by_uid.get(uid) and flight_by_uid[uid].is_international
280
+ and st["staff_id"]),
281
+ key=lambda u: std_to_ops_day_minutes(
282
+ flight_by_uid[u].std, flight_by_uid[u].date, d_day,
283
+ ),
284
+ )
285
+ summary.n_intl_processed = len(intl_uids)
286
+
287
+ for uid in intl_uids:
288
+ f = flight_by_uid[uid]
289
+ st = rstate[uid]
290
+ host = staff_by_id.get(st["staff_id"])
291
+ if host is None or host.shift_today is None:
292
+ continue
293
+
294
+ # Phase R override: skip preceding-flight removal entirely for
295
+ # this INTL DEP if the operator approved it via an override.
296
+ flight_key = f"{f.flt}|{f.std.isoformat(timespec='minutes')}"
297
+ if flight_key in skip_intl_removal_keys:
298
+ summary.log(
299
+ f"INTL flt {f.flt}: preceding-removal SKIPPED by "
300
+ f"an override (skip_intl_removal)"
301
+ )
302
+ continue
303
+
304
+ # Compute the same-handler previous INTL gap (Change 3).
305
+ intl_std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
306
+ prev_intl_std_min: int | None = None
307
+ for u2, sx2 in rstate.items():
308
+ if u2 == uid or sx2["staff_id"] != host.employee_id:
309
+ continue
310
+ f2 = flight_by_uid.get(u2)
311
+ if f2 is None or not f2.is_international:
312
+ continue
313
+ f2_min = std_to_ops_day_minutes(f2.std, f2.date, d_day)
314
+ if f2_min < intl_std_min and (
315
+ prev_intl_std_min is None or f2_min > prev_intl_std_min
316
+ ):
317
+ prev_intl_std_min = f2_min
318
+ prev_gap = (
319
+ intl_std_min - prev_intl_std_min
320
+ if prev_intl_std_min is not None else None
321
+ )
322
+ n_to_remove = compute_removal_count(prev_gap)
323
+
324
+ _apply_preceding_removal(
325
+ f, uid, host, n_to_remove, rstate, current_counts,
326
+ excluded, summary, flight_by_uid, staff_today, matrix, d_day,
327
+ elig_ctx_p2f_handlers, staff_stds,
328
+ )
329
+
330
+ # Rebuild the rows list from rstate.
331
+ new_rows: list[AllocationRow] = []
332
+ n_kept_original = 0
333
+ for uid, st in rstate.items():
334
+ f = flight_by_uid.get(uid)
335
+ if f is None:
336
+ new_rows.append(st["row"])
337
+ n_kept_original += 1
338
+ continue
339
+ new_rows.append(AllocationRow(
340
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
341
+ pax=f.load,
342
+ staff_employee_id=st["staff_id"] or "",
343
+ staff_name=st["staff_name"] or "",
344
+ planned_by_employee_id=st["planned_by_emp"],
345
+ planned_by_name=st["planned_by_name"],
346
+ relieved_by_employee_id=st["relieved_by_emp"],
347
+ relieved_by_name=st["relieved_by_name"],
348
+ warning=st["warning"],
349
+ sheet_target=st["sheet_target"],
350
+ is_international=f.is_international,
351
+ ))
352
+ if len(new_rows) != rows_input_count:
353
+ summary.log(
354
+ f"!! ROW COUNT MISMATCH: input={rows_input_count} "
355
+ f"output={len(new_rows)} kept_original={n_kept_original}"
356
+ )
357
+ return new_rows, summary
358
+
359
+
360
+ # ---------- preceding-flight removal (Phase 3 §2.B with bands) ----------
361
+
362
+ def _set_assignee(
363
+ st: dict, new_staff: StaffMember,
364
+ counts: dict[str, int], old_staff_id: str | None,
365
+ staff_stds: dict[str, list[SpacingKey]], flight_key: SpacingKey,
366
+ ) -> None:
367
+ """Move the row's staff field from ``old_staff_id`` to ``new_staff``.
368
+
369
+ Also maintains ``staff_stds`` (per-staff STD list) so subsequent
370
+ §4 most-eligible calls correctly enforce H10 spacing.
371
+ """
372
+ if old_staff_id:
373
+ counts[old_staff_id] = max(0, counts.get(old_staff_id, 0) - 1)
374
+ if old_staff_id in staff_stds:
375
+ with contextlib.suppress(ValueError):
376
+ staff_stds[old_staff_id].remove(flight_key)
377
+ counts[new_staff.employee_id] = counts.get(new_staff.employee_id, 0) + 1
378
+ staff_stds.setdefault(new_staff.employee_id, []).append(flight_key)
379
+ st["staff_id"] = new_staff.employee_id
380
+ st["staff_name"] = new_staff.name
381
+
382
+
383
+ def _apply_preceding_removal(
384
+ f: FlightInput,
385
+ uid: str,
386
+ host: StaffMember,
387
+ n_to_remove: int,
388
+ rstate: dict[str, dict],
389
+ counts: dict[str, int],
390
+ excluded: set[str],
391
+ summary: PostPassSummary,
392
+ flight_by_uid: dict[str, FlightInput],
393
+ staff_today: list[StaffMember],
394
+ matrix: dict[str, set[str]],
395
+ d_day: date_t,
396
+ p2f_handlers: dict[ShiftCode, str],
397
+ staff_stds: dict[str, list[SpacingKey]],
398
+ ) -> None:
399
+ """Phase 3 / Changes 2 + 3 — remove up to ``n_to_remove`` of the
400
+ host's chronologically previous **normal-domestic** flights and
401
+ redistribute each via §4. P2F flights are skipped.
402
+ """
403
+ intl_std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
404
+ # Candidate set: host's flights with STD < intl_std_min, eligible
405
+ # for removal (plain-domestic).
406
+ prev_candidates: list[tuple[int, str]] = []
407
+ for u, sx in rstate.items():
408
+ if sx["staff_id"] != host.employee_id or u == uid:
409
+ continue
410
+ cand_f = flight_by_uid.get(u)
411
+ if not _eligible_for_removal(cand_f):
412
+ continue
413
+ cand_min = std_to_ops_day_minutes(cand_f.std, cand_f.date, d_day) # type: ignore[union-attr]
414
+ if cand_min < intl_std_min:
415
+ prev_candidates.append((cand_min, u))
416
+
417
+ if not prev_candidates:
418
+ summary.log(
419
+ f"INTL flt {f.flt} (host={host.name}): no chronologically "
420
+ f"previous normal-domestic flight to displace; skipping"
421
+ )
422
+ return
423
+
424
+ # Pick closest preceding flights (largest std_min < intl_std_min) first.
425
+ prev_candidates.sort(reverse=True)
426
+ targets = prev_candidates[:n_to_remove]
427
+
428
+ for displaced_std_min, displaced_uid in targets:
429
+ displaced_f = flight_by_uid[displaced_uid]
430
+ displaced_entry = spacing_key(displaced_f, d_day)
431
+ dst = rstate[displaced_uid]
432
+ # Remove from host; pool via §4 (exclude host so flight doesn't
433
+ # bounce back to them — guards against self-cycles).
434
+ counts[host.employee_id] = max(0, counts.get(host.employee_id, 0) - 1)
435
+ if host.employee_id in staff_stds:
436
+ with contextlib.suppress(ValueError):
437
+ staff_stds[host.employee_id].remove(displaced_entry)
438
+ excluded_for_pool = excluded | {host.employee_id}
439
+ new_holder = _most_eligible(
440
+ displaced_f, staff_today, matrix, counts, excluded_for_pool,
441
+ None,
442
+ p2f_handlers, staff_stds, displaced_entry,
443
+ )
444
+ if new_holder is None:
445
+ # Restore the flight on the host (no eligible recipient found).
446
+ counts[host.employee_id] += 1
447
+ staff_stds.setdefault(host.employee_id, []).append(displaced_entry)
448
+ summary.n_redistribute_failed += 1
449
+ summary.log(
450
+ f"INTL flt {f.flt}: preceding-removal failed — no eligible "
451
+ f"recipient for flt {displaced_f.flt}, keeping it with {host.name}"
452
+ )
453
+ # 2026-05-26 user direction: surface the failure on the
454
+ # INTL row's warning column so the assigner sees at-a-
455
+ # glance which INTL flights didn't get the preceding leg
456
+ # cleared. The string text is intentionally the same as
457
+ # the log line tail so a Ctrl-F across both surfaces
458
+ # turns up the same matches.
459
+ _intl_state = rstate.get(uid)
460
+ if _intl_state is not None:
461
+ _fail_msg = "preceding-removal failed — no eligible recipient"
462
+ _existing = _intl_state.get("warning") or ""
463
+ _intl_state["warning"] = (
464
+ f"{_existing}; {_fail_msg}" if _existing else _fail_msg
465
+ )
466
+ continue
467
+ _set_assignee(
468
+ dst, new_holder, counts, host.employee_id,
469
+ staff_stds, displaced_entry,
470
+ )
471
+ dst["sheet_target"] = sheet_target_for(displaced_f, new_holder)
472
+ excluded.add(new_holder.employee_id)
473
+ summary.n_redistributed += 1
474
+ summary.n_case_2b += 1
475
+ summary.note(
476
+ host.employee_id,
477
+ f"Below target: 1 flight removed for INTL FLT {f.flt}",
478
+ )
479
+ summary.log(
480
+ f"INTL flt {f.flt}: removed flt {displaced_f.flt} from "
481
+ f"{host.name} -> {new_holder.name} ({new_holder.shift_today})"
482
+ f" (n_to_remove={n_to_remove})"
483
+ )