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,549 @@
|
|
|
1
|
+
"""Post-solve assembly for Step 4.
|
|
2
|
+
|
|
3
|
+
Turns the solver's raw ``{flight_id: employee_id}`` assignments into the
|
|
4
|
+
full allocation output: AllocationRow per flight (with PLANNED_BY /
|
|
5
|
+
RELIEVED_BY / WARNING / sheet_target populated) plus a per-staff
|
|
6
|
+
WorkloadSummaryRow.
|
|
7
|
+
|
|
8
|
+
Logic:
|
|
9
|
+
PLANNED_BY (per H6, primary pairs only): for each next-shift staff S
|
|
10
|
+
who has a primary pair at boundary B, S's first H6_COUNTS[B] flights
|
|
11
|
+
of the day get PLANNED_BY = the pair's prev-side staff.
|
|
12
|
+
|
|
13
|
+
RELIEVED_BY (per H7, primary AND handover-only pairs): for each
|
|
14
|
+
prev-shift staff S, their flights in the tail-extension window
|
|
15
|
+
(STD past nominal end, up to tail_end) get RELIEVED_BY = the
|
|
16
|
+
pair's next-side staff. If S has multiple prev-side pairs (e.g.,
|
|
17
|
+
A staff with both A→N primary and A→A1 handover-only), the FIRST
|
|
18
|
+
one matching the prev_shift is used — the assigner can override
|
|
19
|
+
via an override.
|
|
20
|
+
|
|
21
|
+
Both can populate the same row (correction r2-5): when a flight is
|
|
22
|
+
one of S's first-N (PLANNED_BY) AND in S's tail-ext (RELIEVED_BY)
|
|
23
|
+
simultaneously. Rare but supported.
|
|
24
|
+
|
|
25
|
+
Sheet routing (sheet_target_for from eligibility.py):
|
|
26
|
+
P2F → P2F, N-shift → NightOps, everything else → DayOps. Cross-date
|
|
27
|
+
flights (e.g., 05:10 D+1 STD with M staff) follow the STAFF's shift,
|
|
28
|
+
NOT the calendar date — so they go to DayOps not NightOps.
|
|
29
|
+
|
|
30
|
+
WARNING column: per-row soft-violation flag. Currently flags spacing
|
|
31
|
+
margin <20 min between consecutive flights for the same staff (H10
|
|
32
|
+
hard min is 15; <20 is a soft heads-up).
|
|
33
|
+
|
|
34
|
+
WorkloadSummaryRow per staff: actual count vs preferred / acceptable
|
|
35
|
+
/ hard cap, plus a list of violation strings (under preferred, above
|
|
36
|
+
preferred by 1, above preferred by 2+, hit hard cap, etc.).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
from datetime import date as date_t
|
|
43
|
+
|
|
44
|
+
from ..schemas import (
|
|
45
|
+
AllocationRow,
|
|
46
|
+
AllocationSheet,
|
|
47
|
+
FlightInput,
|
|
48
|
+
OpsBoundary,
|
|
49
|
+
Pair,
|
|
50
|
+
Role,
|
|
51
|
+
StaffMember,
|
|
52
|
+
WorkloadSummaryRow,
|
|
53
|
+
)
|
|
54
|
+
from .caps import acceptable_max_for, hard_cap_for, preferred_target_for
|
|
55
|
+
from .eligibility import (
|
|
56
|
+
_intl_handler_covers_d75_through_std,
|
|
57
|
+
sheet_target_for,
|
|
58
|
+
)
|
|
59
|
+
# Re-exported as ``_force_self_pair_for_intl`` — symbolic anchor the
|
|
60
|
+
# Phase 3 spec tests look up. The actual self-pair override happens
|
|
61
|
+
# inline inside ``assemble_allocation_rows`` (no need for a separate
|
|
62
|
+
# function; this name is just a re-export so the test contract finds
|
|
63
|
+
# something hasattr-able). Phase 3 may consolidate this into a proper
|
|
64
|
+
# helper if the logic grows.
|
|
65
|
+
_force_self_pair_for_intl = _intl_handler_covers_d75_through_std
|
|
66
|
+
from .windows import (
|
|
67
|
+
is_in_tail_ext,
|
|
68
|
+
std_to_ops_day_minutes,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# H6 per-primary-pair pre-plan counts (defaults). Aligned with REF_Constraints
|
|
72
|
+
# H6. The actual count for any given pair lives on Pair.preplan_count and may
|
|
73
|
+
# differ from these defaults — for example when N has 2 A-partners, the
|
|
74
|
+
# split-1+2 rule gives the "primary" A-partner preplan_count=2 and the
|
|
75
|
+
# secondary A-partner preplan_count=1 (sum still 3 per N).
|
|
76
|
+
H6_PREPLAN_COUNTS: dict[OpsBoundary, int] = {
|
|
77
|
+
OpsBoundary.N_TO_M: 1,
|
|
78
|
+
OpsBoundary.M_TO_M1: 1,
|
|
79
|
+
OpsBoundary.M_TO_A: 2,
|
|
80
|
+
OpsBoundary.M1_TO_A1: 2,
|
|
81
|
+
OpsBoundary.A_TO_N: 3,
|
|
82
|
+
OpsBoundary.A1_TO_N: 0,
|
|
83
|
+
OpsBoundary.A_TO_A1: 0, # handover-only routing, no preplan
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Per user direction 2026-05-10: when a flight in shift S's tail-ext
|
|
87
|
+
# (the 30 min after nominal end) needs RELIEVED_BY annotation, the
|
|
88
|
+
# relieving partner must be on shift R(S):
|
|
89
|
+
# M -> relieved by A (M->M1 is overlap, not relief)
|
|
90
|
+
# M1 -> relieved by A1
|
|
91
|
+
# A -> relieved by N
|
|
92
|
+
# A1 -> relieved by N
|
|
93
|
+
# N -> no tail-ext
|
|
94
|
+
_RELIEF_NEXT_SHIFT: dict[str, str] = {
|
|
95
|
+
"M": "A",
|
|
96
|
+
"M1": "A1",
|
|
97
|
+
"A": "N",
|
|
98
|
+
"A1": "N",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_pair_lookups(pairs: list[Pair]) -> tuple[
|
|
103
|
+
dict[str, list[Pair]], dict[str, list[Pair]],
|
|
104
|
+
]:
|
|
105
|
+
"""Build two indexes:
|
|
106
|
+
next_planning_pairs_for[emp] = list of pairs with preplan_count > 0
|
|
107
|
+
where emp is next-side. Round-3 update: an N staff with split 1+2
|
|
108
|
+
has TWO entries here (primary count=2 + split-secondary count=1);
|
|
109
|
+
non-split staff still have one. Pairs are returned sorted by
|
|
110
|
+
``-preplan_count`` so the higher-count pair claims the staff's
|
|
111
|
+
FIRST flights, the lower-count pair claims the next slice.
|
|
112
|
+
prev_pairs_for[emp] = list of all pairs (any role) where emp is the
|
|
113
|
+
prev-side. Drives RELIEVED_BY (any pair counts, even if
|
|
114
|
+
preplan_count=0 — handover-only pairs DO carry relief).
|
|
115
|
+
"""
|
|
116
|
+
next_planning: dict[str, list[Pair]] = defaultdict(list)
|
|
117
|
+
prev_all: dict[str, list[Pair]] = defaultdict(list)
|
|
118
|
+
for p in pairs:
|
|
119
|
+
if p.next_employee_id and p.preplan_count > 0:
|
|
120
|
+
next_planning[p.next_employee_id].append(p)
|
|
121
|
+
if p.prev_employee_id:
|
|
122
|
+
prev_all[p.prev_employee_id].append(p)
|
|
123
|
+
# Sort planning pairs: bigger preplan_count first → claims earlier
|
|
124
|
+
# flights. Tiebreak by prev_employee_id for determinism.
|
|
125
|
+
for emp in next_planning:
|
|
126
|
+
next_planning[emp].sort(
|
|
127
|
+
key=lambda p: (-p.preplan_count, p.prev_employee_id or ""),
|
|
128
|
+
)
|
|
129
|
+
return next_planning, prev_all
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_warning(
|
|
133
|
+
flight: FlightInput,
|
|
134
|
+
staff: StaffMember,
|
|
135
|
+
flight_position_in_staff: int,
|
|
136
|
+
staff_flights: list[FlightInput],
|
|
137
|
+
ops_day: date_t,
|
|
138
|
+
) -> str | None:
|
|
139
|
+
"""Compute the per-row WARNING string. None when no violation.
|
|
140
|
+
|
|
141
|
+
Per user direction 2026-05-10: the 15-min spacing soft warn was
|
|
142
|
+
too noisy (firing on every other flight). The HARD spacing rule
|
|
143
|
+
(10 min, enforced by the solver) still prevents physical overlap;
|
|
144
|
+
this function now only flags actual rule violations, not soft
|
|
145
|
+
margin warnings. The negative-min display bug ("-135 min") is
|
|
146
|
+
inherently fixed since the soft warn no longer fires.
|
|
147
|
+
"""
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def assemble_allocation_rows(
|
|
152
|
+
flights: list[FlightInput],
|
|
153
|
+
staff_today: list[StaffMember],
|
|
154
|
+
assignments: dict[str, str],
|
|
155
|
+
pairs: list[Pair],
|
|
156
|
+
ops_day: date_t,
|
|
157
|
+
) -> list[AllocationRow]:
|
|
158
|
+
"""Build the full per-flight AllocationRow list."""
|
|
159
|
+
staff_by_id = {s.employee_id: s for s in staff_today}
|
|
160
|
+
next_planning_pairs, prev_pairs_for = _build_pair_lookups(pairs)
|
|
161
|
+
|
|
162
|
+
# Group + sort flights by assigned staff (chronologically by ops-day
|
|
163
|
+
# minutes). Keyed by FlightInput.unique_id so multi-leg rotations
|
|
164
|
+
# (same FLT number, different STD) stay independent — earlier code
|
|
165
|
+
# used the bare FLT and silently merged legs.
|
|
166
|
+
flights_by_staff: dict[str, list[FlightInput]] = defaultdict(list)
|
|
167
|
+
for f in flights:
|
|
168
|
+
sid = assignments.get(f.unique_id)
|
|
169
|
+
if sid is not None:
|
|
170
|
+
flights_by_staff[sid].append(f)
|
|
171
|
+
for sid in flights_by_staff:
|
|
172
|
+
flights_by_staff[sid].sort(
|
|
173
|
+
key=lambda f: std_to_ops_day_minutes(f.std, f.date, ops_day),
|
|
174
|
+
)
|
|
175
|
+
# 2026-05-26 fix: skip INTL flights when assigning a "position"
|
|
176
|
+
# within the staff's day. INTL rows have ``planned_by`` blanked
|
|
177
|
+
# further down — counting them as positions 0/1/2 silently eats a
|
|
178
|
+
# pair partner's preplan_count slot, leaving the staff's first
|
|
179
|
+
# *visible* flights unplanned. Skipping them here keeps the "first
|
|
180
|
+
# 3 plannable flights" invariant on the visible output.
|
|
181
|
+
position_in_staff: dict[tuple[str, str], int] = {}
|
|
182
|
+
for sid, fl_list in flights_by_staff.items():
|
|
183
|
+
visible_pos = 0
|
|
184
|
+
for f in fl_list:
|
|
185
|
+
if f.is_international:
|
|
186
|
+
# Not in the plannable sequence — planned_by will be
|
|
187
|
+
# blanked anyway. Map to a sentinel so planner_by_position
|
|
188
|
+
# lookups never match these flights.
|
|
189
|
+
position_in_staff[(f.unique_id, sid)] = -1
|
|
190
|
+
continue
|
|
191
|
+
position_in_staff[(f.unique_id, sid)] = visible_pos
|
|
192
|
+
visible_pos += 1
|
|
193
|
+
|
|
194
|
+
# Pre-compute, per staff, which pair plans flight at each position.
|
|
195
|
+
# next_planning_pairs[sid] is sorted bigger-count-first; we walk the
|
|
196
|
+
# list assigning consecutive flight-positions to each pair until that
|
|
197
|
+
# pair's preplan_count is exhausted.
|
|
198
|
+
planner_by_position: dict[tuple[str, int], Pair] = {}
|
|
199
|
+
for sid, plan_pairs in next_planning_pairs.items():
|
|
200
|
+
cursor = 0
|
|
201
|
+
for p in plan_pairs:
|
|
202
|
+
for _ in range(p.preplan_count):
|
|
203
|
+
planner_by_position[(sid, cursor)] = p
|
|
204
|
+
cursor += 1
|
|
205
|
+
|
|
206
|
+
rows: list[AllocationRow] = []
|
|
207
|
+
for f in flights:
|
|
208
|
+
sid = assignments.get(f.unique_id)
|
|
209
|
+
if sid is None or sid not in staff_by_id:
|
|
210
|
+
continue
|
|
211
|
+
s = staff_by_id[sid]
|
|
212
|
+
pos = position_in_staff[(f.unique_id, sid)]
|
|
213
|
+
|
|
214
|
+
# PLANNED_BY: look up which pair (if any) plans this flight-position.
|
|
215
|
+
planned_by_emp: str | None = None
|
|
216
|
+
planned_by_name: str | None = None
|
|
217
|
+
planner_pair = planner_by_position.get((sid, pos))
|
|
218
|
+
if planner_pair is not None:
|
|
219
|
+
planned_by_emp = planner_pair.prev_employee_id
|
|
220
|
+
planned_by_name = planner_pair.prev_name
|
|
221
|
+
|
|
222
|
+
# RELIEVED_BY: only fires on flights past nominal shift end (in
|
|
223
|
+
# the handover-overlap window) for prev-side pairs (any role,
|
|
224
|
+
# including handover-only). Per user direction 2026-05-10, the
|
|
225
|
+
# relief shift for each prev shift is fixed:
|
|
226
|
+
# M relieved by A (NOT M1 — that's a different overlap)
|
|
227
|
+
# M1 relieved by A1
|
|
228
|
+
# A relieved by N
|
|
229
|
+
# A1 relieved by N
|
|
230
|
+
# N has no tail-ext (returned False above)
|
|
231
|
+
relieved_by_emp: str | None = None
|
|
232
|
+
relieved_by_name: str | None = None
|
|
233
|
+
if s.shift_today and is_in_tail_ext(f.std, f.date, ops_day, s.shift_today):
|
|
234
|
+
relief_shift = _RELIEF_NEXT_SHIFT.get(s.shift_today)
|
|
235
|
+
# 2026-05-26 fix: when a staff has multiple A→N pairs (e.g.,
|
|
236
|
+
# primary + split-secondary, or duplicated entries from a
|
|
237
|
+
# non-deduped staff_today on older builds), prefer the
|
|
238
|
+
# PRIMARY pair with the highest preplan_count so the relief
|
|
239
|
+
# annotation matches the same partner who did the bulk of
|
|
240
|
+
# the planning. Removed the double-break (the second was
|
|
241
|
+
# unreachable; flagged in OTHER_FINDINGS.md).
|
|
242
|
+
best: Pair | None = None
|
|
243
|
+
for pp in prev_pairs_for.get(sid, []):
|
|
244
|
+
if (pp.prev_shift == s.shift_today
|
|
245
|
+
and pp.next_shift == relief_shift
|
|
246
|
+
and pp.next_employee_id is not None):
|
|
247
|
+
if best is None or pp.preplan_count > best.preplan_count:
|
|
248
|
+
best = pp
|
|
249
|
+
if best is not None:
|
|
250
|
+
relieved_by_emp = best.next_employee_id
|
|
251
|
+
relieved_by_name = best.next_name
|
|
252
|
+
|
|
253
|
+
# 2026-05-16 (user direction): INTL flights leave planned_by /
|
|
254
|
+
# relieved_by BLANK. The handler is already the same person
|
|
255
|
+
# start-to-finish (F9 enforces D-75 through STD coverage), so
|
|
256
|
+
# the self-pair labels were redundant noise. Previously we
|
|
257
|
+
# over-wrote the cross-shift pair-map output with sid/name on
|
|
258
|
+
# both sides; now we just blank them so the columns stay clean.
|
|
259
|
+
if f.is_international:
|
|
260
|
+
planned_by_emp = None
|
|
261
|
+
planned_by_name = None
|
|
262
|
+
relieved_by_emp = None
|
|
263
|
+
relieved_by_name = None
|
|
264
|
+
|
|
265
|
+
warning = _build_warning(f, s, pos, flights_by_staff[sid], ops_day)
|
|
266
|
+
sheet = sheet_target_for(f, s)
|
|
267
|
+
rows.append(AllocationRow(
|
|
268
|
+
date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
|
|
269
|
+
pax=f.load,
|
|
270
|
+
staff_employee_id=sid, staff_name=s.name,
|
|
271
|
+
planned_by_employee_id=planned_by_emp,
|
|
272
|
+
planned_by_name=planned_by_name,
|
|
273
|
+
relieved_by_employee_id=relieved_by_emp,
|
|
274
|
+
relieved_by_name=relieved_by_name,
|
|
275
|
+
warning=warning,
|
|
276
|
+
sheet_target=sheet,
|
|
277
|
+
is_international=f.is_international,
|
|
278
|
+
))
|
|
279
|
+
return rows
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def relabel_pair_columns(
|
|
283
|
+
rows: list[AllocationRow],
|
|
284
|
+
flights: list[FlightInput],
|
|
285
|
+
staff_today: list[StaffMember],
|
|
286
|
+
pairs: list[Pair],
|
|
287
|
+
ops_day: date_t,
|
|
288
|
+
) -> list[AllocationRow]:
|
|
289
|
+
"""Recompute ``planned_by_*`` and ``relieved_by_*`` for each row
|
|
290
|
+
based on its CURRENT ``staff_employee_id``.
|
|
291
|
+
|
|
292
|
+
2026-05-26 fix. ``assemble_allocation_rows`` sets the pair-derived
|
|
293
|
+
labels when the row is first built from the solver's assignments.
|
|
294
|
+
INTL §2.B and P2F §3 post-passes later mutate the row's staff via
|
|
295
|
+
``_set_assignee`` — but those helpers never touch the planned_by /
|
|
296
|
+
relieved_by columns, so a flight displaced from Ravi to Priya
|
|
297
|
+
still shows planned_by=Kunal (Ravi's A→N partner). This pass
|
|
298
|
+
re-derives the labels using the row's current staff so the
|
|
299
|
+
the allocation output matches the pair map.
|
|
300
|
+
|
|
301
|
+
Rows without ``staff_employee_id`` (preplan-deferred D+1 05:05-05:30
|
|
302
|
+
rows) keep their labels untouched — those carry the N-shift
|
|
303
|
+
pre-planner from step3's round-robin, not a pair partner.
|
|
304
|
+
"""
|
|
305
|
+
from ..schemas import OpsClass as _OC
|
|
306
|
+
staff_by_id = {s.employee_id: s for s in staff_today}
|
|
307
|
+
flight_by_uid = {f.unique_id: f for f in flights}
|
|
308
|
+
next_planning_pairs, prev_pairs_for = _build_pair_lookups(pairs)
|
|
309
|
+
|
|
310
|
+
# Group rows by current staff, sort by STD.
|
|
311
|
+
rows_by_staff: dict[str, list[AllocationRow]] = defaultdict(list)
|
|
312
|
+
for r in rows:
|
|
313
|
+
if r.staff_employee_id:
|
|
314
|
+
rows_by_staff[r.staff_employee_id].append(r)
|
|
315
|
+
for sid in rows_by_staff:
|
|
316
|
+
rows_by_staff[sid].sort(
|
|
317
|
+
key=lambda r: std_to_ops_day_minutes(r.std, r.date, ops_day),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Visible-position map: INTL flights are skipped so the
|
|
321
|
+
# partner's preplan_count covers the first N _plannable_ flights.
|
|
322
|
+
def _row_uid(r: AllocationRow) -> str:
|
|
323
|
+
return (
|
|
324
|
+
f"{r.flt}|{r.dep}|{r.arr}|"
|
|
325
|
+
f"{r.std.isoformat(timespec='minutes')}|"
|
|
326
|
+
f"{r.date.isoformat()}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
position_by_key: dict[tuple[str, str], int] = {}
|
|
330
|
+
for sid, fl_rows in rows_by_staff.items():
|
|
331
|
+
visible_pos = 0
|
|
332
|
+
for r in fl_rows:
|
|
333
|
+
uid = _row_uid(r)
|
|
334
|
+
if r.is_international:
|
|
335
|
+
position_by_key[(sid, uid)] = -1
|
|
336
|
+
continue
|
|
337
|
+
position_by_key[(sid, uid)] = visible_pos
|
|
338
|
+
visible_pos += 1
|
|
339
|
+
|
|
340
|
+
planner_by_position: dict[tuple[str, int], Pair] = {}
|
|
341
|
+
for sid, plan_pairs in next_planning_pairs.items():
|
|
342
|
+
cursor = 0
|
|
343
|
+
for p in plan_pairs:
|
|
344
|
+
for _ in range(p.preplan_count):
|
|
345
|
+
planner_by_position[(sid, cursor)] = p
|
|
346
|
+
cursor += 1
|
|
347
|
+
|
|
348
|
+
out: list[AllocationRow] = []
|
|
349
|
+
for r in rows:
|
|
350
|
+
sid = r.staff_employee_id
|
|
351
|
+
if not sid:
|
|
352
|
+
out.append(r)
|
|
353
|
+
continue
|
|
354
|
+
s = staff_by_id.get(sid)
|
|
355
|
+
if s is None:
|
|
356
|
+
out.append(r)
|
|
357
|
+
continue
|
|
358
|
+
uid = _row_uid(r)
|
|
359
|
+
f = flight_by_uid.get(uid)
|
|
360
|
+
pos = position_by_key.get((sid, uid), -1)
|
|
361
|
+
|
|
362
|
+
planned_by_emp: str | None = None
|
|
363
|
+
planned_by_name: str | None = None
|
|
364
|
+
if pos >= 0:
|
|
365
|
+
planner_pair = planner_by_position.get((sid, pos))
|
|
366
|
+
if planner_pair is not None:
|
|
367
|
+
# 2026-05-26 D-75 check: planner physically can't plan
|
|
368
|
+
# a flight whose STD is more than 75 min after their
|
|
369
|
+
# shift's nominal end (planning happens at most D-75).
|
|
370
|
+
# When the next-side's flight is too far in the future
|
|
371
|
+
# for the prev-side's shift to reach, blank planned_by
|
|
372
|
+
# rather than annotate a name who couldn't actually
|
|
373
|
+
# have done the work.
|
|
374
|
+
from .windows import SHIFT_NOMINAL_MIN
|
|
375
|
+
_prev_shift = planner_pair.prev_shift
|
|
376
|
+
_prev_nominal = SHIFT_NOMINAL_MIN.get(_prev_shift) if _prev_shift else None
|
|
377
|
+
_flight_min = std_to_ops_day_minutes(r.std, r.date, ops_day)
|
|
378
|
+
if (_prev_nominal is not None
|
|
379
|
+
and _prev_nominal[1] < _flight_min - 75):
|
|
380
|
+
# prev shift ends before D-75; planner can't plan
|
|
381
|
+
pass
|
|
382
|
+
else:
|
|
383
|
+
planned_by_emp = planner_pair.prev_employee_id
|
|
384
|
+
planned_by_name = planner_pair.prev_name
|
|
385
|
+
|
|
386
|
+
relieved_by_emp: str | None = None
|
|
387
|
+
relieved_by_name: str | None = None
|
|
388
|
+
if s.shift_today and is_in_tail_ext(
|
|
389
|
+
r.std, r.date, ops_day, s.shift_today,
|
|
390
|
+
):
|
|
391
|
+
relief_shift = _RELIEF_NEXT_SHIFT.get(s.shift_today)
|
|
392
|
+
best: Pair | None = None
|
|
393
|
+
for pp in prev_pairs_for.get(sid, []):
|
|
394
|
+
if (pp.prev_shift == s.shift_today
|
|
395
|
+
and pp.next_shift == relief_shift
|
|
396
|
+
and pp.next_employee_id is not None):
|
|
397
|
+
if best is None or pp.preplan_count > best.preplan_count:
|
|
398
|
+
best = pp
|
|
399
|
+
if best is not None:
|
|
400
|
+
relieved_by_emp = best.next_employee_id
|
|
401
|
+
relieved_by_name = best.next_name
|
|
402
|
+
|
|
403
|
+
# INTL keeps blank pair-based labels (design choice — INTL is
|
|
404
|
+
# same-handler start-to-finish). P2F rows USED to be blanked here too, but per the
|
|
405
|
+
# 2026-05-27 direction P2F now carries host annotations populated
|
|
406
|
+
# by postpass_p2f (D-3/D-1 host → planned_by, D+20 host →
|
|
407
|
+
# relieved_by). Leaving P2F rows untouched here so those
|
|
408
|
+
# post-pass-assigned labels survive to the output.
|
|
409
|
+
is_p2f = f is not None and f.ops_class == _OC.P2F
|
|
410
|
+
if is_p2f:
|
|
411
|
+
# Keep whatever postpass_p2f wrote to the row.
|
|
412
|
+
out.append(r)
|
|
413
|
+
continue
|
|
414
|
+
if r.is_international:
|
|
415
|
+
planned_by_emp = None
|
|
416
|
+
planned_by_name = None
|
|
417
|
+
relieved_by_emp = None
|
|
418
|
+
relieved_by_name = None
|
|
419
|
+
|
|
420
|
+
out.append(r.model_copy(update={
|
|
421
|
+
"planned_by_employee_id": planned_by_emp,
|
|
422
|
+
"planned_by_name": planned_by_name,
|
|
423
|
+
"relieved_by_employee_id": relieved_by_emp,
|
|
424
|
+
"relieved_by_name": relieved_by_name,
|
|
425
|
+
}))
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_workload_summary(
|
|
430
|
+
staff_today: list[StaffMember],
|
|
431
|
+
assignments: dict[str, str],
|
|
432
|
+
extra_reasons: dict[str, list[str]] | None = None,
|
|
433
|
+
rows: list[AllocationRow] | None = None,
|
|
434
|
+
) -> list[WorkloadSummaryRow]:
|
|
435
|
+
"""Build one row per assignable staff with target/actual/deviation.
|
|
436
|
+
|
|
437
|
+
AMs and off-shift staff are excluded — they don't fly, and zero
|
|
438
|
+
rows aren't useful in the summary sheet.
|
|
439
|
+
|
|
440
|
+
Per user direction 2026-05-11 (revised): every allocated flight
|
|
441
|
+
counts toward ``actual``. P2F / FERRY / TEST / CHARTER are allocated
|
|
442
|
+
as normal flights with no per-staff cap separate from H16.
|
|
443
|
+
|
|
444
|
+
Per user direction 2026-05-12 (Neetu fix): when ``rows`` is provided,
|
|
445
|
+
counts are derived directly from the (post-pass) AllocationRow list
|
|
446
|
+
rather than from the parallel ``assignments`` dict. This guarantees
|
|
447
|
+
that the workload "actual" matches the number of rows the writer
|
|
448
|
+
will emit for that staff — eliminating any chance of drift between
|
|
449
|
+
the two structures after the §2/§3 post-passes mutate the rows.
|
|
450
|
+
|
|
451
|
+
Examples (post-2026-05-11):
|
|
452
|
+
BHASKAR (P2F-A handler): 6 regular + 11 P2F → actual = 17
|
|
453
|
+
KULDEEP (P2F-M handler): 12 regular + 5 P2F → actual = 17
|
|
454
|
+
ANY STAFF: 18 regular + 2 ferry + 1 test + 1 charter → actual = 22
|
|
455
|
+
"""
|
|
456
|
+
counts: dict[str, int] = defaultdict(int)
|
|
457
|
+
if rows is not None:
|
|
458
|
+
# Preferred path (Neetu fix, 2026-05-12): count from the rows
|
|
459
|
+
# themselves so the workload tally matches the allocation
|
|
460
|
+
# output exactly.
|
|
461
|
+
for r in rows:
|
|
462
|
+
if not r.staff_employee_id:
|
|
463
|
+
continue
|
|
464
|
+
counts[r.staff_employee_id] += 1
|
|
465
|
+
else:
|
|
466
|
+
for sid in assignments.values():
|
|
467
|
+
counts[sid] += 1
|
|
468
|
+
out: list[WorkloadSummaryRow] = []
|
|
469
|
+
for s in staff_today:
|
|
470
|
+
if s.role == Role.AM or s.shift_today is None:
|
|
471
|
+
continue
|
|
472
|
+
actual = counts.get(s.employee_id, 0)
|
|
473
|
+
preferred = preferred_target_for(s)
|
|
474
|
+
accept_max = acceptable_max_for(s)
|
|
475
|
+
cap = hard_cap_for(s)
|
|
476
|
+
dev_below = max(0, preferred - actual)
|
|
477
|
+
dev_above = max(0, actual - preferred)
|
|
478
|
+
violations: list[str] = []
|
|
479
|
+
if actual < preferred:
|
|
480
|
+
violations.append(f"under preferred ({actual} < {preferred})")
|
|
481
|
+
elif actual > accept_max:
|
|
482
|
+
violations.append(
|
|
483
|
+
f"above preferred ({actual} > acceptable max {accept_max})"
|
|
484
|
+
)
|
|
485
|
+
if actual >= cap:
|
|
486
|
+
violations.append(f"at hard cap ({cap})")
|
|
487
|
+
# Post-pass reasons (INTL §2, P2F §3) — appended after the
|
|
488
|
+
# standard violations so the visual order is structured.
|
|
489
|
+
if extra_reasons:
|
|
490
|
+
for r in extra_reasons.get(s.employee_id, ()):
|
|
491
|
+
violations.append(r)
|
|
492
|
+
out.append(WorkloadSummaryRow(
|
|
493
|
+
employee_id=s.employee_id, name=s.name,
|
|
494
|
+
shift=s.shift_today, role=s.role,
|
|
495
|
+
target_preferred=preferred,
|
|
496
|
+
target_acceptable_max=accept_max,
|
|
497
|
+
hard_cap=cap,
|
|
498
|
+
actual=actual,
|
|
499
|
+
deviation_below_preferred=dev_below,
|
|
500
|
+
deviation_above_preferred=dev_above,
|
|
501
|
+
violations=tuple(violations),
|
|
502
|
+
))
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ---------- color metadata for the writer ----------
|
|
507
|
+
|
|
508
|
+
PLANNED_BY_FILL_HEX = "FFD9EAF7" # light blue (correction r2-5)
|
|
509
|
+
RELIEVED_BY_FILL_HEX = "FFD9F2D9" # light green
|
|
510
|
+
WARNING_FILL_HEX = "FFFFF2CC" # light yellow
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def needs_color(row: AllocationRow) -> dict[str, str]:
|
|
514
|
+
"""Return ``{column_name: fill_hex}`` for every column on the row
|
|
515
|
+
that should be filled. The writer applies these to the output
|
|
516
|
+
workbook. Empty dict when no fill needed."""
|
|
517
|
+
fills: dict[str, str] = {}
|
|
518
|
+
if row.planned_by_employee_id:
|
|
519
|
+
fills["planned_by"] = PLANNED_BY_FILL_HEX
|
|
520
|
+
if row.relieved_by_employee_id:
|
|
521
|
+
fills["relieved_by"] = RELIEVED_BY_FILL_HEX
|
|
522
|
+
if row.warning:
|
|
523
|
+
fills["warning"] = WARNING_FILL_HEX
|
|
524
|
+
return fills
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# Sheet routing helper (used by the writer to dispatch rows to the
|
|
528
|
+
# right allocation lane).
|
|
529
|
+
|
|
530
|
+
def split_by_sheet(
|
|
531
|
+
rows: list[AllocationRow],
|
|
532
|
+
) -> dict[AllocationSheet, list[AllocationRow]]:
|
|
533
|
+
"""Group rows by sheet_target for write-time dispatching."""
|
|
534
|
+
out: dict[AllocationSheet, list[AllocationRow]] = defaultdict(list)
|
|
535
|
+
for r in rows:
|
|
536
|
+
out[r.sheet_target].append(r)
|
|
537
|
+
return out
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
__all__ = [
|
|
541
|
+
"H6_PREPLAN_COUNTS",
|
|
542
|
+
"PLANNED_BY_FILL_HEX",
|
|
543
|
+
"RELIEVED_BY_FILL_HEX",
|
|
544
|
+
"WARNING_FILL_HEX",
|
|
545
|
+
"assemble_allocation_rows",
|
|
546
|
+
"build_workload_summary",
|
|
547
|
+
"needs_color",
|
|
548
|
+
"split_by_sheet",
|
|
549
|
+
]
|