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
src/schemas.py ADDED
@@ -0,0 +1,1244 @@
1
+ """Pydantic schemas for the flight-allocation pipeline (plan §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date as date_t
6
+ from datetime import datetime as datetime_t
7
+ from datetime import time as time_t
8
+ from enum import StrEnum
9
+ from typing import Any, Literal, Self
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
+
13
+
14
+ class CrewStatus(StrEnum):
15
+ """Plan §4.1 — every legal value a roster cell may carry."""
16
+
17
+ UNDERSTUDY = "U"
18
+ DAY_OFF = "F"
19
+ PAID_LEAVE = "P/L"
20
+ CASUAL_LEAVE = "C/L"
21
+ CUSTOM_OFF = "C/OFF"
22
+ TRAINING = "TR"
23
+ SHIFT_M = "M"
24
+ SHIFT_A = "A"
25
+ SHIFT_N = "N"
26
+ SHIFT_M1 = "M1"
27
+ SHIFT_A1 = "A1"
28
+ AM_M_IGT = "M/IGT"
29
+ AM_A_IGT = "A/IGT"
30
+ ZC_M = "M/ZC"
31
+ ZC_A = "A/ZC"
32
+ ZC_N = "N/ZC"
33
+ ZC_M1 = "M1/ZC"
34
+ ZC_A1 = "A1/ZC"
35
+ # Per user direction 2026-05-10: roster can directly mark a staff
36
+ # member as the P2F handler for a shift. Cell value `M/P2F` means
37
+ # "on M shift today AND the P2F handler for M". Engine auto-picks
38
+ # without needing an override row.
39
+ # 2026-05-21 (user direction): canonical form is shift-first then
40
+ # role-suffix — M/P2F, A/P2F, N/P2F — matching the M/ZC, A/ZC,
41
+ # N/ZC convention. Legacy P2F/M / P2F/A / P2F/N still accepted via
42
+ # the canonicalization path in readers.normalize_status (parts
43
+ # sorted alphabetically — M < P2F, A < P2F, N < P2F).
44
+ P2F_M = "M/P2F"
45
+ P2F_A = "A/P2F"
46
+ P2F_N = "N/P2F"
47
+ # 2026-05-26 (user direction): a single roster cell can carry BOTH
48
+ # roles. `M/ZC/P2F` (or `M/P2F/ZC`, any order) marks the staff as
49
+ # the M-shift ZC AND the M-shift P2F handler. Canonical form is
50
+ # alphabetical: M < P2F < ZC. Cap stays at ZC's lower band (14-15
51
+ # day / 10-11 night), applied to regular+P2F combined.
52
+ ZC_P2F_M = "M/P2F/ZC"
53
+ ZC_P2F_A = "A/P2F/ZC"
54
+ ZC_P2F_N = "N/P2F/ZC"
55
+ ZC_P2F_M1 = "M1/P2F/ZC"
56
+ ZC_P2F_A1 = "A1/P2F/ZC"
57
+ OTHER = "OTHER"
58
+ BLANK = ""
59
+
60
+
61
+ NON_ASSIGNABLE: frozenset[CrewStatus] = frozenset({
62
+ CrewStatus.UNDERSTUDY,
63
+ CrewStatus.TRAINING,
64
+ CrewStatus.PAID_LEAVE,
65
+ CrewStatus.CASUAL_LEAVE,
66
+ CrewStatus.CUSTOM_OFF,
67
+ CrewStatus.OTHER,
68
+ })
69
+
70
+
71
+ class OpsClass(StrEnum):
72
+ """Sheet routing for the cleaned flights output (plan §4.13).
73
+
74
+ Per user direction 2026-05-11:
75
+ - TEST: TYPE=X AND dep==arr (same-airport loop)
76
+ - FERRY: TYPE=X AND dep!=arr (positioning leg) — also TYPE in K/P/T/G
77
+ (legacy ferry mapping)
78
+ - CHARTER: TYPE=B (charter passenger flight, new class)
79
+ - All three are allocated as normal flights and counted under
80
+ workload (no special per-staff cap). The cleaned output groups
81
+ them into a single SpecialOps group with one table
82
+ per class so the assigner sees them side by side.
83
+
84
+ Per user direction 2026-05-19:
85
+ - GULF: flights touching AUH / DOH / DXB (DEP-side) OR with
86
+ Aircraft Owner=QR. EXTRACT-ONLY — visible on the dedicated
87
+ GULF class for reference, but Step 3 does NOT read
88
+ that sheet so these flights are NEVER allocated to staff. Wins
89
+ over the legacy ``routing_drop_dep_codes`` outright drop.
90
+ """
91
+
92
+ DAY = "day"
93
+ NIGHT = "night"
94
+ P2F = "p2f"
95
+ FERRY = "ferry"
96
+ TEST = "test"
97
+ CHARTER = "charter"
98
+ GULF = "gulf"
99
+
100
+
101
+ class RawFlightRow(BaseModel):
102
+ """Raw SV portal row — permissive (plan §4.3 / §1.5.1).
103
+
104
+ Strict pax parsing is moved to Step 1's drop filter. Unknown columns are
105
+ ignored.
106
+ """
107
+
108
+ model_config = ConfigDict(extra="ignore")
109
+
110
+ flight_id: str | None = None
111
+ departure: str | None = None
112
+ arrival: str | None = None
113
+ dep_time: time_t | None = None
114
+ arr_time: time_t | None = None
115
+ aircraft_type: str | None = None
116
+ aircraft_subtype: str | None = None
117
+ # Aircraft Owner carrier code. Used for GULF owner routing and the
118
+ # extraction filters; everything else routes by TYPE / date.
119
+ owner: str | None = None
120
+ date: date_t | None = None
121
+ booked_pax_raw: str | None = None
122
+ # 2026-05-25 (user direction — extraction filters): capture every
123
+ # SV-portal cell by its raw header so the extraction-filter system
124
+ # can match on arbitrary columns (e.g. Crew, REG, MEMO) that
125
+ # aren't part of the canonical schema. Populated by
126
+ # ``read_sv_portal_from_workbook``; keys are the literal header
127
+ # strings, values are stringified cell contents (or None).
128
+ extra_columns: dict[str, str] = Field(default_factory=dict)
129
+
130
+ @field_validator(
131
+ "flight_id", "aircraft_subtype", "owner", "booked_pax_raw",
132
+ mode="before",
133
+ )
134
+ @classmethod
135
+ def _coerce_to_str(cls, v: Any) -> str | None:
136
+ if v is None:
137
+ return None
138
+ return str(v)
139
+
140
+
141
+ class CleanFlightRow(BaseModel):
142
+ """Cleaned flight row written to one of the day_ops/night_ops/... sheets.
143
+
144
+ Phase 3 (2026-05-14, INTL overhaul):
145
+ - ``is_international``: **DEP-side only** — ``dep ∈ intl_codes``
146
+ (was ``dep OR arr``). Rendered yellow in workbook + browser.
147
+ - The legacy ``routing_via_excluded_hub`` field (pink-highlight for
148
+ Gulf-3 ARR) was removed; Gulf-3 ARR flights are plain domestic now.
149
+ Gulf-3 DEP flights are dropped at Step 1.
150
+ - ``is_preplan_deferred``: D+1 flights with STD ∈ [05:05, 05:30]
151
+ enter the pool but are NOT allocated today; Step 3 re-appends
152
+ them with warning="PREPLAN_DEFERRED".
153
+ """
154
+
155
+ model_config = ConfigDict(frozen=True)
156
+
157
+ date: date_t
158
+ flt: str
159
+ type: str
160
+ ac: str
161
+ dep: str
162
+ arr: str
163
+ std: time_t
164
+ load: int = Field(ge=0, le=600)
165
+ ops_class: OpsClass
166
+ is_international: bool = False
167
+ is_preplan_deferred: bool = False
168
+
169
+ @field_validator("dep", "arr")
170
+ @classmethod
171
+ def _three_letters(cls, v: str) -> str:
172
+ v = v.upper()
173
+ if len(v) != 3 or not v.isalpha():
174
+ raise ValueError(f"expected 3 letters, got {v!r}")
175
+ return v
176
+
177
+ @field_validator("flt")
178
+ @classmethod
179
+ def _flt_non_empty(cls, v: str) -> str:
180
+ if not v:
181
+ raise ValueError("flt must be non-empty")
182
+ return v
183
+
184
+ @field_validator("type")
185
+ @classmethod
186
+ def _type_upper(cls, v: str) -> str:
187
+ # 2026-05-14: simplified SV portal exports drop the J/B/K/X letter
188
+ # column; ``type`` is allowed to be empty in that case. Ops_class
189
+ # then falls back to date-based DAY/NIGHT classification (see
190
+ # _derive_ops_class). Older exports that DO publish the letter
191
+ # column still benefit from the explicit per-letter mapping.
192
+ return v.upper()
193
+
194
+ @field_validator("ac")
195
+ @classmethod
196
+ def _ac_upper_lenient(cls, v: str) -> str:
197
+ # Real-world SV exports often leave AC blank; tolerate it.
198
+ return v.upper() if v else ""
199
+
200
+
201
+ # ---------- Phase 2: roster extraction ----------
202
+
203
+
204
+ class Role(StrEnum):
205
+ """Plan §4.5/§4.6 — STAFF for staff_roster.xlsx; AM/ZC inferred for am_roster.xlsx."""
206
+
207
+ STAFF = "STAFF"
208
+ AM = "AM"
209
+ ZC = "ZC"
210
+
211
+
212
+ ShiftCode = Literal["M", "A", "N", "M1", "A1"]
213
+ """The five plain shift codes that populate AvailabilityRow.current_shift."""
214
+
215
+ SHIFT_CODES: frozenset[str] = frozenset({"M", "A", "N", "M1", "A1"})
216
+
217
+
218
+ # ============================================================================
219
+ # CENTRAL STATUS TAXONOMY (2026-05-27)
220
+ # ----------------------------------------------------------------------------
221
+ # Single source of truth for what each CrewStatus implies. Every reader,
222
+ # writer, dashboard panel, eligibility filter that needs to ask "what
223
+ # shift does this status mean?" / "does this status make them a P2F
224
+ # handler?" / "does this status imply ZC role?" should look up here —
225
+ # NOT carry its own copy of `if status in (...)`.
226
+ #
227
+ # WHEN ADDING A NEW STATUS:
228
+ # 1. Add the enum member to CrewStatus above.
229
+ # 2. Add entries to the maps below as appropriate.
230
+ # 3. That's it — readback, step2 emit, step3 admit, and the eligibility
231
+ # layer all pull from these maps, so they pick it up automatically.
232
+ # ============================================================================
233
+
234
+ # Status → shift it implies. None means the status doesn't carry a
235
+ # shift (off-day, leave, training, OTHER, BLANK).
236
+ STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode | None] = {
237
+ # Off / non-assignable
238
+ CrewStatus.UNDERSTUDY: None,
239
+ CrewStatus.DAY_OFF: None,
240
+ CrewStatus.PAID_LEAVE: None,
241
+ CrewStatus.CASUAL_LEAVE: None,
242
+ CrewStatus.CUSTOM_OFF: None,
243
+ CrewStatus.TRAINING: None,
244
+ CrewStatus.OTHER: None,
245
+ CrewStatus.BLANK: None,
246
+ # Plain shift codes
247
+ CrewStatus.SHIFT_M: "M",
248
+ CrewStatus.SHIFT_A: "A",
249
+ CrewStatus.SHIFT_N: "N",
250
+ CrewStatus.SHIFT_M1: "M1",
251
+ CrewStatus.SHIFT_A1: "A1",
252
+ # AM (on-shift but not flying — admin/training duty)
253
+ CrewStatus.AM_M_IGT: "M",
254
+ CrewStatus.AM_A_IGT: "A",
255
+ # ZC variants
256
+ CrewStatus.ZC_M: "M",
257
+ CrewStatus.ZC_A: "A",
258
+ CrewStatus.ZC_N: "N",
259
+ CrewStatus.ZC_M1: "M1",
260
+ CrewStatus.ZC_A1: "A1",
261
+ # P2F-handler variants (STAFF that nominate as P2F handler)
262
+ CrewStatus.P2F_M: "M",
263
+ CrewStatus.P2F_A: "A",
264
+ CrewStatus.P2F_N: "N",
265
+ # Combined ZC + P2F-handler variants
266
+ CrewStatus.ZC_P2F_M: "M",
267
+ CrewStatus.ZC_P2F_A: "A",
268
+ CrewStatus.ZC_P2F_N: "N",
269
+ CrewStatus.ZC_P2F_M1: "M1",
270
+ CrewStatus.ZC_P2F_A1: "A1",
271
+ }
272
+
273
+ # Statuses that explicitly imply a particular Role. Statuses NOT in
274
+ # this set inherit role from the crew row's sheet-origin (STAFF for
275
+ # IN_Staff, AM for IN_AM_Roster). Resolved via STATUS_TO_ROLE() below
276
+ # because Role isn't defined yet at module-load time when this dict
277
+ # is built — we close over the Role enum lazily inside the helper.
278
+ _STATUS_ROLE_TAGS: dict[CrewStatus, str] = {
279
+ # ZC variants imply ZC role for the day (overrides crew-row default).
280
+ CrewStatus.ZC_M: "ZC",
281
+ CrewStatus.ZC_A: "ZC",
282
+ CrewStatus.ZC_N: "ZC",
283
+ CrewStatus.ZC_M1: "ZC",
284
+ CrewStatus.ZC_A1: "ZC",
285
+ CrewStatus.ZC_P2F_M: "ZC",
286
+ CrewStatus.ZC_P2F_A: "ZC",
287
+ CrewStatus.ZC_P2F_N: "ZC",
288
+ CrewStatus.ZC_P2F_M1: "ZC",
289
+ CrewStatus.ZC_P2F_A1: "ZC",
290
+ # AM/IGT variants → AM
291
+ CrewStatus.AM_M_IGT: "AM",
292
+ CrewStatus.AM_A_IGT: "AM",
293
+ }
294
+
295
+ # Statuses that imply ZC role for the day (subset of _STATUS_ROLE_TAGS).
296
+ STATUS_IS_ZC: frozenset[CrewStatus] = frozenset(
297
+ s for s, tag in _STATUS_ROLE_TAGS.items() if tag == "ZC"
298
+ )
299
+
300
+ # Statuses that nominate the staff as a P2F handler. Maps to the
301
+ # SHIFT they handle P2F flights for (which equals STATUS_TO_SHIFT[s]
302
+ # for these — but kept as a separate map for clarity at call sites).
303
+ STATUS_TO_P2F_HANDLER_SHIFT: dict[CrewStatus, ShiftCode] = {
304
+ CrewStatus.P2F_M: "M",
305
+ CrewStatus.P2F_A: "A",
306
+ CrewStatus.P2F_N: "N",
307
+ CrewStatus.ZC_P2F_M: "M",
308
+ CrewStatus.ZC_P2F_A: "A",
309
+ CrewStatus.ZC_P2F_N: "N",
310
+ CrewStatus.ZC_P2F_M1: "M1",
311
+ CrewStatus.ZC_P2F_A1: "A1",
312
+ }
313
+
314
+ # Convenience set form for `status in STATUS_IS_P2F_HANDLER` checks.
315
+ STATUS_IS_P2F_HANDLER: frozenset[CrewStatus] = frozenset(
316
+ STATUS_TO_P2F_HANDLER_SHIFT.keys()
317
+ )
318
+
319
+
320
+ def role_implied_by_status(status: CrewStatus) -> "Role | None":
321
+ """Return the Role this status forces for the day, or None when
322
+ the status doesn't override (caller falls back to the crew row's
323
+ sheet-origin role). Lazy lookup of Role to dodge the forward
324
+ reference — Role is defined after this section."""
325
+ tag = _STATUS_ROLE_TAGS.get(status)
326
+ if tag is None:
327
+ return None
328
+ return Role(tag)
329
+
330
+
331
+ def is_p2f_handler_status(status: CrewStatus) -> bool:
332
+ """True iff the status nominates the staff as a P2F handler."""
333
+ return status in STATUS_IS_P2F_HANDLER
334
+
335
+
336
+ def p2f_handler_shift(status: CrewStatus) -> ShiftCode | None:
337
+ """Return the shift this status nominates a P2F handler for, or
338
+ None if the status isn't a P2F-handler status."""
339
+ return STATUS_TO_P2F_HANDLER_SHIFT.get(status)
340
+
341
+
342
+ def is_zc_status(status: CrewStatus) -> bool:
343
+ """True iff the status implies ZC role for the day."""
344
+ return status in STATUS_IS_ZC
345
+
346
+
347
+ def shift_of_status(status: CrewStatus) -> ShiftCode | None:
348
+ """Return the shift this status implies, or None for off-day /
349
+ leave / OTHER / BLANK."""
350
+ return STATUS_TO_SHIFT.get(status)
351
+
352
+
353
+ # Convenience: statuses that auto-grant a P2F license (because they
354
+ # nominate the staff as a P2F handler). step2 uses this to populate
355
+ # the license cell so downstream eligibility (F3) treats them as
356
+ # licensed even when the License column on the input roster was blank.
357
+ P2F_LICENSE_GRANTING_STATUSES: frozenset[CrewStatus] = STATUS_IS_P2F_HANDLER
358
+
359
+ # Shifts that nominate a plain (non-ZC) P2F handler. M1 / A1 do NOT
360
+ # nominate plain P2F handlers per the 2026-05-10 directive — only
361
+ # M / A / N. The combined ZC+P2F variant CAN appear on M1 / A1 via
362
+ # the ZC_P2F_M1 / ZC_P2F_A1 statuses; those route through
363
+ # STATUS_TO_P2F_HANDLER_SHIFT regardless of this tuple.
364
+ P2F_HANDLER_ELIGIBLE_SHIFTS: tuple[ShiftCode, ...] = ("M", "A", "N")
365
+
366
+
367
+ class OverrideType(StrEnum):
368
+ """Every legal `type` value an override row may carry.
369
+
370
+ Adding a new override type? Three steps:
371
+ 1. Add an enum member here.
372
+ 2. Implement the reader/applier (typically in io/readers.py or
373
+ staged_overrides.py).
374
+ 3. If the assigner UI should offer it in the drawer's Add row
375
+ dropdown, add to OVERRIDE_TYPES_FOR_UI (below). Frontend
376
+ pulls the list from /api/override_types at page load, so a
377
+ new entry shows up automatically.
378
+ """
379
+ # Handler nominations (engine reads at solve time)
380
+ P2F = "p2f"
381
+ # Per-staff constraints (read at solve time)
382
+ SICK = "sick"
383
+ CHANGE_ROLE = "change_role"
384
+ CHANGE_SHIFT = "change_shift"
385
+ MAX_FLIGHTS = "max_flights"
386
+ CUTOFF_TIME = "cutoff_time"
387
+ # Staged-form mutations (apply directly to data sheets at save time)
388
+ ADD_FLIGHT = "add_flight"
389
+ REMOVE_FLIGHT = "remove_flight"
390
+ ADD_STAFF = "add_staff"
391
+ REMOVE_STAFF = "remove_staff"
392
+ # Phase-R per-flight overrides written by the recommender
393
+ WAIVE_H10_PAIR = "waive_h10_pair"
394
+ RAISE_CAP = "raise_cap"
395
+ SKIP_P2F_BUFFER = "skip_p2f_buffer"
396
+ SKIP_INTL_REMOVAL = "skip_intl_removal"
397
+
398
+
399
+ # The subset that the drawer's Add row dropdown should expose — i.e.,
400
+ # user-facing override types. Phase-R recommender types are written
401
+ # via /api/recommender/apply, not the manual drawer flow, so they're
402
+ # excluded here.
403
+ OVERRIDE_TYPES_FOR_UI: tuple[OverrideType, ...] = (
404
+ OverrideType.P2F,
405
+ OverrideType.SICK,
406
+ OverrideType.CHANGE_ROLE,
407
+ OverrideType.CHANGE_SHIFT,
408
+ OverrideType.MAX_FLIGHTS,
409
+ OverrideType.CUTOFF_TIME,
410
+ )
411
+
412
+
413
+ # Subset that the staged-form helper accepts (add/remove flight/staff,
414
+ # the Roster Change pane's shift moves, and the Handler Assign pane's
415
+ # P2F nominations — all apply immediately, no drawer round-trip).
416
+ STAGED_FORM_OVERRIDE_TYPES: tuple[OverrideType, ...] = (
417
+ OverrideType.ADD_FLIGHT,
418
+ OverrideType.REMOVE_FLIGHT,
419
+ OverrideType.ADD_STAFF,
420
+ OverrideType.REMOVE_STAFF,
421
+ OverrideType.CHANGE_SHIFT,
422
+ OverrideType.P2F,
423
+ )
424
+
425
+
426
+ # 2026-05-27 (user direction — "no patchwork, centralize"): the drawer
427
+ # was rendering every override column on every row regardless of
428
+ # type, so a p2f row showed empty std / other_std / dep / arr cells
429
+ # (added by Phase-R writes) crowding out the cells that actually
430
+ # matter. This map declares, per override type, which override
431
+ # header columns are relevant — the frontend uses it to gray-out
432
+ # irrelevant cells (so the assigner only sees the dropdowns that
433
+ # matter for the row's type).
434
+ #
435
+ # Adding a new OverrideType? Add its column set here so the drawer
436
+ # auto-tailors. The frontend pulls this via /api/override_types.
437
+ #
438
+ # Names are LOWERCASED matches against the override column names.
439
+ # Headers not listed here for a given type are treated as irrelevant
440
+ # and rendered as a disabled "—" placeholder (the actual cell value
441
+ # is preserved on save — we only hide it visually).
442
+ OVERRIDE_TYPE_RELEVANT_COLS: dict[OverrideType, tuple[str, ...]] = {
443
+ # ----- User-facing (handler nominations + per-staff) -----
444
+ OverrideType.P2F: ("type", "employee", "shift"),
445
+ OverrideType.SICK: ("type", "employee"),
446
+ # change_role: the "shift" column carries the new ROLE
447
+ # (STAFF / ZC / AM), not a shift code — the frontend swaps the
448
+ # dropdown's option set when type=change_role.
449
+ OverrideType.CHANGE_ROLE: ("type", "employee", "shift"),
450
+ # change_shift: the "shift" column carries the new CrewStatus literal
451
+ # for an EXISTING staff member — a plain shift code (M/A/N/M1/A1) to
452
+ # move them, or an off/leave code (F day-off, P/L paid leave, C/L
453
+ # casual leave, C/OFF custom off) to pull them off today without
454
+ # dropping their roster row. Roster Change pane on the dashboard.
455
+ OverrideType.CHANGE_SHIFT: ("type", "employee", "shift"),
456
+ OverrideType.MAX_FLIGHTS: ("type", "employee", "limit"),
457
+ # cutoff_time: windowed (from_time + to_time). Either may be blank;
458
+ # the engine falls back to the staff's normal shift boundary.
459
+ OverrideType.CUTOFF_TIME: ("type", "employee", "from_time", "to_time"),
460
+ # ----- Staged forms (add/remove flight/staff) -----
461
+ OverrideType.ADD_FLIGHT: (
462
+ "type", "flt", "dep", "arr", "std",
463
+ "ops_class", "ac_type", "pax", "owner", "date",
464
+ ),
465
+ OverrideType.REMOVE_FLIGHT: ("type", "flt", "dep", "arr", "std", "date"),
466
+ OverrideType.ADD_STAFF: ("type", "employee", "shift", "role"),
467
+ OverrideType.REMOVE_STAFF: ("type", "employee"),
468
+ # ----- Phase-R recommender types -----
469
+ OverrideType.WAIVE_H10_PAIR: ("type", "employee", "flight", "std", "other_std"),
470
+ OverrideType.RAISE_CAP: ("type", "employee", "shift", "limit", "flight", "std"),
471
+ OverrideType.SKIP_P2F_BUFFER: ("type", "employee", "flight", "std"),
472
+ OverrideType.SKIP_INTL_REMOVAL: ("type", "flight", "std"),
473
+ }
474
+
475
+
476
+ def _validate_override_relevant_cols_completeness() -> None:
477
+ """Fail at import time if a new OverrideType is added but never
478
+ registered in OVERRIDE_TYPE_RELEVANT_COLS — same defensive pattern
479
+ as _validate_taxonomy_completeness for CrewStatus. Catches the
480
+ "added enum, forgot the map" regression before it ships."""
481
+ missing = [t for t in OverrideType if t not in OVERRIDE_TYPE_RELEVANT_COLS]
482
+ if missing:
483
+ raise RuntimeError(
484
+ "OverrideType relevant-cols map incomplete — schemas."
485
+ "OVERRIDE_TYPE_RELEVANT_COLS missing entries for: "
486
+ f"{[m.value for m in missing]}. Add each new type's "
487
+ "tuple of relevant header names so the drawer renders "
488
+ "the right dropdowns and gray-outs the rest."
489
+ )
490
+
491
+
492
+ _validate_override_relevant_cols_completeness()
493
+
494
+
495
+ def _validate_taxonomy_completeness() -> None:
496
+ """Fail loudly at import time if a new CrewStatus member was added
497
+ but never registered in STATUS_TO_SHIFT.
498
+
499
+ This catches the most common "I added a new enum value but forgot
500
+ to update the map" regression — the same class of bug that's
501
+ bitten the engine multiple times in 2026-05. Better to crash the
502
+ server at startup with a precise message than to silently
503
+ misroute statuses in production.
504
+ """
505
+ missing = [s for s in CrewStatus if s not in STATUS_TO_SHIFT]
506
+ if missing:
507
+ raise RuntimeError(
508
+ "CrewStatus taxonomy incomplete in schemas.STATUS_TO_SHIFT — "
509
+ f"missing entries: {[s.value for s in missing]}. "
510
+ "Every CrewStatus enum member must be registered in "
511
+ "STATUS_TO_SHIFT (use None for off-day / leave / OTHER). "
512
+ "Edit src/schemas.py to fix."
513
+ )
514
+
515
+
516
+ _validate_taxonomy_completeness()
517
+ # ============================================================================
518
+ # END central status taxonomy
519
+ # ============================================================================
520
+
521
+
522
+ class CrewRosterRow(BaseModel):
523
+ """Wide crew-roster row read from IN_Staff (plan §4.5).
524
+
525
+ Normalization happens at read time: ``status_by_date`` holds CrewStatus values
526
+ (with ``OFF→F`` aliases applied and unrecognized literals mapped to OTHER).
527
+ The original literal is preserved in ``raw_status_by_date`` per §1.5.1.
528
+
529
+ Step 4 additions:
530
+ - ``employee_id``: stable join key, auto-generated as ``STAFF_NNN`` by
531
+ the reader if the assigner leaves the IN_Staff cell blank. Disambiguates
532
+ same-name records (e.g., the two DEEPAK KUMARs across IN_Staff and
533
+ IN_AM_Roster).
534
+ - ``license``: P2F-licensure tag. None = no special license. Values
535
+ observed in real data: 'P2F', 'P2F+Corendon', 'Corendon'.
536
+ """
537
+
538
+ model_config = ConfigDict(frozen=True)
539
+ employee_id: str
540
+ name: str
541
+ role: Role
542
+ license: str | None = None
543
+ status_by_date: dict[date_t, CrewStatus]
544
+ raw_status_by_date: dict[date_t, str]
545
+
546
+
547
+ class AMRosterRow(BaseModel):
548
+ """Wide AM/ZC-roster row read from IN_AM_Roster (plan §4.6).
549
+
550
+ ``role`` is inferred at read time:
551
+ - any ``M/IGT`` or ``A/IGT`` anywhere in the month -> Role.AM
552
+ - else any ``*/ZC`` -> Role.ZC
553
+ - else -> Role.STAFF (defensive fallback)
554
+
555
+ Step 4 additions: ``employee_id`` (auto-generated as ``AM_NNN`` by the
556
+ reader if blank) and ``license`` (per-row P2F tag — ZCs may also be P2F-
557
+ licensed and act as the shift's P2F handler).
558
+ """
559
+
560
+ model_config = ConfigDict(frozen=True)
561
+ employee_id: str
562
+ name: str
563
+ role: Role
564
+ license: str | None = None
565
+ status_by_date: dict[date_t, CrewStatus]
566
+ raw_status_by_date: dict[date_t, str]
567
+
568
+
569
+ class AvailabilityRow(BaseModel):
570
+ """Step 2 output, long format (plan §4.9 + §1.5.1).
571
+
572
+ Step 4 additions: ``employee_id`` and ``license`` propagate verbatim from
573
+ CrewRosterRow / AMRosterRow so downstream solvers can join by ID and
574
+ filter by license without re-reading the source sheets.
575
+ """
576
+
577
+ model_config = ConfigDict(frozen=True)
578
+ employee_id: str
579
+ name: str
580
+ role: Role
581
+ date: date_t
582
+ status: CrewStatus
583
+ raw_status: str
584
+ assignable: bool
585
+ current_shift: ShiftCode | None = None
586
+ license: str | None = None
587
+ # Which roster the person comes from (STAFF for the staff roster, AM
588
+ # for the AM roster), before any /ZC promotion. Lets a ZC be taken
589
+ # back to what they were when the assigner removes them on the
590
+ # dashboard. None for synthetic rows (add_staff).
591
+ origin_role: Role | None = None
592
+
593
+
594
+ class Severity(StrEnum):
595
+ INFO = "INFO"
596
+ WARN = "WARN"
597
+ ERROR = "ERROR"
598
+
599
+
600
+ class WarningRow(BaseModel):
601
+ """Plan §4.12."""
602
+
603
+ model_config = ConfigDict(frozen=True)
604
+ severity: Severity
605
+ code: str = Field(pattern=r"^W\d{3}$")
606
+ name: str | None = None
607
+ date: date_t | None = None
608
+ message: str = Field(max_length=500)
609
+
610
+
611
+ # ---------- Phase 3: balance roster ----------
612
+
613
+
614
+ class OverrideMode(StrEnum):
615
+ """Plan §4.7 — disposition of a single Overrides row.
616
+
617
+ NO row is informational, ignored by solver
618
+ FORCE hard constraint: solver must assign to_shift on (employee, date);
619
+ infeasibility triggers W001 drop-and-retry
620
+ VOLUNTEER soft hint: employee has nominated themself for from_shift -> to_shift.
621
+ Solver MAY take the swap "for free" (no swap-count penalty) if that
622
+ helps balance, but never forces it. If the solver leaves the crew on
623
+ from_shift, the volunteered swap is silently unused.
624
+ """
625
+
626
+ NO = "NO"
627
+ FORCE = "FORCE"
628
+ VOLUNTEER = "VOLUNTEER"
629
+
630
+
631
+ class OverrideRow(BaseModel):
632
+ """Plan §4.7 — one row of the Overrides sheet."""
633
+
634
+ model_config = ConfigDict(frozen=True)
635
+ employee: str
636
+ date: date_t
637
+ mode: OverrideMode
638
+ from_shift: ShiftCode | None = None
639
+ to_shift: ShiftCode | None = None
640
+
641
+ @model_validator(mode="after")
642
+ def _check_active_modes_require_shifts(self) -> Self:
643
+ if self.mode != OverrideMode.NO and (self.from_shift is None or self.to_shift is None):
644
+ raise ValueError(f"mode={self.mode} requires both from_shift and to_shift")
645
+ return self
646
+
647
+
648
+ # RequiredStaffingRow removed 2026-05-21 — the IN_Required sheet and
649
+ # its reader are gone, no callers reference this schema anymore.
650
+
651
+
652
+ class Origin(StrEnum):
653
+ ROSTER = "ROSTER"
654
+ OVERRIDE = "OVERRIDE"
655
+ SOLVER_SWAP = "SOLVER_SWAP"
656
+
657
+
658
+ class BalancedRosterRow(BaseModel):
659
+ """Plan §4.10 — Step 3 output, long format.
660
+
661
+ Step 4 additions: ``employee_id`` (the join key Step 4 uses everywhere) and
662
+ ``license`` (passed through from AvailabilityRow).
663
+ """
664
+
665
+ model_config = ConfigDict(frozen=True)
666
+ employee_id: str
667
+ name: str
668
+ role: Role
669
+ date: date_t
670
+ assigned_shift: ShiftCode | None = None
671
+ origin: Origin
672
+ was_swapped: bool
673
+ license: str | None = None
674
+
675
+
676
+ class SwapReason(StrEnum):
677
+ RESPECT_OVERRIDE = "RESPECT_OVERRIDE"
678
+ BALANCE_DEFICIT = "BALANCE_DEFICIT"
679
+ BALANCE_SURPLUS = "BALANCE_SURPLUS"
680
+ # Crew moved to OFF because keeping their current_shift would have violated the
681
+ # 24-hr rule against either D-1 history or the other target date's natural
682
+ # shift. Distinguishes a forced-off from a true surplus removal.
683
+ RULE_24H_CONFLICT = "RULE_24H_CONFLICT"
684
+
685
+
686
+ class SwapLogRow(BaseModel):
687
+ """Plan §4.11."""
688
+
689
+ model_config = ConfigDict(frozen=True)
690
+ name: str
691
+ date: date_t
692
+ from_shift: str | None = None
693
+ to_shift: str | None = None
694
+ reason: SwapReason
695
+ timestamp_utc: datetime_t
696
+
697
+
698
+ # Plan §4.2 shift-start times (24-hr rule uses these as IntervalVar starts).
699
+ SHIFT_START: dict[str, time_t] = {
700
+ "M": time_t(4, 0),
701
+ "A": time_t(12, 30),
702
+ "N": time_t(20, 30),
703
+ "M1": time_t(6, 0),
704
+ "A1": time_t(14, 30),
705
+ }
706
+
707
+
708
+ # ====================================================================
709
+ # Phase 4: flight allocation
710
+ #
711
+ # These models are consumed only by Step 4. Earlier-phase code (Steps 1-3)
712
+ # does not import any of them. Keeping them in schemas.py rather than a
713
+ # step4-specific module so the type vocabulary stays in one place.
714
+ # ====================================================================
715
+
716
+
717
+ class OpsBoundary(StrEnum):
718
+ """A shift transition. Pairs are keyed by boundary; ZC pairs use the same
719
+ boundary identifiers."""
720
+
721
+ N_TO_M = "N->M"
722
+ M_TO_M1 = "M->M1"
723
+ M_TO_A = "M->A"
724
+ M1_TO_A1 = "M1->A1"
725
+ A_TO_N = "A->N"
726
+ A_TO_A1 = "A->A1" # handover-only routing for A surplus
727
+ A1_TO_N = "A1->N"
728
+
729
+
730
+ class PairRole(StrEnum):
731
+ """A pair's responsibility at its boundary.
732
+
733
+ PRIMARY — both pre-planning (per H6 counts) AND tail handover
734
+ (per H7 windows).
735
+ HANDOVER_ONLY — tail handover only; never carries pre-planning.
736
+ Used for A↔A1 routing when |A|>|N| and for the
737
+ secondary A↔N pairs when surplus A staff still remain
738
+ after the A1 handover route is exhausted.
739
+ """
740
+
741
+ PRIMARY = "primary"
742
+ HANDOVER_ONLY = "handover_only"
743
+
744
+
745
+ class AllocationSheet(StrEnum):
746
+ """Routing target for an AllocationRow — which allocation lane
747
+ the row writes to."""
748
+
749
+ DAY_OPS = "DayOps"
750
+ NIGHT_OPS = "NightOps"
751
+ P2F = "P2F"
752
+
753
+
754
+ class FlightInput(BaseModel):
755
+ """A single flight for Step 4's solver input. Mirrors CleanFlightRow but
756
+ adds Step-4-specific tags computed during preprocessing."""
757
+
758
+ model_config = ConfigDict(frozen=True)
759
+ date: date_t
760
+ flt: str
761
+ type: str
762
+ ac: str
763
+ dep: str
764
+ arr: str
765
+ std: time_t
766
+ load: int = Field(ge=0, le=600)
767
+ ops_class: OpsClass
768
+ is_international: bool = False
769
+ is_preplan_deferred: bool = False
770
+ """Phase 3 / Change 7: D+1 flights with STD ∈ [05:05, 05:30] enter
771
+ the pool but are split out by Step 3 before the solver runs; re-
772
+ appended to the output sheet with warning="PREPLAN_DEFERRED" and
773
+ empty staff column. Solver never sees them."""
774
+ is_awkward_window: bool = False # H15 routing
775
+ awkward_eligible_shifts: tuple[ShiftCode, ...] = ()
776
+ """If is_awkward_window=True, the only shifts a flight at this STD may be
777
+ assigned to. Empty tuple when not in an awkward window."""
778
+
779
+ @property
780
+ def unique_id(self) -> str:
781
+ """A stable, unique identifier for THIS leg of the rotation.
782
+
783
+ Per user direction 2026-05-12: multi-leg flights (same FLT number,
784
+ different STD/dep/arr legs) must be treated as SEPARATE flights for
785
+ allocation purposes. Earlier code keyed eligibility / x-vars by
786
+ ``flt`` alone, which made later legs overwrite earlier ones and
787
+ caused phantom assignments + H10 spacing violations. Keying by
788
+ (flt, dep, arr, std, date) makes each leg an independent flight
789
+ in the CP-SAT model — exactly what the operations team expects.
790
+
791
+ 2026-05-26 fix: date appended. SV portal exports two days at a
792
+ time; the same daily flight (e.g. 64 JED-DEL 05:30) appears on
793
+ both D and D+1 with identical FLT|DEP|ARR|STD. Without date in
794
+ the key, the D+1 preplan-deferred filter collided with D-day and
795
+ silently dropped 33 morning flights per run.
796
+ """
797
+ return f"{self.flt}|{self.dep}|{self.arr}|{self.std.isoformat(timespec='minutes')}|{self.date.isoformat()}"
798
+
799
+
800
+ class StaffMember(BaseModel):
801
+ """A single staff record assembled by Step 4 from OUT_Balanced_Roster
802
+ (STAFF) or IN_AM_Roster (ZC). AMs are not represented as StaffMember at
803
+ all — they are filtered out at preprocessing time.
804
+ """
805
+
806
+ model_config = ConfigDict(frozen=True)
807
+ employee_id: str
808
+ name: str
809
+ role: Role
810
+ date: date_t
811
+ shift_today: ShiftCode | None = None
812
+ """None means the staff is OFF today (not assignable). Assignable staff
813
+ have one of M/A/N/M1/A1."""
814
+ license: str | None = None
815
+ is_p2f_licensed: bool = False
816
+ """Convenience flag derived from license string. True when license
817
+ contains 'P2F'."""
818
+ is_newbie: bool = False
819
+ """Per override row; affects H12 international exclusion."""
820
+ max_flights_cap: int | None = None
821
+ """Per-staff override max; lower-bound clamp on the
822
+ H16 hard cap. None means use the H16 default for the staff's shift+role."""
823
+ std_cutoff: time_t | None = None
824
+ """Per-staff override STD cutoff (UPPER bound);
825
+ flights with STD past this are excluded for this staff. None means
826
+ no upper bound — staff's normal shift end applies."""
827
+ std_start: time_t | None = None
828
+ """2026-05-27: per-staff override STD START (LOWER bound) from
829
+ an override; flights with STD before this are excluded for this
830
+ staff. None means no lower bound — staff's normal shift start
831
+ applies. Together with std_cutoff, this carves a custom working
832
+ window inside the staff's normal shift. Example: an A-staff with
833
+ std_start=14:00 + std_cutoff=17:00 only takes flights in that
834
+ 3-hour band; one with just std_cutoff=17:00 comes in at the
835
+ regular A start (12:30) but leaves early at 17:00."""
836
+
837
+ @model_validator(mode="after")
838
+ def _coherent_p2f_flag(self) -> Self:
839
+ if self.is_p2f_licensed and not (self.license and "P2F" in self.license.upper()):
840
+ raise ValueError(
841
+ "is_p2f_licensed=True requires 'P2F' in license; "
842
+ f"got license={self.license!r}"
843
+ )
844
+ return self
845
+
846
+
847
+ class Pair(BaseModel):
848
+ """One pair at one boundary. The 'prev' side is the earlier shift (e.g.,
849
+ M for the M→A boundary); 'next' is the later shift (A). Either side may
850
+ be None for self-planning surplus, but not both — that wouldn't be a pair.
851
+
852
+ ``preplan_count`` carries the per-pair count of flights this pair's
853
+ prev-side plans for this pair's next-side. Default H6 values:
854
+ N→M=1, M→M1=1, M→A=2, M1→A1=2, A→N=3, A1→N=0, A→A1=0.
855
+ When N has 2 A-partners (split-1+2), the primary pair's count drops
856
+ to 2 and the secondary pair carries 1. Pairs with preplan_count=0
857
+ are handover-only.
858
+ """
859
+
860
+ model_config = ConfigDict(frozen=True)
861
+ boundary: OpsBoundary
862
+ pair_role: PairRole
863
+ preplan_count: int = Field(default=0, ge=0, le=5)
864
+ prev_employee_id: str | None
865
+ prev_name: str | None
866
+ prev_shift: ShiftCode | None
867
+ next_employee_id: str | None
868
+ next_name: str | None
869
+ next_shift: ShiftCode | None
870
+
871
+ @model_validator(mode="after")
872
+ def _at_least_one_side_present(self) -> Self:
873
+ if self.prev_employee_id is None and self.next_employee_id is None:
874
+ raise ValueError("a pair must have at least one side populated")
875
+ return self
876
+
877
+
878
+ class P2FNomination(BaseModel):
879
+ """Per (date, shift) P2F handler nomination from an override row."""
880
+
881
+ model_config = ConfigDict(frozen=True)
882
+ date: date_t
883
+ shift: ShiftCode
884
+ employee_id: str
885
+
886
+
887
+ class PerStaffOverride(BaseModel):
888
+ """Per-staff Step-4 override row (separate from Step 3's
889
+ shift-swap overrides)."""
890
+
891
+ model_config = ConfigDict(frozen=True)
892
+ employee_id: str
893
+ date: date_t
894
+ max_flights: int | None = Field(default=None, ge=0, le=30)
895
+ std_cutoff: time_t | None = None
896
+ std_start: time_t | None = None
897
+ is_newbie: bool = False
898
+
899
+
900
+ # 2026-05-24: four new override types for ad-hoc additions /
901
+ # removals of flights or staff on the planning day. Each row applies
902
+ # to D only, on top of whatever the SV portal + roster exports gave.
903
+ # The "stage-and-confirm" UX writes these into the workbook
904
+ # immediately on Save so the dashboard reflects the projected state
905
+ # (extra flight in the right ops-class card; extra body in the
906
+ # roster-by-shift block) BEFORE the assigner clicks Allocate.
907
+
908
+ class AddFlightOverride(BaseModel):
909
+ """``type=add_flight`` — inject a new flight into the cleaned <class> list
910
+ so it enters the allocation pool.
911
+
912
+ Override columns used: flt, shift (used as STD HH:MM), dep, arr,
913
+ ops_class. The assigner provides every field the cleaning step
914
+ would have parsed from the SV portal — the engine treats this as
915
+ if it had appeared in IN_SVportal."""
916
+
917
+ model_config = ConfigDict(frozen=True)
918
+ date: date_t
919
+ flt: str
920
+ dep: str = Field(min_length=3, max_length=4)
921
+ arr: str = Field(min_length=3, max_length=4)
922
+ std: time_t
923
+ ops_class: OpsClass
924
+
925
+
926
+ class RemoveFlightOverride(BaseModel):
927
+ """``type=remove_flight`` — pull a flight out of the allocation pool.
928
+
929
+ Matches by (flt, std) tuple — same disambiguator used elsewhere for
930
+ multi-leg rotations. Removed flights are dropped from
931
+ the cleaned <class> list at apply time."""
932
+
933
+ model_config = ConfigDict(frozen=True)
934
+ date: date_t
935
+ flt: str
936
+ std: time_t
937
+
938
+
939
+ class AddStaffOverride(BaseModel):
940
+ """``type=add_staff`` — inject a new (synthetic) staff into
941
+ the availability list so they participate in this day's allocation.
942
+
943
+ Override columns used: employee (name), shift (M/A/N/M1/A1 or
944
+ M/ZC / A/ZC / ...), role (STAFF / AM / ZC). Engine assigns a
945
+ synthetic employee_id of ``ADD_<n>`` so the schema's unique-ID
946
+ rule isn't violated."""
947
+
948
+ model_config = ConfigDict(frozen=True)
949
+ date: date_t
950
+ name: str = Field(min_length=1, max_length=80)
951
+ shift: ShiftCode
952
+ role: Role
953
+
954
+
955
+ class RemoveStaffOverride(BaseModel):
956
+ """``type=remove_staff`` — pull a staff out of today's allocation.
957
+
958
+ Semantically identical to ``type=sick`` (which already exists);
959
+ kept as a separate type for UI clarity. Both flip the staff's
960
+ ``assignable`` flag to False before Step 3 runs."""
961
+
962
+ model_config = ConfigDict(frozen=True)
963
+ date: date_t
964
+ name: str = Field(min_length=1, max_length=80)
965
+
966
+
967
+ # ====================================================================
968
+ # Phase R — per-flight relaxation overrides (2026-05-15)
969
+ #
970
+ # These four override types let the assigner explicitly waive a hard
971
+ # constraint for ONE specific flight (or one specific (flight, staff)
972
+ # pair). Each row is read from the override list on D and the engine's
973
+ # eligibility / post-pass code checks the override set before applying
974
+ # the corresponding constraint.
975
+ #
976
+ # All four take effect only on the named date. Wiped by the standard
977
+ # reset_overrides flow.
978
+ # ====================================================================
979
+
980
+
981
+ class WaiveH10Pair(BaseModel):
982
+ """Waive the H10 15-min spacing rule between ONE flight and ONE
983
+ staff's other flight at a given STD.
984
+
985
+ Override columns: type=waive_h10_pair, flight, std, employee,
986
+ other_std. ``flight`` is the bare FLT number; ``std`` disambiguates
987
+ multi-leg rotations.
988
+
989
+ Example. flt 339 (STD 06:10) is unallocated because KARRA PRANAY
990
+ REDDY already has flt 6305 at 06:15. A WaiveH10Pair row of:
991
+ date=2026-05-06, flight=339, std=06:10,
992
+ employee=KARRA PRANAY REDDY, other_std=06:15
993
+ tells the solver: when building the H10 spacing constraint, EXCLUDE
994
+ the pair (flt 339 @ 06:10, flt @ 06:15) from the no-overlap set
995
+ for this staff.
996
+
997
+ Granular by design — does NOT relax H10 for the staff's other
998
+ flights; only this exact pair gets the waiver.
999
+ """
1000
+
1001
+ model_config = ConfigDict(frozen=True)
1002
+ date: date_t
1003
+ flight: str
1004
+ std: time_t
1005
+ employee_id: str
1006
+ other_std: time_t
1007
+
1008
+
1009
+ class RaiseCapForFlight(BaseModel):
1010
+ """Per-staff one-shot cap raise to absorb ONE specific flight.
1011
+
1012
+ Override columns: type=raise_cap, flight, std, employee.
1013
+
1014
+ Example. ARSHI KHAN is at 24/24 cap. A RaiseCapForFlight row of:
1015
+ date=2026-05-06, flight=1185, std=06:10, employee=ARSHI KHAN
1016
+ raises ARSHI's H16 cap by 1 (to 25) **for this run only**. The cap
1017
+ reverts after the engine completes — operationally we're not
1018
+ permanently changing the cap; we're just letting one extra flight
1019
+ land on this person.
1020
+ """
1021
+
1022
+ model_config = ConfigDict(frozen=True)
1023
+ date: date_t
1024
+ flight: str
1025
+ std: time_t
1026
+ employee_id: str
1027
+
1028
+
1029
+ class SkipP2FBuffer(BaseModel):
1030
+ """Waive a P2F handler's buffer (STD-2hrs to STD+1hr) to allow ONE
1031
+ specific normal flight inside the window.
1032
+
1033
+ Override columns: type=skip_p2f_buffer, flight, std, employee.
1034
+
1035
+ Example. GAYATHRI is P2F-M handler. flt 6142 (08:35) falls in her
1036
+ P2F buffer (engine F6 hard-blocks). A SkipP2FBuffer row of:
1037
+ date=2026-05-06, flight=6142, std=08:35, employee=GAYATHRI
1038
+ tells eligibility F6 to skip the buffer check for this exact
1039
+ (flight, staff) pair.
1040
+ """
1041
+
1042
+ model_config = ConfigDict(frozen=True)
1043
+ date: date_t
1044
+ flight: str
1045
+ std: time_t
1046
+ employee_id: str
1047
+
1048
+
1049
+ class SkipINTLRemoval(BaseModel):
1050
+ """Tell the INTL post-pass NOT to remove a preceding flight when
1051
+ this specific INTL DEP flight gets allocated.
1052
+
1053
+ Override columns: type=skip_intl_removal, flight, std.
1054
+
1055
+ Example. flt 1064 lands on MANISH KUMAR. By default the post-pass
1056
+ removes MANISH's chronologically previous normal flight and
1057
+ redistributes. A SkipINTLRemoval row of:
1058
+ date=2026-05-06, flight=1064, std=10:25
1059
+ keeps both flights on MANISH. Used when the assigner explicitly
1060
+ decides the buffer isn't needed for this INTL.
1061
+ """
1062
+
1063
+ model_config = ConfigDict(frozen=True)
1064
+ date: date_t
1065
+ flight: str
1066
+ std: time_t
1067
+
1068
+
1069
+ class AllocationRow(BaseModel):
1070
+ """One allocation row. The display name and employee_id are
1071
+ both stored: names are for the human reader, employee_ids are for any
1072
+ downstream rejoin (e.g., the workload summary).
1073
+
1074
+ Per user direction 2026-05-11: ``staff_*`` may be empty for
1075
+ "pre-plan only" flights (05:05–05:30 D+1 STDs whose actual fly-er
1076
+ is on D+1's M shift — but D+1's roster isn't loaded yet). Those
1077
+ rows carry a ``planned_by_*`` (the N-shift planner) and no staff.
1078
+ """
1079
+
1080
+ model_config = ConfigDict(frozen=True)
1081
+ date: date_t
1082
+ flt: str
1083
+ dep: str
1084
+ arr: str
1085
+ std: time_t
1086
+ pax: int = Field(ge=0, le=600)
1087
+ staff_employee_id: str = ""
1088
+ staff_name: str = ""
1089
+ planned_by_employee_id: str | None = None
1090
+ planned_by_name: str | None = None
1091
+ relieved_by_employee_id: str | None = None
1092
+ relieved_by_name: str | None = None
1093
+ warning: str | None = None
1094
+ sheet_target: AllocationSheet
1095
+ # Color hint carried through from CleanFlightRow → solver → output.
1096
+ # Workbook writer applies cell fills; web readback returns the
1097
+ # flag so the browser tints rows yellow. Phase 3 (2026-05-14) made
1098
+ # is_international DEP-only; the pink-highlight machinery
1099
+ # (routing_via_excluded_hub) was deleted.
1100
+ is_international: bool = False
1101
+
1102
+
1103
+ class WorkloadSummaryRow(BaseModel):
1104
+ """One row of OUT_Workload_Summary."""
1105
+
1106
+ model_config = ConfigDict(frozen=True)
1107
+ employee_id: str
1108
+ name: str
1109
+ shift: ShiftCode | None
1110
+ role: Role
1111
+ target_preferred: int
1112
+ target_acceptable_max: int
1113
+ hard_cap: int
1114
+ actual: int
1115
+ deviation_below_preferred: int = Field(ge=0)
1116
+ deviation_above_preferred: int = Field(ge=0)
1117
+ violations: tuple[str, ...] = ()
1118
+
1119
+
1120
+ class AllocationResult(BaseModel):
1121
+ """Top-level Step 4 output bundle returned by the orchestrator."""
1122
+
1123
+ model_config = ConfigDict(frozen=True)
1124
+ rows: tuple[AllocationRow, ...]
1125
+ pairs: tuple[Pair, ...]
1126
+ summary: tuple[WorkloadSummaryRow, ...]
1127
+ warnings: tuple[WarningRow, ...]
1128
+ wall_clock_seconds: float = Field(ge=0)
1129
+ solver_status: str
1130
+ """OPTIMAL | FEASIBLE | INFEASIBLE | TIMEOUT — short-form status from
1131
+ the solver run."""
1132
+
1133
+
1134
+ # ---------- Agent (Phase 1: write-action chassis, 2026-05-13) ----------
1135
+ #
1136
+ # These models are consumed only by ``src/agent/`` and the
1137
+ # two new audit sheets in ``OUT_Agent_Trace`` / ``OUT_Agent_Queue``.
1138
+ # Nothing in the existing pipeline (allocation, eligibility, post-passes,
1139
+ # readers, writers, pairings, web UI) imports from this block — it's
1140
+ # isolated by design so the chassis can land without engine impact.
1141
+
1142
+
1143
+ class WriteAction(StrEnum):
1144
+ """The kind of action a single audit-trace row describes.
1145
+
1146
+ READ — agent read state via a read-only tool (kept here
1147
+ so the same trace sheet works for Phase R too).
1148
+ WRITE_PREVIEW — ApprovalGate.preview() built; nothing committed.
1149
+ APPROVAL_RECORDED — human clicked Approve; token issued.
1150
+ APPROVAL_REJECTED — human clicked Reject; gate dead.
1151
+ WRITE_APPLY — agent applied the previously-approved change.
1152
+ ROLLBACK — WriteTransaction restored from backup after a
1153
+ mid-apply failure.
1154
+ """
1155
+
1156
+ READ = "READ"
1157
+ WRITE_PREVIEW = "WRITE_PREVIEW"
1158
+ APPROVAL_RECORDED = "APPROVAL_RECORDED"
1159
+ APPROVAL_REJECTED = "APPROVAL_REJECTED"
1160
+ WRITE_APPLY = "WRITE_APPLY"
1161
+ ROLLBACK = "ROLLBACK"
1162
+
1163
+
1164
+ class ApprovalStatus(StrEnum):
1165
+ """Lifecycle state of a single ApprovalGate, persisted in SQLite.
1166
+
1167
+ PENDING — preview built, awaiting human decision.
1168
+ APPROVED — token issued, not yet applied or expired.
1169
+ APPLIED — token consumed by a successful apply().
1170
+ REJECTED — human said no.
1171
+ EXPIRED — TTL elapsed before apply() was called.
1172
+ """
1173
+
1174
+ PENDING = "PENDING"
1175
+ APPROVED = "APPROVED"
1176
+ APPLIED = "APPLIED"
1177
+ REJECTED = "REJECTED"
1178
+ EXPIRED = "EXPIRED"
1179
+
1180
+
1181
+ class AgentTraceRow(BaseModel):
1182
+ """One row of the OUT_Agent_Trace sheet — the forensic log of every
1183
+ agent action.
1184
+
1185
+ The sheet is append-only by design: ``reset_outputs`` excludes it so
1186
+ audit history survives Plan / Allocate runs. Manual purge via a
1187
+ separate "clear agent history" tool when the assigner explicitly
1188
+ wants it gone.
1189
+
1190
+ Hash columns let an auditor confirm the pre/post state without
1191
+ storing entire workbook snapshots inline.
1192
+ """
1193
+
1194
+ model_config = ConfigDict(frozen=True)
1195
+ timestamp: datetime_t
1196
+ txn_id: str
1197
+ """ULID-style identifier; ties together preview + approval + apply +
1198
+ (optional) rollback rows for the same logical action."""
1199
+ action_type: WriteAction
1200
+ tool_name: str
1201
+ """The agent tool that emitted this row (e.g. add_intl_airport,
1202
+ update_roster_cell, mark_unavailable)."""
1203
+ target: str
1204
+ """Plain-English description of what was touched
1205
+ (e.g. "configs/config.yml#international_airports", "override row 7")."""
1206
+ pre_state_hash: str = ""
1207
+ """SHA-256 hex digest of the affected file/sheet before the change.
1208
+ Empty string for READ rows."""
1209
+ post_state_hash: str = ""
1210
+ """SHA-256 hex digest of the affected file/sheet after the change.
1211
+ Empty string for READ and PREVIEW rows (no change yet)."""
1212
+ approval_user: str = ""
1213
+ """Empty for READ / PREVIEW / ROLLBACK; the human who clicked
1214
+ Approve/Reject for the approval-bound rows."""
1215
+ approval_timestamp: datetime_t | None = None
1216
+ note: str = ""
1217
+ """Free-form: rejection reason, rollback cause, plan-summary blurb, etc."""
1218
+
1219
+
1220
+ class AgentQueueRow(BaseModel):
1221
+ """One row of the OUT_Agent_Queue sheet — items the agent flagged for
1222
+ human triage via the ``flag_for_human`` tool (CONTEXT.md §2.7).
1223
+
1224
+ Kept separate from OUT_Agent_Trace so the assigner has a focused
1225
+ work-list view independent of the firehose of read/write events.
1226
+ """
1227
+
1228
+ model_config = ConfigDict(frozen=True)
1229
+ timestamp: datetime_t
1230
+ queue_id: str
1231
+ """Stable identifier so the UI can mark rows resolved/dismissed
1232
+ without re-keying by row index."""
1233
+ question: str
1234
+ """The user's original prompt that triggered the escalation."""
1235
+ why_stuck: str
1236
+ """The agent's own statement of why it couldn't resolve confidently."""
1237
+ partial_evidence: str
1238
+ """JSON-serialized snapshot of what the agent had figured out so far
1239
+ (citations, partial findings). Read-only context for the human."""
1240
+ confidence: Literal["HIGH", "MEDIUM", "LOW"] = "LOW"
1241
+ status: Literal["OPEN", "RESOLVED", "DISMISSED"] = "OPEN"
1242
+ resolved_by: str = ""
1243
+ resolved_at: datetime_t | None = None
1244
+ resolution_note: str = ""