flight-alloc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
src/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"]