flight-alloc 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
|
@@ -0,0 +1,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
|
+
)
|