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
src/state.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""In-memory application state — the single store the whole app runs on.
|
|
2
|
+
|
|
3
|
+
There is no workbook, no database and no per-date file tree. The three
|
|
4
|
+
input files arrive as uploads from the browser and are held as bytes;
|
|
5
|
+
every engine stage reads its inputs from this object and writes its
|
|
6
|
+
outputs back into it; the web layer serialises it to JSON for the
|
|
7
|
+
dashboard and to a one-off .xlsx for the Download button.
|
|
8
|
+
|
|
9
|
+
Lifetime is the server process. Restarting the server clears the
|
|
10
|
+
results, the overrides and the daily flight schedule. The two roster
|
|
11
|
+
files are the exception: they cover a whole period, so every upload is
|
|
12
|
+
also kept on disk (``roster_store``) and reloaded at start-up, and
|
|
13
|
+
several rosters can be held at once — the one that covers the
|
|
14
|
+
allocation date is the one that gets used.
|
|
15
|
+
|
|
16
|
+
Threading: ``ThreadingHTTPServer`` means several requests can touch the
|
|
17
|
+
state at once, so every mutation goes through ``STATE.lock``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import threading
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
from datetime import date as date_t
|
|
28
|
+
from datetime import datetime, timedelta
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from . import roster_store
|
|
32
|
+
from .schemas import (
|
|
33
|
+
AllocationRow,
|
|
34
|
+
AvailabilityRow,
|
|
35
|
+
CleanFlightRow,
|
|
36
|
+
OpsClass,
|
|
37
|
+
Pair,
|
|
38
|
+
WarningRow,
|
|
39
|
+
WorkloadSummaryRow,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# The three files the operator uploads. Keys are the API's ``kind``
|
|
43
|
+
# path segment; the labels are what the UI shows next to each slot.
|
|
44
|
+
INPUT_KINDS: tuple[str, ...] = ("sv_portal", "staff_roster", "am_roster")
|
|
45
|
+
INPUT_LABELS: dict[str, str] = {
|
|
46
|
+
"sv_portal": "Flight schedule (SV portal export)",
|
|
47
|
+
"staff_roster": "Staff roster",
|
|
48
|
+
"am_roster": "AM List",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# How often each file actually changes, which is what decides where the
|
|
52
|
+
# UI puts it. The flight schedule is a fresh export every morning, so it
|
|
53
|
+
# stays on the dashboard where the day's work starts. The two rosters are
|
|
54
|
+
# published for a whole period — uploaded once and then left alone — so
|
|
55
|
+
# they live in the Setup sidebar, out of the daily path.
|
|
56
|
+
DAILY_INPUT_KINDS: tuple[str, ...] = ("sv_portal",)
|
|
57
|
+
SETUP_INPUT_KINDS: tuple[str, ...] = ("staff_roster", "am_roster")
|
|
58
|
+
INPUT_CADENCE: dict[str, str] = {
|
|
59
|
+
**{k: "daily" for k in DAILY_INPUT_KINDS},
|
|
60
|
+
**{k: "setup" for k in SETUP_INPUT_KINDS},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Where the operator fixes a given file, quoted back in the
|
|
64
|
+
# "not uploaded yet" error so the message names a real place in the UI.
|
|
65
|
+
INPUT_HOMES: dict[str, str] = {
|
|
66
|
+
"daily": "the Flight schedule card on the dashboard",
|
|
67
|
+
"setup": "the Setup sidebar (Setup button, top right)",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Column set for an override row. Previously the IN_Override sheet's
|
|
71
|
+
# header; now just the keys each override dict may carry. Order is the
|
|
72
|
+
# column order the drawer renders.
|
|
73
|
+
OVERRIDE_HEADERS: tuple[str, ...] = (
|
|
74
|
+
"type", "employee", "shift", "limit",
|
|
75
|
+
"flight", "std", "other_std",
|
|
76
|
+
"flt", "dep", "arr", "ops_class", "date",
|
|
77
|
+
"ac_type", "ac", "pax", "owner", "role",
|
|
78
|
+
"from_time", "to_time",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# What the drawer renders as editable columns. The rest stay in the row
|
|
82
|
+
# payload (so a Phase-R row keeps its flight/std) but are not worth a
|
|
83
|
+
# column each in a hand-edited table.
|
|
84
|
+
OVERRIDE_HEADERS_VISIBLE: tuple[str, ...] = (
|
|
85
|
+
"type", "employee", "shift", "limit",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class UploadedInput:
|
|
91
|
+
"""One uploaded .xlsx, held in memory."""
|
|
92
|
+
|
|
93
|
+
kind: str
|
|
94
|
+
filename: str
|
|
95
|
+
data: bytes
|
|
96
|
+
uploaded_at: datetime = field(default_factory=datetime.now)
|
|
97
|
+
|
|
98
|
+
def as_json(self) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"kind": self.kind,
|
|
101
|
+
"label": INPUT_LABELS.get(self.kind, self.kind),
|
|
102
|
+
"cadence": INPUT_CADENCE.get(self.kind, "daily"),
|
|
103
|
+
"filename": self.filename,
|
|
104
|
+
"size_kb": round(len(self.data) / 1024, 1),
|
|
105
|
+
"uploaded_at": self.uploaded_at.isoformat(timespec="seconds"),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RosterFile:
|
|
111
|
+
"""One uploaded period roster. Unlike the daily schedule, several of
|
|
112
|
+
these can be held at once (Aug-Sep, then Sep-Oct, ...); ``dates`` is
|
|
113
|
+
every calendar date the workbook has a column for."""
|
|
114
|
+
|
|
115
|
+
id: str
|
|
116
|
+
kind: str
|
|
117
|
+
filename: str
|
|
118
|
+
data: bytes
|
|
119
|
+
dates: list[date_t]
|
|
120
|
+
uploaded_at: datetime = field(default_factory=datetime.now)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def start(self) -> date_t | None:
|
|
124
|
+
return min(self.dates) if self.dates else None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def end(self) -> date_t | None:
|
|
128
|
+
return max(self.dates) if self.dates else None
|
|
129
|
+
|
|
130
|
+
def as_json(self) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"id": self.id,
|
|
133
|
+
"filename": self.filename,
|
|
134
|
+
"size_kb": round(len(self.data) / 1024, 1),
|
|
135
|
+
"uploaded_at": self.uploaded_at.isoformat(timespec="seconds"),
|
|
136
|
+
"start": self.start.isoformat() if self.start else "",
|
|
137
|
+
"end": self.end.isoformat() if self.end else "",
|
|
138
|
+
"days": len(self.dates),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class AppState:
|
|
144
|
+
"""Everything the app knows, for one operator, for one day."""
|
|
145
|
+
|
|
146
|
+
lock: threading.RLock = field(default_factory=threading.RLock)
|
|
147
|
+
|
|
148
|
+
# ---- inputs (uploaded from the dashboard and the Setup sidebar) ----
|
|
149
|
+
inputs: dict[str, UploadedInput] = field(default_factory=dict)
|
|
150
|
+
# Period rosters (SETUP_INPUT_KINDS), oldest upload first. Kept apart
|
|
151
|
+
# from ``inputs`` because there can be several per kind.
|
|
152
|
+
rosters: dict[str, list[RosterFile]] = field(default_factory=dict)
|
|
153
|
+
run_date: date_t | None = None
|
|
154
|
+
|
|
155
|
+
# ---- overrides (edited via the drawer) ----
|
|
156
|
+
overrides: list[dict[str, str]] = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
# ---- Plan stage outputs ----
|
|
159
|
+
cleaned: dict[OpsClass, list[CleanFlightRow]] = field(default_factory=dict)
|
|
160
|
+
availability: list[AvailabilityRow] = field(default_factory=list)
|
|
161
|
+
plan_summary: dict[str, Any] | None = None
|
|
162
|
+
plan_text: str = ""
|
|
163
|
+
staffing: list[dict[str, Any]] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
# ---- Allocate stage outputs ----
|
|
166
|
+
allocations: list[AllocationRow] = field(default_factory=list)
|
|
167
|
+
pairs: list[Pair] = field(default_factory=list)
|
|
168
|
+
workload: list[WorkloadSummaryRow] = field(default_factory=list)
|
|
169
|
+
warnings: list[WarningRow] = field(default_factory=list)
|
|
170
|
+
unallocated: list[dict[str, Any]] = field(default_factory=list)
|
|
171
|
+
recommendations: dict[str, Any] = field(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
# ---- run status ----
|
|
174
|
+
last_run: str = ""
|
|
175
|
+
mode: str = ""
|
|
176
|
+
solver_status: str = ""
|
|
177
|
+
duration_s: float | None = None
|
|
178
|
+
|
|
179
|
+
# Snapshot of the previous solve's {flight_key: staff_name}. Marks
|
|
180
|
+
# redistributed rows in the Allocations tab and warm-starts the
|
|
181
|
+
# next solve.
|
|
182
|
+
prev_staff_by_key: dict[str, str] = field(default_factory=dict)
|
|
183
|
+
|
|
184
|
+
# ---------------- inputs ----------------
|
|
185
|
+
|
|
186
|
+
def set_input(self, kind: str, filename: str, data: bytes) -> UploadedInput:
|
|
187
|
+
"""Store a single-file input (the daily schedule). Rosters go
|
|
188
|
+
through ``add_roster`` because they accumulate."""
|
|
189
|
+
if kind not in INPUT_KINDS:
|
|
190
|
+
raise ValueError(f"unknown input kind {kind!r}")
|
|
191
|
+
if kind in SETUP_INPUT_KINDS:
|
|
192
|
+
raise ValueError(f"{kind!r} is a period roster; use add_roster()")
|
|
193
|
+
with self.lock:
|
|
194
|
+
up = UploadedInput(kind=kind, filename=filename, data=data)
|
|
195
|
+
self.inputs[kind] = up
|
|
196
|
+
return up
|
|
197
|
+
|
|
198
|
+
def clear_input(self, kind: str) -> None:
|
|
199
|
+
"""Drop an input. For a roster kind that means every stored roster
|
|
200
|
+
of that kind, from memory and from disk."""
|
|
201
|
+
with self.lock:
|
|
202
|
+
if kind in SETUP_INPUT_KINDS:
|
|
203
|
+
for rf in self.rosters.pop(kind, []):
|
|
204
|
+
roster_store.delete(rf.id)
|
|
205
|
+
else:
|
|
206
|
+
self.inputs.pop(kind, None)
|
|
207
|
+
|
|
208
|
+
def input_bytes(self, kind: str) -> bytes:
|
|
209
|
+
if kind in SETUP_INPUT_KINDS:
|
|
210
|
+
files = self.rosters.get(kind) or []
|
|
211
|
+
if not files:
|
|
212
|
+
raise FileNotFoundError(self._missing_message(kind))
|
|
213
|
+
return files[-1].data
|
|
214
|
+
up = self.inputs.get(kind)
|
|
215
|
+
if up is None:
|
|
216
|
+
raise FileNotFoundError(self._missing_message(kind))
|
|
217
|
+
return up.data
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _missing_message(kind: str) -> str:
|
|
221
|
+
home = INPUT_HOMES.get(INPUT_CADENCE.get(kind, "daily"), "the dashboard")
|
|
222
|
+
return (
|
|
223
|
+
f"{INPUT_LABELS.get(kind, kind)} has not been uploaded yet — "
|
|
224
|
+
f"upload it from {home}."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _has_input(self, kind: str) -> bool:
|
|
228
|
+
if kind in SETUP_INPUT_KINDS:
|
|
229
|
+
return bool(self.rosters.get(kind))
|
|
230
|
+
return kind in self.inputs
|
|
231
|
+
|
|
232
|
+
def missing_inputs(self, cadence: str | None = None) -> list[str]:
|
|
233
|
+
"""Kinds with no file yet. ``cadence`` narrows it to one group, so
|
|
234
|
+
the UI can tell "no schedule today" apart from "rosters were
|
|
235
|
+
never set up" — two different fixes, in two different places."""
|
|
236
|
+
return [
|
|
237
|
+
k for k in INPUT_KINDS
|
|
238
|
+
if not self._has_input(k)
|
|
239
|
+
and (cadence is None or INPUT_CADENCE.get(k) == cadence)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
def inputs_as_json(self) -> list[dict[str, Any]]:
|
|
243
|
+
out = []
|
|
244
|
+
for kind in INPUT_KINDS:
|
|
245
|
+
base = {
|
|
246
|
+
"kind": kind,
|
|
247
|
+
"label": INPUT_LABELS[kind],
|
|
248
|
+
"cadence": INPUT_CADENCE.get(kind, "daily"),
|
|
249
|
+
}
|
|
250
|
+
if kind in SETUP_INPUT_KINDS:
|
|
251
|
+
files = self.rosters.get(kind) or []
|
|
252
|
+
newest = files[-1] if files else None
|
|
253
|
+
out.append({
|
|
254
|
+
**base,
|
|
255
|
+
"filename": newest.filename if newest else "",
|
|
256
|
+
"size_kb": round(len(newest.data) / 1024, 1) if newest else 0,
|
|
257
|
+
"uploaded_at": (
|
|
258
|
+
newest.uploaded_at.isoformat(timespec="seconds")
|
|
259
|
+
if newest else ""
|
|
260
|
+
),
|
|
261
|
+
# Newest first: the one on top is the one that wins
|
|
262
|
+
# wherever two rosters overlap.
|
|
263
|
+
"files": [f.as_json() for f in reversed(files)],
|
|
264
|
+
})
|
|
265
|
+
continue
|
|
266
|
+
up = self.inputs.get(kind)
|
|
267
|
+
if up is None:
|
|
268
|
+
out.append({**base, "filename": "", "size_kb": 0, "uploaded_at": ""})
|
|
269
|
+
else:
|
|
270
|
+
out.append(up.as_json())
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
# ---------------- period rosters ----------------
|
|
274
|
+
|
|
275
|
+
def add_roster(
|
|
276
|
+
self, kind: str, filename: str, data: bytes, dates: Iterable[date_t],
|
|
277
|
+
*, persist: bool = True,
|
|
278
|
+
) -> RosterFile:
|
|
279
|
+
"""Keep an uploaded roster alongside the ones already held.
|
|
280
|
+
|
|
281
|
+
Re-uploading the same file (same name or same bytes) replaces
|
|
282
|
+
the earlier copy rather than piling up duplicates. A roster for
|
|
283
|
+
a different period is added; the rosters stay ordered by upload
|
|
284
|
+
time, and where two overlap the later upload wins.
|
|
285
|
+
"""
|
|
286
|
+
if kind not in SETUP_INPUT_KINDS:
|
|
287
|
+
raise ValueError(f"{kind!r} is not a period roster")
|
|
288
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
289
|
+
with self.lock:
|
|
290
|
+
kept: list[RosterFile] = []
|
|
291
|
+
for rf in self.rosters.get(kind, []):
|
|
292
|
+
same = (
|
|
293
|
+
rf.filename == filename
|
|
294
|
+
or hashlib.sha256(rf.data).hexdigest() == digest
|
|
295
|
+
)
|
|
296
|
+
if same:
|
|
297
|
+
roster_store.delete(rf.id)
|
|
298
|
+
else:
|
|
299
|
+
kept.append(rf)
|
|
300
|
+
now = datetime.now()
|
|
301
|
+
rf = RosterFile(
|
|
302
|
+
id=f"{now:%Y%m%dT%H%M%S}_{uuid.uuid4().hex[:6]}",
|
|
303
|
+
kind=kind, filename=filename, data=data,
|
|
304
|
+
dates=sorted(set(dates)), uploaded_at=now,
|
|
305
|
+
)
|
|
306
|
+
kept.append(rf)
|
|
307
|
+
self.rosters[kind] = kept
|
|
308
|
+
if persist:
|
|
309
|
+
roster_store.save(
|
|
310
|
+
file_id=rf.id, kind=kind, filename=filename,
|
|
311
|
+
uploaded_at=now, dates=rf.dates, data=data,
|
|
312
|
+
)
|
|
313
|
+
return rf
|
|
314
|
+
|
|
315
|
+
def remove_roster(self, kind: str, file_id: str) -> bool:
|
|
316
|
+
with self.lock:
|
|
317
|
+
files = self.rosters.get(kind, [])
|
|
318
|
+
keep = [f for f in files if f.id != file_id]
|
|
319
|
+
if len(keep) == len(files):
|
|
320
|
+
return False
|
|
321
|
+
self.rosters[kind] = keep
|
|
322
|
+
roster_store.delete(file_id)
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
def load_persisted_rosters(self) -> int:
|
|
326
|
+
"""Reload the rosters saved by earlier sessions. Called once at
|
|
327
|
+
server start; returns how many came back."""
|
|
328
|
+
loaded = 0
|
|
329
|
+
with self.lock:
|
|
330
|
+
for stored in roster_store.load_all():
|
|
331
|
+
if stored.kind not in SETUP_INPUT_KINDS:
|
|
332
|
+
continue
|
|
333
|
+
self.rosters.setdefault(stored.kind, []).append(RosterFile(
|
|
334
|
+
id=stored.id, kind=stored.kind, filename=stored.filename,
|
|
335
|
+
data=stored.data, dates=sorted(stored.dates),
|
|
336
|
+
uploaded_at=stored.uploaded_at,
|
|
337
|
+
))
|
|
338
|
+
loaded += 1
|
|
339
|
+
for files in self.rosters.values():
|
|
340
|
+
files.sort(key=lambda f: f.uploaded_at)
|
|
341
|
+
return loaded
|
|
342
|
+
|
|
343
|
+
def roster_files_for(
|
|
344
|
+
self, kind: str, window: Iterable[date_t] | None = None,
|
|
345
|
+
) -> list[RosterFile]:
|
|
346
|
+
"""The stored rosters that matter for ``window`` (oldest upload
|
|
347
|
+
first, so a later upload overrides an earlier one on the merge).
|
|
348
|
+
|
|
349
|
+
Only rosters with a column in the window are returned. When none
|
|
350
|
+
has, every stored roster is returned instead: the reader then
|
|
351
|
+
loads real rows, finds no cell for the date, and the coverage
|
|
352
|
+
warning (W010 / W011) names the missing date — better than
|
|
353
|
+
silently planning with an empty roster.
|
|
354
|
+
"""
|
|
355
|
+
with self.lock:
|
|
356
|
+
files = list(self.rosters.get(kind, []))
|
|
357
|
+
if not files or window is None:
|
|
358
|
+
return files
|
|
359
|
+
wanted = set(window)
|
|
360
|
+
hits = [f for f in files if wanted.intersection(f.dates)]
|
|
361
|
+
return hits or files
|
|
362
|
+
|
|
363
|
+
def roster_coverage(self, day: date_t) -> dict[str, Any]:
|
|
364
|
+
"""Does each roster kind have a column for ``day`` and ``day+1``
|
|
365
|
+
(the two days the Plan stage reads)? Drives the notice in the
|
|
366
|
+
Setup sidebar so a stale roster is caught before Plan runs."""
|
|
367
|
+
next_day = day + timedelta(days=1)
|
|
368
|
+
out: dict[str, Any] = {"date": day.isoformat(), "notices": []}
|
|
369
|
+
with self.lock:
|
|
370
|
+
for kind in SETUP_INPUT_KINDS:
|
|
371
|
+
files = self.rosters.get(kind, [])
|
|
372
|
+
covered = {d for f in files for d in f.dates}
|
|
373
|
+
info = {
|
|
374
|
+
"loaded": bool(files),
|
|
375
|
+
"covers_date": day in covered,
|
|
376
|
+
"covers_next_day": next_day in covered,
|
|
377
|
+
}
|
|
378
|
+
out[kind] = info
|
|
379
|
+
label = INPUT_LABELS[kind]
|
|
380
|
+
if not files:
|
|
381
|
+
continue
|
|
382
|
+
if not info["covers_date"]:
|
|
383
|
+
out["notices"].append(
|
|
384
|
+
f"{label}: no roster covers {day:%d %b %Y}. "
|
|
385
|
+
"Upload the roster for that period."
|
|
386
|
+
)
|
|
387
|
+
elif not info["covers_next_day"]:
|
|
388
|
+
out["notices"].append(
|
|
389
|
+
f"{label}: the roster ends on {day:%d %b %Y}. "
|
|
390
|
+
"Upload the next one before planning the following day."
|
|
391
|
+
)
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
# ---------------- overrides ----------------
|
|
395
|
+
|
|
396
|
+
def add_override(self, row: dict[str, str]) -> int:
|
|
397
|
+
"""Append an override row. Returns its 0-based index."""
|
|
398
|
+
with self.lock:
|
|
399
|
+
self.overrides.append(_clean_override(row))
|
|
400
|
+
return len(self.overrides) - 1
|
|
401
|
+
|
|
402
|
+
def update_override(self, index: int, row: dict[str, str]) -> None:
|
|
403
|
+
with self.lock:
|
|
404
|
+
if not 0 <= index < len(self.overrides):
|
|
405
|
+
raise IndexError(f"no override row at index {index}")
|
|
406
|
+
self.overrides[index] = _clean_override(row)
|
|
407
|
+
|
|
408
|
+
def delete_override(self, index: int) -> None:
|
|
409
|
+
with self.lock:
|
|
410
|
+
if not 0 <= index < len(self.overrides):
|
|
411
|
+
raise IndexError(f"no override row at index {index}")
|
|
412
|
+
del self.overrides[index]
|
|
413
|
+
|
|
414
|
+
# ---------------- lifecycle ----------------
|
|
415
|
+
|
|
416
|
+
def reset_results(self) -> dict[str, int]:
|
|
417
|
+
"""Clear every engine output but keep the uploads and the
|
|
418
|
+
override rows. This is what the Reset button does."""
|
|
419
|
+
with self.lock:
|
|
420
|
+
cleared = {
|
|
421
|
+
"cleaned": sum(len(v) for v in self.cleaned.values()),
|
|
422
|
+
"availability": len(self.availability),
|
|
423
|
+
"allocations": len(self.allocations),
|
|
424
|
+
"pairs": len(self.pairs),
|
|
425
|
+
"workload": len(self.workload),
|
|
426
|
+
"warnings": len(self.warnings),
|
|
427
|
+
"unallocated": len(self.unallocated),
|
|
428
|
+
}
|
|
429
|
+
self.cleaned = {}
|
|
430
|
+
self.availability = []
|
|
431
|
+
self.plan_summary = None
|
|
432
|
+
self.plan_text = ""
|
|
433
|
+
self.staffing = []
|
|
434
|
+
self.allocations = []
|
|
435
|
+
self.pairs = []
|
|
436
|
+
self.workload = []
|
|
437
|
+
self.warnings = []
|
|
438
|
+
self.unallocated = []
|
|
439
|
+
self.recommendations = {}
|
|
440
|
+
self.prev_staff_by_key = {}
|
|
441
|
+
self.last_run = ""
|
|
442
|
+
self.mode = ""
|
|
443
|
+
self.solver_status = ""
|
|
444
|
+
self.duration_s = None
|
|
445
|
+
return cleared
|
|
446
|
+
|
|
447
|
+
def stamp_run(
|
|
448
|
+
self, *, mode: str, solver_status: str, duration_s: float | None,
|
|
449
|
+
) -> None:
|
|
450
|
+
with self.lock:
|
|
451
|
+
self.last_run = datetime.now().isoformat(timespec="seconds")
|
|
452
|
+
self.mode = mode
|
|
453
|
+
self.solver_status = solver_status
|
|
454
|
+
self.duration_s = duration_s
|
|
455
|
+
|
|
456
|
+
# ---------------- convenience views ----------------
|
|
457
|
+
|
|
458
|
+
def all_cleaned(self) -> list[CleanFlightRow]:
|
|
459
|
+
out: list[CleanFlightRow] = []
|
|
460
|
+
for rows in self.cleaned.values():
|
|
461
|
+
out.extend(rows)
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
def cleaned_for(self, *ops: OpsClass) -> list[CleanFlightRow]:
|
|
465
|
+
out: list[CleanFlightRow] = []
|
|
466
|
+
for o in ops:
|
|
467
|
+
out.extend(self.cleaned.get(o, []))
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
def allocatable_cleaned(self) -> list[CleanFlightRow]:
|
|
471
|
+
"""Everything the solver is allowed to assign — every cleaned
|
|
472
|
+
flight except GULF, which is extract-only."""
|
|
473
|
+
return [
|
|
474
|
+
r for r in self.all_cleaned() if r.ops_class is not OpsClass.GULF
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _clean_override(row: dict[str, str]) -> dict[str, str]:
|
|
479
|
+
"""Normalise an override row: lowercased keys, stripped string
|
|
480
|
+
values, blanks dropped."""
|
|
481
|
+
out: dict[str, str] = {}
|
|
482
|
+
for k, v in row.items():
|
|
483
|
+
key = str(k).strip().lower()
|
|
484
|
+
if not key:
|
|
485
|
+
continue
|
|
486
|
+
s = "" if v is None else str(v).strip()
|
|
487
|
+
if s:
|
|
488
|
+
out[key] = s
|
|
489
|
+
return out
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# The process-wide singleton. Imported directly by the engine stages and
|
|
493
|
+
# the web layer — there is exactly one operator per server.
|
|
494
|
+
STATE = AppState()
|