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,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
+ ]