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/io/export.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Build a downloadable .xlsx from the current state.
|
|
2
|
+
|
|
3
|
+
The app no longer keeps a workbook — this module exists only so the
|
|
4
|
+
assigner can click "Download .xlsx" and walk away with a file to email
|
|
5
|
+
or print. It is a pure read of ``AppState``: nothing here is ever read
|
|
6
|
+
back in.
|
|
7
|
+
|
|
8
|
+
One sheet per output, plus the colour cues that carried meaning in the
|
|
9
|
+
old console workbook (international DEP, pre-plan / relief handovers,
|
|
10
|
+
warnings, unallocated rows).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
|
|
18
|
+
from openpyxl import Workbook
|
|
19
|
+
from openpyxl.styles import Alignment, Font, PatternFill
|
|
20
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
21
|
+
|
|
22
|
+
from ..schemas import AllocationRow, AllocationSheet, OpsClass
|
|
23
|
+
from ..state import AppState, OVERRIDE_HEADERS
|
|
24
|
+
|
|
25
|
+
# Colour cues, carried over from the console workbook.
|
|
26
|
+
HEADER_FILL_HEX = "FF1F3864"
|
|
27
|
+
INTL_FILL_HEX = "FFFFD6E7" # soft pink — international DEP
|
|
28
|
+
PLANNED_BY_FILL_HEX = "FFDEEAF6" # blue — pre-planned by someone else
|
|
29
|
+
RELIEVED_BY_FILL_HEX = "FFE2EFDA" # green — handed over mid-flight
|
|
30
|
+
WARNING_FILL_HEX = "FFFFF2CC" # amber
|
|
31
|
+
UNALLOCATED_FILL_HEX = "FFFCE4E4" # soft red
|
|
32
|
+
|
|
33
|
+
ALLOCATION_HEADERS = (
|
|
34
|
+
"date", "flt", "dep", "arr", "std", "pax",
|
|
35
|
+
"staff", "planned_by", "relieved_by", "warning",
|
|
36
|
+
)
|
|
37
|
+
CLEANED_HEADERS = (
|
|
38
|
+
"date", "flt", "type", "ac", "dep", "arr", "std", "pax", "ops_class",
|
|
39
|
+
)
|
|
40
|
+
PAIR_MAP_HEADERS = (
|
|
41
|
+
"boundary", "pair_role", "preplan_count",
|
|
42
|
+
"prev_employee_id", "prev_name", "prev_shift",
|
|
43
|
+
"next_employee_id", "next_name", "next_shift",
|
|
44
|
+
)
|
|
45
|
+
UNALLOCATED_HEADERS = (
|
|
46
|
+
"date", "flt", "dep", "arr", "std", "pax",
|
|
47
|
+
"ops_class", "is_international", "reason",
|
|
48
|
+
)
|
|
49
|
+
WORKLOAD_HEADERS = (
|
|
50
|
+
"employee_id", "name", "shift", "role",
|
|
51
|
+
"target_preferred", "target_acceptable_max", "hard_cap", "actual",
|
|
52
|
+
"deviation_below_preferred", "deviation_above_preferred", "violations",
|
|
53
|
+
)
|
|
54
|
+
WARNING_HEADERS = ("severity", "code", "name", "date", "message")
|
|
55
|
+
AVAILABILITY_HEADERS = (
|
|
56
|
+
"employee_id", "name", "role", "date", "status", "raw_status",
|
|
57
|
+
"assignable", "current_shift", "license",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_ALLOC_SHEET_NAME = {
|
|
61
|
+
AllocationSheet.DAY_OPS: "Allocations_DayOps",
|
|
62
|
+
AllocationSheet.NIGHT_OPS: "Allocations_NightOps",
|
|
63
|
+
AllocationSheet.P2F: "Allocations_P2F",
|
|
64
|
+
}
|
|
65
|
+
_CLEANED_SHEET_NAME = {
|
|
66
|
+
OpsClass.DAY: "Flights_DayOps",
|
|
67
|
+
OpsClass.NIGHT: "Flights_NightOps",
|
|
68
|
+
OpsClass.P2F: "Flights_P2F",
|
|
69
|
+
OpsClass.GULF: "Flights_Gulf",
|
|
70
|
+
OpsClass.TEST: "Flights_Test",
|
|
71
|
+
OpsClass.FERRY: "Flights_Ferry",
|
|
72
|
+
OpsClass.CHARTER: "Flights_Charter",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Column indices (1-based) used for the per-cell fills.
|
|
76
|
+
_COL_DEP = ALLOCATION_HEADERS.index("dep") + 1
|
|
77
|
+
_COL_ARR = ALLOCATION_HEADERS.index("arr") + 1
|
|
78
|
+
_COL_PLANNED_BY = ALLOCATION_HEADERS.index("planned_by") + 1
|
|
79
|
+
_COL_RELIEVED_BY = ALLOCATION_HEADERS.index("relieved_by") + 1
|
|
80
|
+
_COL_WARNING = ALLOCATION_HEADERS.index("warning") + 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sheet(wb: Workbook, title: str, headers: Iterable[str]) -> Worksheet:
|
|
84
|
+
"""Create a sheet with a styled, frozen header row."""
|
|
85
|
+
ws = wb.create_sheet(title[:31])
|
|
86
|
+
cols = list(headers)
|
|
87
|
+
ws.append(cols)
|
|
88
|
+
fill = PatternFill("solid", fgColor=HEADER_FILL_HEX)
|
|
89
|
+
for col in range(1, len(cols) + 1):
|
|
90
|
+
cell = ws.cell(row=1, column=col)
|
|
91
|
+
cell.fill = fill
|
|
92
|
+
cell.font = Font(bold=True, color="FFFFFFFF")
|
|
93
|
+
cell.alignment = Alignment(horizontal="center")
|
|
94
|
+
ws.column_dimensions[cell.column_letter].width = max(
|
|
95
|
+
12, min(48, len(str(cols[col - 1])) + 6),
|
|
96
|
+
)
|
|
97
|
+
ws.freeze_panes = "A2"
|
|
98
|
+
return ws
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fill(ws: Worksheet, row: int, col: int, hex_color: str) -> None:
|
|
102
|
+
ws.cell(row=row, column=col).fill = PatternFill("solid", fgColor=hex_color)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_summary(wb: Workbook, state: AppState) -> None:
|
|
106
|
+
ws = wb.create_sheet("Summary")
|
|
107
|
+
ws.column_dimensions["A"].width = 28
|
|
108
|
+
ws.column_dimensions["B"].width = 60
|
|
109
|
+
rows = [
|
|
110
|
+
("Allocation date", state.run_date.isoformat() if state.run_date else "—"),
|
|
111
|
+
("Last run", state.last_run or "—"),
|
|
112
|
+
("Mode", state.mode or "—"),
|
|
113
|
+
("Solver status", state.solver_status or "—"),
|
|
114
|
+
("Duration (s)", round(state.duration_s, 1) if state.duration_s else "—"),
|
|
115
|
+
("", ""),
|
|
116
|
+
("Flights allocated", len(state.allocations)),
|
|
117
|
+
("Flights unallocated", len(state.unallocated)),
|
|
118
|
+
("Staff in workload summary", len(state.workload)),
|
|
119
|
+
("Pairs", len(state.pairs)),
|
|
120
|
+
("Warnings", len(state.warnings)),
|
|
121
|
+
("", ""),
|
|
122
|
+
("Input files", ""),
|
|
123
|
+
]
|
|
124
|
+
for label, value in rows:
|
|
125
|
+
ws.append([label, value])
|
|
126
|
+
if label and not isinstance(value, (int, float)):
|
|
127
|
+
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
|
128
|
+
for up in state.inputs_as_json():
|
|
129
|
+
ws.append([
|
|
130
|
+
f" {up['label']}",
|
|
131
|
+
up["filename"] or "(not uploaded)",
|
|
132
|
+
])
|
|
133
|
+
if state.plan_text:
|
|
134
|
+
ws.append(["", ""])
|
|
135
|
+
ws.append(["Plan summary", ""])
|
|
136
|
+
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
|
137
|
+
for line in state.plan_text.splitlines():
|
|
138
|
+
if line.strip():
|
|
139
|
+
ws.append(["", line])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_allocations(wb: Workbook, state: AppState) -> None:
|
|
143
|
+
by_sheet: dict[AllocationSheet, list[AllocationRow]] = {
|
|
144
|
+
target: [] for target in _ALLOC_SHEET_NAME
|
|
145
|
+
}
|
|
146
|
+
for r in state.allocations:
|
|
147
|
+
by_sheet.setdefault(r.sheet_target, []).append(r)
|
|
148
|
+
for target, name in _ALLOC_SHEET_NAME.items():
|
|
149
|
+
ws = _sheet(wb, name, ALLOCATION_HEADERS)
|
|
150
|
+
for r in by_sheet.get(target, []):
|
|
151
|
+
ws.append([
|
|
152
|
+
r.date.isoformat(),
|
|
153
|
+
r.flt,
|
|
154
|
+
r.dep,
|
|
155
|
+
r.arr,
|
|
156
|
+
r.std.isoformat(timespec="minutes"),
|
|
157
|
+
r.pax,
|
|
158
|
+
r.staff_name,
|
|
159
|
+
r.planned_by_name or "",
|
|
160
|
+
r.relieved_by_name or "",
|
|
161
|
+
r.warning or "",
|
|
162
|
+
])
|
|
163
|
+
row_idx = ws.max_row
|
|
164
|
+
if r.planned_by_employee_id:
|
|
165
|
+
_fill(ws, row_idx, _COL_PLANNED_BY, PLANNED_BY_FILL_HEX)
|
|
166
|
+
if r.relieved_by_employee_id:
|
|
167
|
+
_fill(ws, row_idx, _COL_RELIEVED_BY, RELIEVED_BY_FILL_HEX)
|
|
168
|
+
if r.warning:
|
|
169
|
+
_fill(ws, row_idx, _COL_WARNING, WARNING_FILL_HEX)
|
|
170
|
+
if r.is_international:
|
|
171
|
+
_fill(ws, row_idx, _COL_DEP, INTL_FILL_HEX)
|
|
172
|
+
_fill(ws, row_idx, _COL_ARR, INTL_FILL_HEX)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _write_cleaned(wb: Workbook, state: AppState) -> None:
|
|
176
|
+
for ops, name in _CLEANED_SHEET_NAME.items():
|
|
177
|
+
rows = state.cleaned.get(ops, [])
|
|
178
|
+
if not rows:
|
|
179
|
+
continue
|
|
180
|
+
ws = _sheet(wb, name, CLEANED_HEADERS)
|
|
181
|
+
for r in rows:
|
|
182
|
+
ws.append([
|
|
183
|
+
r.date.isoformat(), r.flt, r.type, r.ac, r.dep, r.arr,
|
|
184
|
+
r.std.isoformat(timespec="minutes"), r.load, r.ops_class.value,
|
|
185
|
+
])
|
|
186
|
+
if r.is_international:
|
|
187
|
+
_fill(ws, ws.max_row, CLEANED_HEADERS.index("dep") + 1,
|
|
188
|
+
INTL_FILL_HEX)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_workbook(state: AppState) -> bytes:
|
|
192
|
+
"""Render the whole state as a single .xlsx and return its bytes."""
|
|
193
|
+
wb = Workbook()
|
|
194
|
+
# Workbook() ships with one default sheet we don't want.
|
|
195
|
+
wb.remove(wb.active)
|
|
196
|
+
|
|
197
|
+
with state.lock:
|
|
198
|
+
_write_summary(wb, state)
|
|
199
|
+
_write_allocations(wb, state)
|
|
200
|
+
|
|
201
|
+
ws = _sheet(wb, "Unallocated", UNALLOCATED_HEADERS)
|
|
202
|
+
for row in state.unallocated:
|
|
203
|
+
ws.append([
|
|
204
|
+
row.get("date", ""), row.get("flt", ""), row.get("dep", ""),
|
|
205
|
+
row.get("arr", ""), row.get("std", ""), row.get("pax", 0),
|
|
206
|
+
row.get("ops_class", ""),
|
|
207
|
+
"Y" if row.get("is_international") else "N",
|
|
208
|
+
row.get("reason", ""),
|
|
209
|
+
])
|
|
210
|
+
for col in range(1, len(UNALLOCATED_HEADERS) + 1):
|
|
211
|
+
_fill(ws, ws.max_row, col, UNALLOCATED_FILL_HEX)
|
|
212
|
+
|
|
213
|
+
ws = _sheet(wb, "Workload", WORKLOAD_HEADERS)
|
|
214
|
+
for r in state.workload:
|
|
215
|
+
ws.append([
|
|
216
|
+
r.employee_id, r.name, r.shift or "", r.role.value,
|
|
217
|
+
r.target_preferred, r.target_acceptable_max, r.hard_cap,
|
|
218
|
+
r.actual, r.deviation_below_preferred,
|
|
219
|
+
r.deviation_above_preferred, "; ".join(r.violations),
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
ws = _sheet(wb, "Pair_Map", PAIR_MAP_HEADERS)
|
|
223
|
+
for p in state.pairs:
|
|
224
|
+
ws.append([
|
|
225
|
+
p.boundary.value, p.pair_role.value, p.preplan_count,
|
|
226
|
+
p.prev_employee_id or "", p.prev_name or "", p.prev_shift or "",
|
|
227
|
+
p.next_employee_id or "", p.next_name or "", p.next_shift or "",
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
ws = _sheet(wb, "Warnings", WARNING_HEADERS)
|
|
231
|
+
for w in state.warnings:
|
|
232
|
+
ws.append([
|
|
233
|
+
w.severity.value, w.code, w.name or "",
|
|
234
|
+
w.date.isoformat() if w.date else "", w.message,
|
|
235
|
+
])
|
|
236
|
+
if w.severity.value in ("WARN", "ERROR"):
|
|
237
|
+
for col in range(1, len(WARNING_HEADERS) + 1):
|
|
238
|
+
_fill(ws, ws.max_row, col, WARNING_FILL_HEX)
|
|
239
|
+
|
|
240
|
+
_write_cleaned(wb, state)
|
|
241
|
+
|
|
242
|
+
ws = _sheet(wb, "Roster", AVAILABILITY_HEADERS)
|
|
243
|
+
for av in state.availability:
|
|
244
|
+
ws.append([
|
|
245
|
+
av.employee_id, av.name, av.role.value, av.date.isoformat(),
|
|
246
|
+
av.status.value, av.raw_status, av.assignable,
|
|
247
|
+
av.current_shift or "", av.license or "",
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
if state.staffing:
|
|
251
|
+
headers = list(state.staffing[0].keys())
|
|
252
|
+
ws = _sheet(wb, "Staffing_Recommendation", headers)
|
|
253
|
+
for row in state.staffing:
|
|
254
|
+
ws.append([row.get(h, "") for h in headers])
|
|
255
|
+
|
|
256
|
+
if state.overrides:
|
|
257
|
+
ws = _sheet(wb, "Overrides", OVERRIDE_HEADERS)
|
|
258
|
+
for row in state.overrides:
|
|
259
|
+
ws.append([row.get(h, "") for h in OVERRIDE_HEADERS])
|
|
260
|
+
|
|
261
|
+
buf = io.BytesIO()
|
|
262
|
+
wb.save(buf)
|
|
263
|
+
wb.close()
|
|
264
|
+
return buf.getvalue()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def filename_for(state: AppState) -> str:
|
|
268
|
+
"""A download name that carries the allocation date."""
|
|
269
|
+
d = state.run_date.isoformat() if state.run_date else "undated"
|
|
270
|
+
return f"flight_allocation_{d}.xlsx"
|
src/io/export_xml.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Build a downloadable .xml of just the combined Allocations rows.
|
|
2
|
+
|
|
3
|
+
Companion to export.py's build_workbook(): that renders the *whole*
|
|
4
|
+
state (Summary, Roster, Warnings, ...) as a multi-sheet .xlsx. This
|
|
5
|
+
renders only the thing most people actually read off after a run —
|
|
6
|
+
flight -> staff, across all three allocation lanes (DayOps, NightOps,
|
|
7
|
+
P2F) — as a single flat XML file. Nothing here is ever read back in.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from xml.dom import minidom
|
|
14
|
+
from xml.etree.ElementTree import Element, ElementTree, SubElement
|
|
15
|
+
|
|
16
|
+
from ..schemas import AllocationSheet
|
|
17
|
+
from ..state import AppState
|
|
18
|
+
|
|
19
|
+
_SHEET_LABEL = {
|
|
20
|
+
AllocationSheet.DAY_OPS: "DayOps",
|
|
21
|
+
AllocationSheet.NIGHT_OPS: "NightOps",
|
|
22
|
+
AllocationSheet.P2F: "P2F",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_allocations_xml(state: AppState) -> bytes:
|
|
27
|
+
"""Render every allocation row (all three sheets, combined) as XML.
|
|
28
|
+
|
|
29
|
+
One <allocation> element per flight, each carrying the same fields
|
|
30
|
+
as an Allocations_* row in the .xlsx (flight, staff, and the
|
|
31
|
+
handover/warning context), plus a ``sheet`` attribute so a reader
|
|
32
|
+
can tell DayOps/NightOps/P2F rows apart without three files.
|
|
33
|
+
"""
|
|
34
|
+
with state.lock:
|
|
35
|
+
run_date = state.run_date.isoformat() if state.run_date else ""
|
|
36
|
+
root = Element("allocations", {"date": run_date})
|
|
37
|
+
for r in state.allocations:
|
|
38
|
+
SubElement(root, "allocation", {
|
|
39
|
+
"sheet": _SHEET_LABEL.get(r.sheet_target, ""),
|
|
40
|
+
"date": r.date.isoformat(),
|
|
41
|
+
"flt": r.flt,
|
|
42
|
+
"dep": r.dep,
|
|
43
|
+
"arr": r.arr,
|
|
44
|
+
"std": r.std.isoformat(timespec="minutes"),
|
|
45
|
+
"pax": str(r.pax),
|
|
46
|
+
"staff": r.staff_name,
|
|
47
|
+
"planned_by": r.planned_by_name or "",
|
|
48
|
+
"relieved_by": r.relieved_by_name or "",
|
|
49
|
+
"warning": r.warning or "",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
buf = io.BytesIO()
|
|
53
|
+
ElementTree(root).write(buf, encoding="utf-8", xml_declaration=True)
|
|
54
|
+
# Re-serialize with indentation — this is meant to be opened
|
|
55
|
+
# directly by a human, not just piped into another program.
|
|
56
|
+
pretty = minidom.parseString(buf.getvalue()).toprettyxml(indent=" ")
|
|
57
|
+
return pretty.encode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def filename_for_xml(state: AppState) -> str:
|
|
61
|
+
"""A download name that carries the allocation date."""
|
|
62
|
+
d = state.run_date.isoformat() if state.run_date else "undated"
|
|
63
|
+
return f"flight_allocation_{d}_allocations.xml"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["build_allocations_xml", "filename_for_xml"]
|