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,286 @@
|
|
|
1
|
+
"""Step 1 — clean SV portal flights (plan §10 Phase 1).
|
|
2
|
+
|
|
3
|
+
Reads the uploaded flight schedule and groups the surviving rows by
|
|
4
|
+
OpsClass into ``state.cleaned``.
|
|
5
|
+
|
|
6
|
+
Drop filter (Phase 3 INTL overhaul — 2026-05-14, amended 2026-05-19):
|
|
7
|
+
1. drop rows where date is None or dep_time is None
|
|
8
|
+
2. drop rows where date not in {D, D+1}
|
|
9
|
+
3. drop rows where date == D and std < 05:05 (asymmetric — see plan §1.5)
|
|
10
|
+
4. drop rows where date == D+1 and std > 05:30 (Change 7: widened from
|
|
11
|
+
05:05 to 05:30 — flights at 05:05-05:30 D+1 are KEPT with the
|
|
12
|
+
``is_preplan_deferred=True`` flag, re-appended by Step 3 as
|
|
13
|
+
``warning="PREPLAN_DEFERRED"``)
|
|
14
|
+
5. drop rows where dep is None or arr is None
|
|
15
|
+
6. drop rows where ops_class cannot be derived
|
|
16
|
+
7. drop rows where the cleaned-row schema rejects the values
|
|
17
|
+
|
|
18
|
+
2026-05-19 amendment (Gulf routing):
|
|
19
|
+
* Gulf-3 DEP flights (AUH / DOH / DXB) and QR-owner flights are no
|
|
20
|
+
longer DROPPED. They route to OpsClass.GULF and land on the
|
|
21
|
+
dedicated GULF class for reference. Step 3 does not
|
|
22
|
+
read that sheet, so these flights are never allocated to staff —
|
|
23
|
+
same operational outcome as the legacy drop, but with audit-trail
|
|
24
|
+
visibility.
|
|
25
|
+
* ``routing_drop_dep_codes`` is now defunct; the new
|
|
26
|
+
``ops_class_gulf_dep_codes`` + ``ops_class_gulf_owner_codes``
|
|
27
|
+
drive the routing.
|
|
28
|
+
|
|
29
|
+
Phase 3 amendments (2026-05-14):
|
|
30
|
+
* Change 9: ``is_international = (dep ∈ intl_codes)`` — DEP-side only
|
|
31
|
+
(was DEP or ARR). Pink-highlight machinery removed.
|
|
32
|
+
* Change 7: D+1 STD 05:05-05:30 enters pool with ``is_preplan_deferred``.
|
|
33
|
+
|
|
34
|
+
Pax (Booked Pax) is read for reference only as of 2026-05-10 — it does
|
|
35
|
+
NOT drop rows or influence allocation. Unparseable pax strings (CV,
|
|
36
|
+
empty, etc.) are kept with load=0.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
from collections.abc import Iterable, Mapping
|
|
43
|
+
from datetime import date as date_type
|
|
44
|
+
from datetime import time, timedelta
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from .config import Config, load_config
|
|
48
|
+
from .io.readers import read_sv_portal, workbook_from_bytes
|
|
49
|
+
from .schemas import CleanFlightRow, OpsClass, RawFlightRow
|
|
50
|
+
from .state import AppState
|
|
51
|
+
|
|
52
|
+
CUTOFF: time = time(5, 5)
|
|
53
|
+
# Phase 3 / Change 7: D+1 flights with STD in [CUTOFF, DEFERRED_UPPER]
|
|
54
|
+
# are KEPT (flagged is_preplan_deferred); D+1 > DEFERRED_UPPER is dropped.
|
|
55
|
+
DEFERRED_UPPER: time = time(5, 30)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe_load(pax_raw: str | None) -> int:
|
|
59
|
+
"""Best-effort parse of a Booked Pax string to an int load. Pax is
|
|
60
|
+
reference-only since 2026-05-10 — unparseable values default to 0
|
|
61
|
+
rather than dropping the flight."""
|
|
62
|
+
if not pax_raw or not isinstance(pax_raw, str):
|
|
63
|
+
return 0
|
|
64
|
+
tail = pax_raw.rsplit("/", 1)[-1].strip()
|
|
65
|
+
try:
|
|
66
|
+
n = int(tail)
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
return 0
|
|
69
|
+
return max(0, min(600, n))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _derive_ops_class(
|
|
73
|
+
aircraft_type: str | None,
|
|
74
|
+
owner: str | None,
|
|
75
|
+
dep: str,
|
|
76
|
+
arr: str,
|
|
77
|
+
row_date: date_type,
|
|
78
|
+
d_day: date_type,
|
|
79
|
+
ops_map: Mapping[str, str],
|
|
80
|
+
gulf_dep_codes: frozenset[str],
|
|
81
|
+
gulf_owner_codes: frozenset[str],
|
|
82
|
+
) -> OpsClass | None:
|
|
83
|
+
"""Routing precedence (highest to lowest):
|
|
84
|
+
|
|
85
|
+
1. GULF — DEP in ``gulf_dep_codes`` (AUH / DOH / DXB) OR owner /
|
|
86
|
+
TYPE letter in ``gulf_owner_codes`` (e.g. ``QR``). Extract-only:
|
|
87
|
+
the cleaned row lands in the GULF class but Step 3 ignores
|
|
88
|
+
that sheet, so these flights never enter the solver. Per user
|
|
89
|
+
direction 2026-05-19.
|
|
90
|
+
2. TYPE = X → TEST or FERRY based on routing (user direction
|
|
91
|
+
2026-05-11):
|
|
92
|
+
* dep == arr (same-airport loop) → TEST
|
|
93
|
+
* dep != arr (positioning leg) → FERRY
|
|
94
|
+
3. Flat TYPE-letter mapping — H → P2F, B → CHARTER, K/P/T/G/W → FERRY.
|
|
95
|
+
4. Date-based fallback — D → DAY, D+1 → NIGHT (J / unknown types
|
|
96
|
+
routing to passenger ops).
|
|
97
|
+
"""
|
|
98
|
+
owner_upper = (owner or "").strip().upper()
|
|
99
|
+
t_upper = (aircraft_type or "").strip().upper()
|
|
100
|
+
# 2026-05-19: GULF detection wins outright. DEP-side OR owner-side
|
|
101
|
+
# match routes the flight to the extract-only GULF class.
|
|
102
|
+
if dep in gulf_dep_codes:
|
|
103
|
+
return OpsClass.GULF
|
|
104
|
+
if owner_upper and owner_upper in gulf_owner_codes:
|
|
105
|
+
return OpsClass.GULF
|
|
106
|
+
if t_upper and t_upper in gulf_owner_codes:
|
|
107
|
+
return OpsClass.GULF
|
|
108
|
+
if t_upper == "X":
|
|
109
|
+
return OpsClass.TEST if dep == arr else OpsClass.FERRY
|
|
110
|
+
if t_upper:
|
|
111
|
+
mapped = ops_map.get(t_upper)
|
|
112
|
+
if mapped is not None:
|
|
113
|
+
try:
|
|
114
|
+
return OpsClass(mapped)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
if row_date == d_day:
|
|
118
|
+
return OpsClass.DAY
|
|
119
|
+
if row_date == d_day + timedelta(days=1):
|
|
120
|
+
return OpsClass.NIGHT
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _matches_filter(
|
|
125
|
+
f: "dict[str, str]",
|
|
126
|
+
*, ac_type: str, ac_owner: str, ac: str, dep: str, arr: str,
|
|
127
|
+
extras: "dict[str, str]",
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Return True iff every non-empty field in ``f`` matches the
|
|
130
|
+
corresponding flight value (case-insensitive exact). Empty/missing
|
|
131
|
+
fields are skipped — they don't constrain the match. A filter
|
|
132
|
+
with every field blank never matches anything (returns False) so
|
|
133
|
+
a blank filter row in config can't accidentally drop the whole
|
|
134
|
+
pool."""
|
|
135
|
+
def _eq(target: str | None, actual: str) -> bool:
|
|
136
|
+
return bool(target and target.strip()
|
|
137
|
+
and target.strip().upper() == actual.strip().upper())
|
|
138
|
+
constraints = [
|
|
139
|
+
("ac_type", ac_type),
|
|
140
|
+
("ac_owner", ac_owner),
|
|
141
|
+
("ac", ac),
|
|
142
|
+
("dep", dep),
|
|
143
|
+
("arr", arr),
|
|
144
|
+
]
|
|
145
|
+
active = False
|
|
146
|
+
for key, actual in constraints:
|
|
147
|
+
want = f.get(key) or ""
|
|
148
|
+
if want.strip():
|
|
149
|
+
active = True
|
|
150
|
+
if not _eq(want, actual):
|
|
151
|
+
return False
|
|
152
|
+
# Custom-header constraint.
|
|
153
|
+
ch = (f.get("custom_header") or "").strip()
|
|
154
|
+
cv = (f.get("custom_value") or "").strip()
|
|
155
|
+
if ch and cv:
|
|
156
|
+
active = True
|
|
157
|
+
cell = extras.get(ch) or ""
|
|
158
|
+
if cell.strip().upper() != cv.upper():
|
|
159
|
+
return False
|
|
160
|
+
return active
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def clean_flights(
|
|
164
|
+
raw_rows: Iterable[RawFlightRow],
|
|
165
|
+
d_day: date_type,
|
|
166
|
+
config: Config,
|
|
167
|
+
) -> dict[OpsClass, list[CleanFlightRow]]:
|
|
168
|
+
d_plus_1 = d_day + timedelta(days=1)
|
|
169
|
+
# Phase 3 / Change 9: DEP-side only INTL definition.
|
|
170
|
+
intl_codes = {c.upper() for c in config.io.sv_portal.international_airport_codes}
|
|
171
|
+
ops_map = {k.upper(): v for k, v in config.ops_class_by_aircraft_type.items()}
|
|
172
|
+
# 2026-05-19: GULF routing — extract-only sheet, never allocated.
|
|
173
|
+
gulf_dep_codes = frozenset(
|
|
174
|
+
c.strip().upper() for c in config.ops_class_gulf_dep_codes if c.strip()
|
|
175
|
+
)
|
|
176
|
+
gulf_owner_codes = frozenset(
|
|
177
|
+
c.strip().upper() for c in config.ops_class_gulf_owner_codes if c.strip()
|
|
178
|
+
)
|
|
179
|
+
# 2026-05-25: user-defined extraction filters. Flights matching any
|
|
180
|
+
# filter route to OpsClass.GULF — same destination as Gulf-DEP
|
|
181
|
+
# flights (extract-only, never allocated).
|
|
182
|
+
extraction_filters = list(config.extraction_filters or [])
|
|
183
|
+
|
|
184
|
+
out: dict[OpsClass, list[CleanFlightRow]] = defaultdict(list)
|
|
185
|
+
|
|
186
|
+
for r in raw_rows:
|
|
187
|
+
if r.date is None or r.dep_time is None:
|
|
188
|
+
continue
|
|
189
|
+
if r.date not in (d_day, d_plus_1):
|
|
190
|
+
continue
|
|
191
|
+
if r.date == d_day and r.dep_time < CUTOFF:
|
|
192
|
+
continue
|
|
193
|
+
# Phase 3 / Change 7: D+1 keep-window widened to [CUTOFF, DEFERRED_UPPER]
|
|
194
|
+
# inclusive. STD in that band is flagged is_preplan_deferred. Anything
|
|
195
|
+
# past DEFERRED_UPPER on D+1 is dropped.
|
|
196
|
+
if r.date == d_plus_1 and r.dep_time > DEFERRED_UPPER:
|
|
197
|
+
continue
|
|
198
|
+
if r.departure is None or r.arrival is None:
|
|
199
|
+
continue
|
|
200
|
+
dep = r.departure[:3].upper()
|
|
201
|
+
arr = r.arrival[:3].upper()
|
|
202
|
+
# 2026-05-19: Gulf-3 + QR-owner flights are no longer dropped.
|
|
203
|
+
# They route to OpsClass.GULF (extract-only) — see
|
|
204
|
+
# _derive_ops_class precedence. Step 3 ignores the GULF class
|
|
205
|
+
# so they stay out of the solver naturally.
|
|
206
|
+
# Pax is reference-only (user direction 2026-05-10) — never
|
|
207
|
+
# dropped on this basis. Unparseable pax becomes load=0.
|
|
208
|
+
load = _safe_load(r.booked_pax_raw)
|
|
209
|
+
ops = _derive_ops_class(
|
|
210
|
+
r.aircraft_type, r.owner,
|
|
211
|
+
dep, arr, r.date, d_day, ops_map,
|
|
212
|
+
gulf_dep_codes, gulf_owner_codes,
|
|
213
|
+
)
|
|
214
|
+
if ops is None:
|
|
215
|
+
continue
|
|
216
|
+
# 2026-05-25: user-defined extraction filters override the
|
|
217
|
+
# derived ops_class. If any filter matches, route the flight
|
|
218
|
+
# to OpsClass.GULF (extract-only sheet) regardless of what
|
|
219
|
+
# _derive_ops_class returned. Filters run AFTER ops_class
|
|
220
|
+
# derivation so the original class is known (helpful for
|
|
221
|
+
# debugging / log output later if needed) but the destination
|
|
222
|
+
# is overridden to GULF when matched.
|
|
223
|
+
if extraction_filters:
|
|
224
|
+
ac_type_val = str(r.aircraft_type or "")
|
|
225
|
+
ac_owner_val = str(r.owner or "")
|
|
226
|
+
ac_val = str(r.aircraft_subtype or "")
|
|
227
|
+
extras = r.extra_columns or {}
|
|
228
|
+
for f in extraction_filters:
|
|
229
|
+
if _matches_filter(
|
|
230
|
+
f,
|
|
231
|
+
ac_type=ac_type_val, ac_owner=ac_owner_val,
|
|
232
|
+
ac=ac_val, dep=dep, arr=arr, extras=extras,
|
|
233
|
+
):
|
|
234
|
+
ops = OpsClass.GULF
|
|
235
|
+
break
|
|
236
|
+
# Change 7: deferred = D+1 row whose STD is at/after CUTOFF
|
|
237
|
+
# (D+1 < CUTOFF is the existing post-midnight NIGHT tail).
|
|
238
|
+
is_deferred = (r.date == d_plus_1 and r.dep_time >= CUTOFF)
|
|
239
|
+
try:
|
|
240
|
+
cleaned = CleanFlightRow(
|
|
241
|
+
date=r.date,
|
|
242
|
+
flt=str(r.flight_id) if r.flight_id is not None else "",
|
|
243
|
+
type=str(r.aircraft_type or ""),
|
|
244
|
+
ac=str(r.aircraft_subtype or ""),
|
|
245
|
+
dep=dep,
|
|
246
|
+
arr=arr,
|
|
247
|
+
std=r.dep_time,
|
|
248
|
+
load=load,
|
|
249
|
+
ops_class=ops,
|
|
250
|
+
# Change 9 (2026-05-14): DEP-side only.
|
|
251
|
+
# 2026-05-16: P2F flights are NEVER marked international
|
|
252
|
+
# even when their DEP airport (e.g. DAC, KMG) is in the
|
|
253
|
+
# INTL list. P2F has its own dedicated routing + color
|
|
254
|
+
# convention; the INTL flag is for regular DEP-side INTL
|
|
255
|
+
# passenger ops only.
|
|
256
|
+
is_international=(dep in intl_codes
|
|
257
|
+
and ops != OpsClass.P2F),
|
|
258
|
+
is_preplan_deferred=is_deferred,
|
|
259
|
+
)
|
|
260
|
+
except ValueError:
|
|
261
|
+
continue
|
|
262
|
+
out[ops].append(cleaned)
|
|
263
|
+
|
|
264
|
+
return dict(out)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def run(
|
|
268
|
+
state: AppState,
|
|
269
|
+
d_day: date_type,
|
|
270
|
+
config_path: Path | str = "configs/config.yml",
|
|
271
|
+
) -> dict[OpsClass, int]:
|
|
272
|
+
"""Clean the uploaded flight schedule into ``state.cleaned``.
|
|
273
|
+
|
|
274
|
+
Returns the row count per OpsClass. Raises FileNotFoundError when
|
|
275
|
+
the schedule has not been uploaded yet.
|
|
276
|
+
"""
|
|
277
|
+
config = load_config(config_path)
|
|
278
|
+
wb = workbook_from_bytes(state.input_bytes("sv_portal"))
|
|
279
|
+
try:
|
|
280
|
+
raw = read_sv_portal(wb, config)
|
|
281
|
+
finally:
|
|
282
|
+
wb.close()
|
|
283
|
+
by_class = clean_flights(raw, d_day, config)
|
|
284
|
+
with state.lock:
|
|
285
|
+
state.cleaned = by_class
|
|
286
|
+
return {k: len(v) for k, v in by_class.items()}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Step 2 — extract roster availability (plan §10 Phase 2).
|
|
2
|
+
|
|
3
|
+
Reads the two uploaded rosters (regular staff + AM/ZC), normalises
|
|
4
|
+
statuses, and stores the long-format availability matrix in
|
|
5
|
+
``state.availability``. Coverage-shortfall warnings (W010, W011) land in
|
|
6
|
+
``state.warnings``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from datetime import date as date_t
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import cast
|
|
16
|
+
|
|
17
|
+
from .config import Config, load_config
|
|
18
|
+
from .io.readers import DuplicateEmployeeIdError
|
|
19
|
+
from .io.roster_library import read_library
|
|
20
|
+
from .schemas import (
|
|
21
|
+
NON_ASSIGNABLE,
|
|
22
|
+
SHIFT_CODES,
|
|
23
|
+
AMRosterRow,
|
|
24
|
+
AvailabilityRow,
|
|
25
|
+
CrewRosterRow,
|
|
26
|
+
CrewStatus,
|
|
27
|
+
Role,
|
|
28
|
+
Severity,
|
|
29
|
+
ShiftCode,
|
|
30
|
+
WarningRow,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 2026-05-27 (centralization): the per-status shift mapping now lives
|
|
34
|
+
# in schemas.STATUS_TO_SHIFT (single source of truth). This local alias
|
|
35
|
+
# is kept ONLY for readability at call sites that already say
|
|
36
|
+
# `_STATUS_TO_SHIFT.get(...)` — its content is byte-identical to the
|
|
37
|
+
# central map. When a new status is added to CrewStatus, update the
|
|
38
|
+
# central STATUS_TO_SHIFT and every consumer picks it up — no more
|
|
39
|
+
# scattered duplicates to keep in sync.
|
|
40
|
+
from .schemas import STATUS_TO_SHIFT as _STATUS_TO_SHIFT
|
|
41
|
+
|
|
42
|
+
# Status values that ALSO imply a P2F license, even if the License column
|
|
43
|
+
# 2026-05-27 (centralization): the set of P2F-license-granting statuses
|
|
44
|
+
# now lives in schemas.P2F_LICENSE_GRANTING_STATUSES. The alias is kept
|
|
45
|
+
# so existing call sites read clearly. Add a new combined ZC+P2F or
|
|
46
|
+
# plain P2F status to the central STATUS_TO_P2F_HANDLER_SHIFT map and
|
|
47
|
+
# this set picks it up automatically.
|
|
48
|
+
from .schemas import P2F_LICENSE_GRANTING_STATUSES as _P2F_HANDLER_STATUSES
|
|
49
|
+
from .state import AppState
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _emit_for_window(
|
|
53
|
+
rows: Iterable[CrewRosterRow | AMRosterRow],
|
|
54
|
+
window: list[date_t],
|
|
55
|
+
) -> list[AvailabilityRow]:
|
|
56
|
+
out: list[AvailabilityRow] = []
|
|
57
|
+
for crew in rows:
|
|
58
|
+
for d in window:
|
|
59
|
+
if d not in crew.status_by_date:
|
|
60
|
+
continue
|
|
61
|
+
status = crew.status_by_date[d]
|
|
62
|
+
raw = crew.raw_status_by_date.get(d, "")
|
|
63
|
+
# Derive current_shift from any shift-bearing status: plain
|
|
64
|
+
# shifts (M/A/N/M1/A1), ZC variants (M/ZC etc.), and P2F-
|
|
65
|
+
# handler variants (P2F/M etc.). Older code only matched the
|
|
66
|
+
# plain shifts via SHIFT_CODES, which left ZC/P2F rows with
|
|
67
|
+
# current_shift=None — Step 3 then skipped them entirely.
|
|
68
|
+
current_shift: ShiftCode | None = _STATUS_TO_SHIFT.get(status)
|
|
69
|
+
if current_shift is None and status.value in SHIFT_CODES:
|
|
70
|
+
# Defensive fallback for any new shift literal added to
|
|
71
|
+
# CrewStatus without an entry in the map above.
|
|
72
|
+
current_shift = cast(ShiftCode, status.value)
|
|
73
|
+
# P2F-handler statuses auto-populate the license column so
|
|
74
|
+
# downstream eligibility (F3 + F4) treats them as P2F-
|
|
75
|
+
# licensed even when the assigner didn't set the License
|
|
76
|
+
# cell explicitly.
|
|
77
|
+
license_val = crew.license
|
|
78
|
+
if status in _P2F_HANDLER_STATUSES and not (
|
|
79
|
+
license_val and "P2F" in license_val.upper()
|
|
80
|
+
):
|
|
81
|
+
license_val = "P2F (auto from roster cell)"
|
|
82
|
+
# 2026-05-23: per-day role override. Previously crew.role
|
|
83
|
+
# was inferred at READ time across the entire row's date
|
|
84
|
+
# window — so an AM who was M/ZC on one day got tagged
|
|
85
|
+
# Role.ZC for EVERY day, including days they were just
|
|
86
|
+
# plain M. The bug surfaced as "AMs showing up as ZC" in
|
|
87
|
+
# the dashboard. Fix: derive role per-day from THIS day's
|
|
88
|
+
# status only; fall back to the crew's sheet-origin role.
|
|
89
|
+
day_role = crew.role
|
|
90
|
+
if status in (
|
|
91
|
+
CrewStatus.ZC_M, CrewStatus.ZC_A, CrewStatus.ZC_N,
|
|
92
|
+
CrewStatus.ZC_M1, CrewStatus.ZC_A1,
|
|
93
|
+
# 2026-05-26: combined ZC+P2F cells also imply ZC role.
|
|
94
|
+
CrewStatus.ZC_P2F_M, CrewStatus.ZC_P2F_A, CrewStatus.ZC_P2F_N,
|
|
95
|
+
CrewStatus.ZC_P2F_M1, CrewStatus.ZC_P2F_A1,
|
|
96
|
+
):
|
|
97
|
+
day_role = Role.ZC
|
|
98
|
+
elif crew.role == Role.ZC:
|
|
99
|
+
# Row-level inferred ZC but TODAY's cell isn't /ZC —
|
|
100
|
+
# treat as AM (sheet origin = IN_AM_Roster).
|
|
101
|
+
day_role = Role.AM
|
|
102
|
+
out.append(AvailabilityRow(
|
|
103
|
+
employee_id=crew.employee_id,
|
|
104
|
+
name=crew.name,
|
|
105
|
+
role=day_role,
|
|
106
|
+
date=d,
|
|
107
|
+
status=status,
|
|
108
|
+
raw_status=raw,
|
|
109
|
+
assignable=status not in NON_ASSIGNABLE,
|
|
110
|
+
current_shift=current_shift,
|
|
111
|
+
license=license_val,
|
|
112
|
+
origin_role=Role.STAFF if crew.role == Role.STAFF else Role.AM,
|
|
113
|
+
))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _coverage_warning(
|
|
118
|
+
code: str,
|
|
119
|
+
label: str,
|
|
120
|
+
rows_loaded: int,
|
|
121
|
+
covered_dates: set[date_t],
|
|
122
|
+
target_window: list[date_t],
|
|
123
|
+
) -> WarningRow | None:
|
|
124
|
+
if rows_loaded == 0:
|
|
125
|
+
return None
|
|
126
|
+
missing = [d for d in target_window if d not in covered_dates]
|
|
127
|
+
if not missing:
|
|
128
|
+
return None
|
|
129
|
+
iso = ", ".join(d.isoformat() for d in missing)
|
|
130
|
+
return WarningRow(
|
|
131
|
+
severity=Severity.WARN,
|
|
132
|
+
code=code,
|
|
133
|
+
message=(
|
|
134
|
+
f"{label} roster does not cover {iso}; "
|
|
135
|
+
"emitting empty availability for those dates."
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def extract_availability(
|
|
141
|
+
staff_rows: list[CrewRosterRow],
|
|
142
|
+
am_rows: list[AMRosterRow],
|
|
143
|
+
d_day: date_t,
|
|
144
|
+
) -> tuple[list[AvailabilityRow], list[WarningRow]]:
|
|
145
|
+
window: list[date_t] = [d_day, d_day + timedelta(days=1)]
|
|
146
|
+
|
|
147
|
+
staff_dates: set[date_t] = set()
|
|
148
|
+
for s in staff_rows:
|
|
149
|
+
staff_dates.update(s.status_by_date.keys())
|
|
150
|
+
am_dates: set[date_t] = set()
|
|
151
|
+
for a in am_rows:
|
|
152
|
+
am_dates.update(a.status_by_date.keys())
|
|
153
|
+
|
|
154
|
+
warnings: list[WarningRow] = []
|
|
155
|
+
w_staff = _coverage_warning("W010", "staff", len(staff_rows), staff_dates, window)
|
|
156
|
+
if w_staff is not None:
|
|
157
|
+
warnings.append(w_staff)
|
|
158
|
+
w_am = _coverage_warning("W011", "AM", len(am_rows), am_dates, window)
|
|
159
|
+
if w_am is not None:
|
|
160
|
+
warnings.append(w_am)
|
|
161
|
+
|
|
162
|
+
avail = _emit_for_window(staff_rows, window) + _emit_for_window(am_rows, window)
|
|
163
|
+
return avail, warnings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run(
|
|
167
|
+
state: AppState,
|
|
168
|
+
d_day: date_t,
|
|
169
|
+
config_path: Path | str = "configs/config.yml",
|
|
170
|
+
*,
|
|
171
|
+
append_warnings: bool = False,
|
|
172
|
+
) -> dict[str, int]:
|
|
173
|
+
"""Read both uploaded rosters and store availability + warnings on
|
|
174
|
+
``state``. Returns a per-role count dict plus WARNINGS."""
|
|
175
|
+
config: Config = load_config(config_path)
|
|
176
|
+
# The rosters on file cover whole periods and there can be several
|
|
177
|
+
# of them; read the ones that have a column for D / D+1 and merge.
|
|
178
|
+
window = [d_day, d_day + timedelta(days=1)]
|
|
179
|
+
avail: list[AvailabilityRow] = []
|
|
180
|
+
warnings: list[WarningRow] = []
|
|
181
|
+
try:
|
|
182
|
+
staff_rows = read_library(state, "staff_roster", config, window)
|
|
183
|
+
am_rows = read_library(state, "am_roster", config, window)
|
|
184
|
+
except DuplicateEmployeeIdError as e:
|
|
185
|
+
# W020: Surface as ERROR and abort.
|
|
186
|
+
warnings = [WarningRow(
|
|
187
|
+
severity=Severity.ERROR,
|
|
188
|
+
code="W020",
|
|
189
|
+
message=str(e),
|
|
190
|
+
)]
|
|
191
|
+
_store_warnings(state, warnings, append=append_warnings)
|
|
192
|
+
return {"WARNINGS": len(warnings), "ABORTED": 1}
|
|
193
|
+
# 2026-05-27 (user direction): also check for CROSS-roster ID
|
|
194
|
+
# collisions. _check_unique_employee_ids only validates within
|
|
195
|
+
# one sheet at a time — if the same employee_id appears in
|
|
196
|
+
# BOTH Staff_Roster and AM_Roster, downstream code collapses
|
|
197
|
+
# the two rows into one decision variable in the solver. The
|
|
198
|
+
# M1/ZC entry that "doesn't get extracted" is actually
|
|
199
|
+
# silently overwritten by the same-id row from the other
|
|
200
|
+
# sheet. Abort with a clear message.
|
|
201
|
+
cross_dup_ids: list[tuple[str, str, str]] = [] # (id, staff_name, am_name)
|
|
202
|
+
staff_id_to_name = {r.employee_id: r.name for r in staff_rows}
|
|
203
|
+
for r in am_rows:
|
|
204
|
+
if r.employee_id in staff_id_to_name:
|
|
205
|
+
cross_dup_ids.append(
|
|
206
|
+
(r.employee_id, staff_id_to_name[r.employee_id], r.name)
|
|
207
|
+
)
|
|
208
|
+
if cross_dup_ids:
|
|
209
|
+
previews = "; ".join(
|
|
210
|
+
f"id={eid!r}: Staff={sn!r}, AM={an!r}"
|
|
211
|
+
for eid, sn, an in cross_dup_ids[:5]
|
|
212
|
+
)
|
|
213
|
+
if len(cross_dup_ids) > 5:
|
|
214
|
+
previews += f", … (+{len(cross_dup_ids) - 5} more)"
|
|
215
|
+
warnings = [WarningRow(
|
|
216
|
+
severity=Severity.ERROR,
|
|
217
|
+
code="W022",
|
|
218
|
+
message=(
|
|
219
|
+
f"{len(cross_dup_ids)} employee_id(s) appear in BOTH "
|
|
220
|
+
f"the staff roster AND the AM roster: {previews}. Each "
|
|
221
|
+
"staff must have a UNIQUE id across both sheets — the "
|
|
222
|
+
"solver keys decision variables by id, so duplicates "
|
|
223
|
+
"silently drop one of the rows (the M1/ZC entry the "
|
|
224
|
+
"assigner expected to see often disappears this way). "
|
|
225
|
+
"Fix the IDs in the source files, re-upload, re-run."
|
|
226
|
+
),
|
|
227
|
+
)]
|
|
228
|
+
_store_warnings(state, warnings, append=append_warnings)
|
|
229
|
+
return {"WARNINGS": len(warnings), "ABORTED": 1}
|
|
230
|
+
# 2026-05-27 (informational): same NAME with DIFFERENT IDs
|
|
231
|
+
# across rosters is allowed (it's a real scenario — different
|
|
232
|
+
# people with the same name), but emit a WARN so the assigner
|
|
233
|
+
# knows to be careful when matching override rows by name.
|
|
234
|
+
staff_names_lower = {r.name.strip().upper(): r.employee_id for r in staff_rows}
|
|
235
|
+
same_name_diff_id: list[tuple[str, str, str]] = [] # (name, staff_id, am_id)
|
|
236
|
+
for r in am_rows:
|
|
237
|
+
key = r.name.strip().upper()
|
|
238
|
+
if key in staff_names_lower and staff_names_lower[key] != r.employee_id:
|
|
239
|
+
same_name_diff_id.append((r.name, staff_names_lower[key], r.employee_id))
|
|
240
|
+
# Also same name within Staff sheet (different IDs already
|
|
241
|
+
# caught for the same sheet by _check_unique_employee_ids on
|
|
242
|
+
# ids — but two different ids with the SAME name slip past).
|
|
243
|
+
staff_name_counts: dict[str, list[str]] = {}
|
|
244
|
+
for r in staff_rows:
|
|
245
|
+
staff_name_counts.setdefault(r.name.strip().upper(), []).append(r.employee_id)
|
|
246
|
+
dup_names_in_staff = [
|
|
247
|
+
(name, ids) for name, ids in staff_name_counts.items() if len(ids) > 1
|
|
248
|
+
]
|
|
249
|
+
cross_warns: list[WarningRow] = []
|
|
250
|
+
if same_name_diff_id:
|
|
251
|
+
previews = "; ".join(
|
|
252
|
+
f"{nm!r} (Staff id={sid}, AM id={aid})"
|
|
253
|
+
for nm, sid, aid in same_name_diff_id[:5]
|
|
254
|
+
)
|
|
255
|
+
cross_warns.append(WarningRow(
|
|
256
|
+
severity=Severity.WARN,
|
|
257
|
+
code="W023",
|
|
258
|
+
message=(
|
|
259
|
+
f"{len(same_name_diff_id)} name(s) appear in BOTH "
|
|
260
|
+
f"rosters with different IDs: {previews}. Override "
|
|
261
|
+
"rows that match by name will resolve to the AM-roster "
|
|
262
|
+
"ID (later wins). Use IDs explicitly in overrides to "
|
|
263
|
+
"be unambiguous."
|
|
264
|
+
),
|
|
265
|
+
))
|
|
266
|
+
if dup_names_in_staff:
|
|
267
|
+
previews = "; ".join(
|
|
268
|
+
f"{nm!r} (ids: {', '.join(ids)})"
|
|
269
|
+
for nm, ids in dup_names_in_staff[:5]
|
|
270
|
+
)
|
|
271
|
+
cross_warns.append(WarningRow(
|
|
272
|
+
severity=Severity.WARN,
|
|
273
|
+
code="W024",
|
|
274
|
+
message=(
|
|
275
|
+
f"{len(dup_names_in_staff)} name(s) appear MORE THAN "
|
|
276
|
+
f"ONCE in the staff roster with distinct IDs: {previews}. "
|
|
277
|
+
"Override rows that match by name will resolve to "
|
|
278
|
+
"the LAST-seen ID. Disambiguate via IDs in overrides."
|
|
279
|
+
),
|
|
280
|
+
))
|
|
281
|
+
avail, warnings = extract_availability(staff_rows, am_rows, d_day)
|
|
282
|
+
warnings = cross_warns + warnings
|
|
283
|
+
with state.lock:
|
|
284
|
+
state.availability = avail
|
|
285
|
+
_store_warnings(state, warnings, append=append_warnings)
|
|
286
|
+
|
|
287
|
+
# Re-apply the staged drawer mutations AFTER availability has been
|
|
288
|
+
# rebuilt from the raw rosters. Without this, every Plan wipes the
|
|
289
|
+
# assigner's staged intent (remove_staff flipping assignable=False,
|
|
290
|
+
# add_staff synthetic rows, add_flight / remove_flight) and the
|
|
291
|
+
# solver sees the original roster instead. The override list is the
|
|
292
|
+
# source of truth; cleaned flights + availability are working
|
|
293
|
+
# copies that get patched here on every step2 run.
|
|
294
|
+
from . import staged_overrides as _so
|
|
295
|
+
applied = _so.apply_staged_overrides(state, d_day)
|
|
296
|
+
total = sum(applied.values()) if applied else 0
|
|
297
|
+
if total:
|
|
298
|
+
print(
|
|
299
|
+
" staged overrides re-applied after roster rebuild: "
|
|
300
|
+
+ ", ".join(f"{k}={v}" for k, v in applied.items() if v)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
counts: dict[str, int] = {}
|
|
304
|
+
for r in avail:
|
|
305
|
+
counts[r.role.value] = counts.get(r.role.value, 0) + 1
|
|
306
|
+
counts["WARNINGS"] = len(warnings)
|
|
307
|
+
return counts
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _store_warnings(
|
|
311
|
+
state: AppState, warnings: list[WarningRow], *, append: bool,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Put this stage's warnings on the state, either appending to what
|
|
314
|
+
a previous stage left or replacing it."""
|
|
315
|
+
with state.lock:
|
|
316
|
+
state.warnings = (list(state.warnings) + warnings) if append else list(warnings)
|