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,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)